mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bcb98b9d6 | ||
|
|
de17e1baf2 | ||
|
|
d8011adfbd | ||
|
|
53df32e686 | ||
|
|
9c120ed2fa | ||
|
|
a287d303aa | ||
|
|
7185b38a46 | ||
|
|
f06c9aace0 |
No files matched your search
@@ -14,6 +14,12 @@ interface TerminalAutocompleteInputContext {
|
||||
typedInputBufferRef: MutableRefObject<string>;
|
||||
typedBufferReliableRef: MutableRefObject<boolean>;
|
||||
previewActiveRef: MutableRefObject<boolean>;
|
||||
/**
|
||||
* Drop the popup highlight (and cascade panels) without hiding suggestions.
|
||||
* Used when the user keeps typing so Enter cannot accept a stale default
|
||||
* selection during the debounce window.
|
||||
*/
|
||||
clearPopupSelection: () => void;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
hostIdRef: MutableRefObject<string>;
|
||||
hostOsRef: MutableRefObject<"linux" | "windows" | "macos">;
|
||||
@@ -35,6 +41,7 @@ export function handleTerminalAutocompleteInput(
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
clearPopupSelection,
|
||||
termRef,
|
||||
hostIdRef,
|
||||
hostOsRef,
|
||||
@@ -205,6 +212,9 @@ export function handleTerminalAutocompleteInput(
|
||||
// text. Drop preview-active so Escape dismisses the popup without
|
||||
// reverting these edits back to the stale baseline (#1005).
|
||||
previewActiveRef.current = false;
|
||||
// Clear the default/nav highlight immediately so Enter during the
|
||||
// suggestion debounce cannot accept a stale never-previewed row.
|
||||
clearPopupSelection();
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import type { GhostTextAddon } from "./GhostTextAddon";
|
||||
import type { AutocompleteSettings, AutocompleteState, SubDirEntry } from "./useTerminalAutocomplete";
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import { isPreviewlessSelectionCurrentForLine } from "./terminalAutocompleteLayout";
|
||||
|
||||
interface TerminalAutocompleteKeyEventContext {
|
||||
settingsRef: MutableRefObject<AutocompleteSettings>;
|
||||
@@ -10,14 +11,27 @@ interface TerminalAutocompleteKeyEventContext {
|
||||
typedInputBufferRef: MutableRefObject<string>;
|
||||
typedBufferReliableRef: MutableRefObject<boolean>;
|
||||
previewActiveRef: MutableRefObject<boolean>;
|
||||
/**
|
||||
* True once a live preview has rewritten the line for the current popup
|
||||
* cycle. Survives edits that clear `previewActiveRef` so Enter does not
|
||||
* treat an edited preview as a never-previewed default selection.
|
||||
*/
|
||||
previewCommittedRef: MutableRefObject<boolean>;
|
||||
/** Typed input that produced the current popup suggestions. */
|
||||
previewBaselineRef: MutableRefObject<string>;
|
||||
lastAcceptedCommandRef: MutableRefObject<string | null>;
|
||||
setState: Dispatch<SetStateAction<AutocompleteState>>;
|
||||
expandSubDir: (level: number, entry: SubDirEntry, moveFocus?: boolean) => void;
|
||||
writeToTerminal: (text: string) => void;
|
||||
clearState: () => void;
|
||||
/**
|
||||
* Clear cascade panels (and optionally retreat focus) while invalidating
|
||||
* in-flight directory fetches so a late response cannot reinstall them.
|
||||
* Omit `toFocusLevel` to dismiss the whole cascade back to the main list.
|
||||
*/
|
||||
dismissSubDirCascade: (toFocusLevel?: number) => void;
|
||||
renderSubDirPath: (level: number, entry: SubDirEntry) => void;
|
||||
handleSubDirSelect: (level: number, entry: SubDirEntry) => void;
|
||||
fetchSubDirForIndex: (index: number) => void;
|
||||
renderPreviewSelection: (index: number) => void;
|
||||
acceptPreviewlessSelection: (index: number) => boolean;
|
||||
acceptSnippet: (snippet: Snippet) => boolean;
|
||||
@@ -45,14 +59,16 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
previewCommittedRef,
|
||||
previewBaselineRef,
|
||||
lastAcceptedCommandRef,
|
||||
setState,
|
||||
expandSubDir,
|
||||
writeToTerminal,
|
||||
clearState,
|
||||
dismissSubDirCascade,
|
||||
renderSubDirPath,
|
||||
handleSubDirSelect,
|
||||
fetchSubDirForIndex,
|
||||
renderPreviewSelection,
|
||||
acceptPreviewlessSelection,
|
||||
acceptSnippet,
|
||||
@@ -185,6 +201,7 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
// the line and let Tab reach the shell for native completion.
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
@@ -250,14 +267,13 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// Invalidate outstanding listings: Escape keeps the main-row
|
||||
// selection, so an index-only fetch guard would otherwise reinstall
|
||||
// the panels the user just dismissed.
|
||||
if (focusLevel > 0) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
subDirPanels: prev.subDirPanels.slice(0, focusLevel),
|
||||
subDirFocusLevel: focusLevel - 1,
|
||||
}));
|
||||
dismissSubDirCascade(focusLevel - 1);
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, subDirPanels: [], subDirFocusLevel: -1 }));
|
||||
dismissSubDirCascade();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -290,40 +306,79 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
if (settingsRef.current.livePreview) renderPreviewSelection(next);
|
||||
if (next >= 0) fetchSubDirForIndex(next);
|
||||
// Sub-dir cascade prefetch runs from useTerminalAutocomplete after
|
||||
// selectedIndex commits (also covers default-select without ArrowDown).
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter on popup. The selected candidate is already rendered into the
|
||||
// line by live-preview, so let Enter reach the shell. Don't record here:
|
||||
// handleInput's Enter path records the *actual* line — it uses
|
||||
// lastAcceptedCommandRef (set on select) but falls back to the live
|
||||
// buffer when the user edited the previewed command (typing nulls that
|
||||
// ref), so recording stays accurate in both cases.
|
||||
// Enter on popup. When live-preview has already rewritten the line —
|
||||
// or the user edited that preview (previewActive cleared, committed
|
||||
// still set) — let Enter reach the shell. When a row is selected but
|
||||
// never previewed (default first-row highlight, #2821), insert+execute
|
||||
// like the previewless path. Don't record here: handleInput's Enter
|
||||
// path records the *actual* line — it uses lastAcceptedCommandRef
|
||||
// (set on select) but falls back to the live buffer when the user
|
||||
// edited the previewed command (typing nulls that ref).
|
||||
if (isAutocompleteConfirmEnter(e, settingsRef.current)) {
|
||||
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
|
||||
// Never-previewed default/nav selection: typing during the debounce
|
||||
// window leaves selectedIndex on a stale row while both preview refs
|
||||
// stay false. Refuse accept unless the row still matches the fetch
|
||||
// baseline or prefix-completes the edited line.
|
||||
if (
|
||||
selected &&
|
||||
!previewActiveRef.current &&
|
||||
!previewCommittedRef.current &&
|
||||
typedBufferReliableRef.current &&
|
||||
!isPreviewlessSelectionCurrentForLine({
|
||||
currentLine: typedInputBufferRef.current,
|
||||
suggestionBaseline: previewBaselineRef.current,
|
||||
suggestionText: selected.text,
|
||||
suggestionSource: selected.source,
|
||||
})
|
||||
) {
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
if (selected?.source === "snippet" && selected.snippet) {
|
||||
if (!acceptSnippet(selected.snippet)) {
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
e.preventDefault();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return false; // consume — run the snippet, not the typed text
|
||||
}
|
||||
if (!settingsRef.current.livePreview && selected) {
|
||||
if (
|
||||
selected &&
|
||||
settingsRef.current.livePreview &&
|
||||
(previewActiveRef.current || previewCommittedRef.current)
|
||||
) {
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
if (selected) {
|
||||
if (acceptPreviewlessSelection(s.selectedIndex)) {
|
||||
e.preventDefault();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return false;
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -339,6 +394,7 @@ export function handleTerminalAutocompleteKeyEvent(
|
||||
ghost?.hide();
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,89 @@ export function areSuggestionsEqual(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a suggestion refresh should preserve the current popup highlight.
|
||||
*
|
||||
* Equal suggestion rows alone are not enough: typing clears the highlight to
|
||||
* -1 for the debounce window, and a narrowed prefix often yields the same
|
||||
* rows. Preserve only when the input baseline is unchanged (spurious refetch /
|
||||
* ↑↓ baseline slot); otherwise restore the first-row default.
|
||||
*/
|
||||
export function shouldPreserveAutocompletePopupSelection(options: {
|
||||
suggestionsUnchanged: boolean;
|
||||
previousInputBaseline: string;
|
||||
nextInputBaseline: string;
|
||||
}): boolean {
|
||||
return (
|
||||
options.suggestionsUnchanged &&
|
||||
options.previousInputBaseline === options.nextInputBaseline
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial / refreshed popup selection (#2821).
|
||||
* New suggestion lists default to the first row so Enter can confirm without
|
||||
* an ArrowDown. Unchanged lists keep the user's current index (including the
|
||||
* -1 "typed baseline" slot from ↑/↓ wrapping) when
|
||||
* `shouldPreserveAutocompletePopupSelection` is true.
|
||||
*/
|
||||
export function resolveAutocompletePopupSelectedIndex(options: {
|
||||
suggestionCount: number;
|
||||
previousSelectedIndex: number;
|
||||
keepPreviousSelection: boolean;
|
||||
}): number {
|
||||
if (options.suggestionCount <= 0) return -1;
|
||||
if (
|
||||
options.keepPreviousSelection &&
|
||||
options.previousSelectedIndex >= -1 &&
|
||||
options.previousSelectedIndex < options.suggestionCount
|
||||
) {
|
||||
return options.previousSelectedIndex;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a never-previewed popup selection is still safe to accept on Enter.
|
||||
*
|
||||
* Default first-row highlight (#2821) stays selected during the suggestion
|
||||
* debounce window. If the user typed further, accepting would replace the
|
||||
* edited line with a stale row when allowLineReplacement is on. Keep accept
|
||||
* when the line still matches the fetch baseline (incl. fuzzy), or when the
|
||||
* candidate still prefix-completes the edited line.
|
||||
*/
|
||||
export function isPreviewlessSelectionCurrentForLine(options: {
|
||||
currentLine: string;
|
||||
suggestionBaseline: string;
|
||||
suggestionText: string;
|
||||
suggestionSource: string;
|
||||
}): boolean {
|
||||
if (options.currentLine === options.suggestionBaseline) return true;
|
||||
if (options.suggestionSource === "snippet") return false;
|
||||
return options.suggestionText.startsWith(options.currentLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a level-0 directory prefetch may install its panel.
|
||||
*
|
||||
* Index-only checks are not enough after typing replaces the first row:
|
||||
* an older in-flight listing can share index 0 with a newer suggestion.
|
||||
* Require matching fetch generation and suggestion identity too.
|
||||
*/
|
||||
export function shouldApplySubDirPrefetchResult(options: {
|
||||
requestVersion: number;
|
||||
currentVersion: number;
|
||||
requestIndex: number;
|
||||
selectedIndex: number;
|
||||
requestSuggestionText: string;
|
||||
selectedSuggestionText: string | undefined;
|
||||
}): boolean {
|
||||
if (options.requestVersion !== options.currentVersion) return false;
|
||||
if (options.selectedIndex !== options.requestIndex) return false;
|
||||
if (options.selectedSuggestionText === undefined) return false;
|
||||
return options.selectedSuggestionText === options.requestSuggestionText;
|
||||
}
|
||||
|
||||
export function areSubDirPanelsEqual(left: SubDirPanel[], right: SubDirPanel[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
resolveAutocompleteAnchorInViewport,
|
||||
resolveAutocompleteCursorColumn,
|
||||
resolveAutocompleteCwdWithSource,
|
||||
resolveAutocompletePopupSelectedIndex,
|
||||
shouldApplySubDirPrefetchResult,
|
||||
shouldPreserveAutocompletePopupSelection,
|
||||
} from "./terminalAutocompleteLayout";
|
||||
import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
|
||||
import { handleTerminalAutocompleteKeyEvent } from "./terminalAutocompleteKeyEvent";
|
||||
@@ -229,6 +232,12 @@ export function useTerminalAutocomplete(
|
||||
const previewBaselineRef = useRef<string>("");
|
||||
/** Whether a popup candidate is currently rendered into the command line (#1005). */
|
||||
const previewActiveRef = useRef(false);
|
||||
/**
|
||||
* Whether a live preview has rewritten the line for the current popup cycle.
|
||||
* Stays true after the user edits the previewed text (which clears
|
||||
* previewActiveRef) so Enter does not accept the stale selected row.
|
||||
*/
|
||||
const previewCommittedRef = useRef(false);
|
||||
/** Monotonic counter to invalidate stale async sub-dir fetches */
|
||||
const subDirFetchVersionRef = useRef(0);
|
||||
/**
|
||||
@@ -352,11 +361,64 @@ export function useTerminalAutocomplete(
|
||||
// Bump version to invalidate any in-flight async completions
|
||||
fetchVersionRef.current++;
|
||||
subDirFetchVersionRef.current++;
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
setState((prev) =>
|
||||
prev.popupVisible || prev.suggestions.length > 0 ? { ...EMPTY_STATE } : prev,
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear the popup highlight (and cascade) while keeping suggestions visible.
|
||||
* Syncs stateRef immediately so a same-tick Enter cannot accept a stale row.
|
||||
*/
|
||||
const clearPopupSelection = useCallback(() => {
|
||||
const prev = stateRef.current;
|
||||
if (
|
||||
!prev.popupVisible ||
|
||||
(prev.selectedIndex < 0 && prev.subDirPanels.length === 0 && prev.subDirFocusLevel < 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Drop in-flight level-0 fetches so a stale directory cannot install after
|
||||
// the next refresh defaults back to index 0 for a different first row.
|
||||
subDirFetchVersionRef.current++;
|
||||
const next = {
|
||||
...prev,
|
||||
selectedIndex: -1,
|
||||
subDirPanels: [] as typeof prev.subDirPanels,
|
||||
subDirFocusLevel: -1,
|
||||
};
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Dismiss cascade panels (Escape) while keeping the main popup selection.
|
||||
* Always bumps the fetch generation so a late listing cannot reopen panels.
|
||||
*/
|
||||
const dismissSubDirCascade = useCallback((toFocusLevel?: number) => {
|
||||
subDirFetchVersionRef.current++;
|
||||
setState((prev) => {
|
||||
if (toFocusLevel !== undefined && toFocusLevel >= 0) {
|
||||
const nextPanels = prev.subDirPanels.slice(0, toFocusLevel + 1);
|
||||
if (
|
||||
prev.subDirFocusLevel === toFocusLevel &&
|
||||
areSubDirPanelsEqual(prev.subDirPanels, nextPanels)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
subDirPanels: nextPanels,
|
||||
subDirFocusLevel: toFocusLevel,
|
||||
};
|
||||
}
|
||||
if (prev.subDirPanels.length === 0 && prev.subDirFocusLevel < 0) return prev;
|
||||
return { ...prev, subDirPanels: [], subDirFocusLevel: -1 };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const repositionPopup = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
@@ -439,11 +501,23 @@ export function useTerminalAutocomplete(
|
||||
if (!dirPath) return;
|
||||
|
||||
const requestVersion = ++subDirFetchVersionRef.current;
|
||||
const requestSuggestionText = item.text;
|
||||
fetchDirEntries(dirPath).then((entries) => {
|
||||
if (requestVersion !== subDirFetchVersionRef.current) return;
|
||||
startTransition(() => {
|
||||
setState((prev) => {
|
||||
if (prev.selectedIndex !== index) return prev;
|
||||
if (
|
||||
!shouldApplySubDirPrefetchResult({
|
||||
requestVersion,
|
||||
currentVersion: subDirFetchVersionRef.current,
|
||||
requestIndex: index,
|
||||
selectedIndex: prev.selectedIndex,
|
||||
requestSuggestionText,
|
||||
selectedSuggestionText: prev.suggestions[index]?.text,
|
||||
})
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const nextPanels = entries.length > 0 ? [{ entries, selectedIndex: -1, dirPath }] : [];
|
||||
if (
|
||||
prev.subDirFocusLevel === -1 &&
|
||||
@@ -580,6 +654,7 @@ export function useTerminalAutocomplete(
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
previewActiveRef.current = true;
|
||||
previewCommittedRef.current = true;
|
||||
lastAcceptedCommandRef.current = newCommand;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
@@ -764,8 +839,12 @@ export function useTerminalAutocomplete(
|
||||
// Popup
|
||||
if (settingsRef.current.showPopupMenu && completions.length > 0) {
|
||||
// Live-preview baseline: the typed input these suggestions completed.
|
||||
// Capture the prior baseline first — equal suggestion rows after typing
|
||||
// must not preserve the debounce clear's programmatic -1.
|
||||
const previousInputBaseline = previewBaselineRef.current;
|
||||
previewBaselineRef.current = input;
|
||||
previewActiveRef.current = false;
|
||||
previewCommittedRef.current = false;
|
||||
const cursorColumn = resolveAutocompleteCursorColumn(term, currentPrompt);
|
||||
const anchor = resolveAutocompleteAnchorInViewport(
|
||||
term,
|
||||
@@ -777,9 +856,23 @@ export function useTerminalAutocomplete(
|
||||
setState((prev) => {
|
||||
if (version !== fetchVersionRef.current) return prev;
|
||||
|
||||
const suggestionsUnchanged =
|
||||
prev.popupVisible && areSuggestionsEqual(prev.suggestions, completions);
|
||||
const keepPreviousSelection = shouldPreserveAutocompletePopupSelection({
|
||||
suggestionsUnchanged,
|
||||
previousInputBaseline,
|
||||
nextInputBaseline: input,
|
||||
});
|
||||
const selectedIndex = resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: completions.length,
|
||||
previousSelectedIndex: prev.selectedIndex,
|
||||
keepPreviousSelection,
|
||||
});
|
||||
const keepSubDirPanels =
|
||||
suggestionsUnchanged && selectedIndex === prev.selectedIndex;
|
||||
const nextState: AutocompleteState = {
|
||||
suggestions: completions,
|
||||
selectedIndex: -1,
|
||||
selectedIndex,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: {
|
||||
left: anchor.anchorLeft,
|
||||
@@ -787,8 +880,11 @@ export function useTerminalAutocomplete(
|
||||
bottom: anchor.anchorBottom,
|
||||
},
|
||||
expandUpward: anchor.expandUpward,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
// Fresh lists (or a selection change) drop cascade panels.
|
||||
// Unchanged selection keeps them so a spurious refetch does not
|
||||
// collapse an open directory tree.
|
||||
subDirPanels: keepSubDirPanels ? prev.subDirPanels : [],
|
||||
subDirFocusLevel: keepSubDirPanels ? prev.subDirFocusLevel : -1,
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -798,9 +894,9 @@ export function useTerminalAutocomplete(
|
||||
prev.popupAnchorViewport.left === nextState.popupAnchorViewport.left &&
|
||||
prev.popupAnchorViewport.top === nextState.popupAnchorViewport.top &&
|
||||
prev.popupAnchorViewport.bottom === nextState.popupAnchorViewport.bottom &&
|
||||
prev.subDirFocusLevel === -1 &&
|
||||
prev.subDirPanels.length === 0 &&
|
||||
areSuggestionsEqual(prev.suggestions, nextState.suggestions)
|
||||
prev.subDirFocusLevel === nextState.subDirFocusLevel &&
|
||||
areSubDirPanelsEqual(prev.subDirPanels, nextState.subDirPanels) &&
|
||||
suggestionsUnchanged
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
@@ -835,6 +931,7 @@ export function useTerminalAutocomplete(
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
clearPopupSelection,
|
||||
termRef,
|
||||
hostIdRef,
|
||||
hostOsRef,
|
||||
@@ -844,7 +941,7 @@ export function useTerminalAutocomplete(
|
||||
fetchSuggestions,
|
||||
});
|
||||
},
|
||||
[fetchSuggestions, termRef, clearState],
|
||||
[fetchSuggestions, termRef, clearState, clearPopupSelection],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -859,14 +956,16 @@ export function useTerminalAutocomplete(
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
previewCommittedRef,
|
||||
previewBaselineRef,
|
||||
lastAcceptedCommandRef,
|
||||
setState,
|
||||
expandSubDir,
|
||||
writeToTerminal,
|
||||
clearState,
|
||||
dismissSubDirCascade,
|
||||
renderSubDirPath,
|
||||
handleSubDirSelect,
|
||||
fetchSubDirForIndex,
|
||||
renderPreviewSelection,
|
||||
acceptPreviewlessSelection,
|
||||
acceptSnippet,
|
||||
@@ -875,6 +974,28 @@ export function useTerminalAutocomplete(
|
||||
[writeToTerminal],
|
||||
);
|
||||
|
||||
// Prefetch directory cascade panels after selectedIndex commits. Covers both
|
||||
// default-select (#2821) and ↑/↓ navigation. Must run post-commit: calling
|
||||
// from the startTransition refresh path races when fetchDirEntries resolves
|
||||
// before selectedIndex has flushed, and the result handler drops the panels.
|
||||
// Depend only on selection/list identity — not cascade emptiness — so Escape
|
||||
// dismissing level-0 panels does not immediately re-prefetch and reopen them.
|
||||
useEffect(() => {
|
||||
if (!state.popupVisible || state.selectedIndex < 0) return;
|
||||
if (state.selectedIndex >= state.suggestions.length) return;
|
||||
// Skip when cascade state already exists so an unchanged suggestion
|
||||
// refresh (new array identity, same rows) does not wipe nested panels.
|
||||
if (state.subDirPanels.length > 0 || state.subDirFocusLevel >= 0) return;
|
||||
fetchSubDirForIndex(state.selectedIndex);
|
||||
// Cascade emptiness is a skip guard only — omit from deps so Escape dismissal sticks.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- selection/list only
|
||||
}, [
|
||||
state.popupVisible,
|
||||
state.selectedIndex,
|
||||
state.suggestions,
|
||||
fetchSubDirForIndex,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Render the suggestion at `index` straight into the command line (Termius
|
||||
* live-preview, #1005). `index < 0` restores the user's typed baseline.
|
||||
@@ -906,6 +1027,7 @@ export function useTerminalAutocomplete(
|
||||
typedBufferReliableRef.current = true;
|
||||
const isPreview = index >= 0 && candidate !== baseline;
|
||||
previewActiveRef.current = isPreview;
|
||||
if (isPreview) previewCommittedRef.current = true;
|
||||
lastAcceptedCommandRef.current = isPreview ? candidate : null;
|
||||
// Live-preview can move/wrap the cursor. Recompute the anchor after xterm
|
||||
// has processed the write so the popup doesn't drift or flip into a stale
|
||||
|
||||
@@ -46,6 +46,7 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
const previews: number[] = [];
|
||||
const accepted: number[] = [];
|
||||
const clears: number[] = [];
|
||||
const dismissals: Array<number | undefined> = [];
|
||||
const stateRef = { current: state };
|
||||
|
||||
return {
|
||||
@@ -53,6 +54,8 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
previews,
|
||||
accepted,
|
||||
clears,
|
||||
dismissals,
|
||||
getState: () => state,
|
||||
context: {
|
||||
settingsRef: {
|
||||
current: {
|
||||
@@ -72,6 +75,8 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
typedInputBufferRef: { current: "sh" },
|
||||
typedBufferReliableRef: { current: true },
|
||||
previewActiveRef: { current: false },
|
||||
previewCommittedRef: { current: false },
|
||||
previewBaselineRef: { current: "sh" },
|
||||
lastAcceptedCommandRef: { current: null },
|
||||
setState(update: typeof state | ((prev: typeof state) => typeof state)) {
|
||||
state = typeof update === "function" ? update(state) : update;
|
||||
@@ -80,9 +85,9 @@ function createContext(overrides: Record<string, unknown> = {}) {
|
||||
expandSubDir() {},
|
||||
writeToTerminal(text: string) { writes.push(text); },
|
||||
clearState() { clears.push(1); },
|
||||
dismissSubDirCascade(toFocusLevel?: number) { dismissals.push(toFocusLevel); },
|
||||
renderSubDirPath() {},
|
||||
handleSubDirSelect() {},
|
||||
fetchSubDirForIndex() {},
|
||||
renderPreviewSelection(index: number) { previews.push(index); },
|
||||
acceptSnippet() { return true; },
|
||||
acceptPreviewlessSelection(index: number) { accepted.push(index); return true; },
|
||||
@@ -202,3 +207,225 @@ test("serial-style popup Shift+Enter confirms candidate when terminal shortcut i
|
||||
assert.deepEqual(accepted, [0]);
|
||||
assert.deepEqual(clears, []);
|
||||
});
|
||||
|
||||
test("live-preview Enter accepts the default-selected candidate when it is not previewed yet", () => {
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("show version")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.previewActiveRef.current = false;
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(accepted, [0]);
|
||||
assert.deepEqual(clears, []);
|
||||
});
|
||||
|
||||
test("live-preview Enter passes through a stale default selection after further typing", () => {
|
||||
// Default first-row highlight is never previewed. Typing during the
|
||||
// debounce window leaves selectedIndex on the old row while both preview
|
||||
// refs stay false — Enter must not wipe the edited line via line replacement.
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("git status")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.settingsRef.current.allowLineReplacement = true;
|
||||
context.previewActiveRef.current = false;
|
||||
context.previewCommittedRef.current = false;
|
||||
context.previewBaselineRef.current = "gst";
|
||||
context.typedInputBufferRef.current = "gstx";
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.deepEqual(accepted, []);
|
||||
assert.deepEqual(clears, [1]);
|
||||
});
|
||||
|
||||
test("live-preview Enter still accepts default selection that completes edited prefix", () => {
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("show version")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.previewActiveRef.current = false;
|
||||
context.previewCommittedRef.current = false;
|
||||
context.previewBaselineRef.current = "sh";
|
||||
context.typedInputBufferRef.current = "sho";
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(accepted, [0]);
|
||||
assert.deepEqual(clears, []);
|
||||
});
|
||||
|
||||
test("live-preview Enter still passes through when the candidate is already on the line", () => {
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("show version")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.previewActiveRef.current = true;
|
||||
context.previewCommittedRef.current = true;
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.deepEqual(accepted, []);
|
||||
assert.deepEqual(clears, [1]);
|
||||
});
|
||||
|
||||
test("live-preview Enter passes through an edited preview instead of accepting the old row", () => {
|
||||
// Arrow navigation live-previewed a row, then the user typed (clears
|
||||
// previewActive) before debounce refresh. Enter must keep the edited
|
||||
// line — not wipe it via acceptPreviewlessSelection.
|
||||
const { context, accepted, clears } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [suggestion("show version")],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [],
|
||||
subDirFocusLevel: -1,
|
||||
},
|
||||
},
|
||||
});
|
||||
context.settingsRef.current.livePreview = true;
|
||||
context.previewActiveRef.current = false;
|
||||
context.previewCommittedRef.current = true;
|
||||
context.typedInputBufferRef.current = "show version --short";
|
||||
const event = keyEvent("Enter");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(event.defaultPrevented, false);
|
||||
assert.deepEqual(accepted, []);
|
||||
assert.deepEqual(clears, [1]);
|
||||
});
|
||||
|
||||
test("Escape at level 0 dismisses cascade via dismissSubDirCascade so in-flight fetches cannot reinstall", () => {
|
||||
// Escape clears panels while keeping the main-row selection. Without
|
||||
// invalidating outstanding directory fetches, a late response would
|
||||
// reinstall the panel and undo the dismissal.
|
||||
const { context, dismissals } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [{
|
||||
text: "cd docs/",
|
||||
displayText: "docs/",
|
||||
source: "path" as const,
|
||||
score: 1,
|
||||
fileType: "directory" as const,
|
||||
}],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [{
|
||||
entries: [{ name: "src", type: "directory" as const }],
|
||||
selectedIndex: -1,
|
||||
dirPath: "/home/docs",
|
||||
}],
|
||||
subDirFocusLevel: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = keyEvent("Escape");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(dismissals, [undefined]);
|
||||
});
|
||||
|
||||
test("Escape at nested level dismisses via dismissSubDirCascade with prior focus level", () => {
|
||||
const { context, dismissals } = createContext({
|
||||
stateRef: {
|
||||
current: {
|
||||
suggestions: [{
|
||||
text: "cd docs/",
|
||||
displayText: "docs/",
|
||||
source: "path" as const,
|
||||
score: 1,
|
||||
fileType: "directory" as const,
|
||||
}],
|
||||
selectedIndex: 0,
|
||||
popupVisible: true,
|
||||
popupAnchorViewport: { left: 0, top: 0, bottom: 0 },
|
||||
expandUpward: false,
|
||||
subDirPanels: [
|
||||
{
|
||||
entries: [{ name: "src", type: "directory" as const }],
|
||||
selectedIndex: 0,
|
||||
dirPath: "/home/docs",
|
||||
},
|
||||
{
|
||||
entries: [{ name: "main.ts", type: "file" as const }],
|
||||
selectedIndex: 0,
|
||||
dirPath: "/home/docs/src",
|
||||
},
|
||||
],
|
||||
subDirFocusLevel: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = keyEvent("Escape");
|
||||
|
||||
const result = handleTerminalAutocompleteKeyEvent(event, context);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(event.defaultPrevented, true);
|
||||
assert.deepEqual(dismissals, [0]);
|
||||
});
|
||||
@@ -1,12 +1,17 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
clampAutocompletePopupGeometry,
|
||||
computeAutocompletePopupPlacement,
|
||||
isPreviewlessSelectionCurrentForLine,
|
||||
resolveAutocompleteAnchorInViewport,
|
||||
resolveAutocompleteClampViewport,
|
||||
resolveAutocompleteCursorColumn,
|
||||
resolveAutocompletePopupSelectedIndex,
|
||||
shouldApplySubDirPrefetchResult,
|
||||
shouldPreserveAutocompletePopupSelection,
|
||||
type PopupPlacementInput,
|
||||
} from "./autocomplete/terminalAutocompleteLayout.ts";
|
||||
|
||||
@@ -501,3 +506,207 @@ test("resolveAutocompleteAnchorInViewport ignores the helper textarea horizontal
|
||||
assert.equal(anchor.anchorLeft, 640 + cellWidth * cursorColumn);
|
||||
assert.notEqual(anchor.anchorLeft, textarea.getBoundingClientRect().left);
|
||||
});
|
||||
|
||||
test("resolveAutocompletePopupSelectedIndex defaults to the first suggestion", () => {
|
||||
assert.equal(
|
||||
resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: 3,
|
||||
previousSelectedIndex: -1,
|
||||
keepPreviousSelection: false,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldPreserveAutocompletePopupSelection requires an unchanged input baseline", () => {
|
||||
assert.equal(
|
||||
shouldPreserveAutocompletePopupSelection({
|
||||
suggestionsUnchanged: true,
|
||||
previousInputBaseline: "cd do",
|
||||
nextInputBaseline: "cd do",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Typing clears selection to -1; equal rows after a narrower prefix must
|
||||
// restore the first-row default instead of preserving that programmatic -1.
|
||||
assert.equal(
|
||||
shouldPreserveAutocompletePopupSelection({
|
||||
suggestionsUnchanged: true,
|
||||
previousInputBaseline: "cd do",
|
||||
nextInputBaseline: "cd doc",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPreserveAutocompletePopupSelection({
|
||||
suggestionsUnchanged: false,
|
||||
previousInputBaseline: "cd do",
|
||||
nextInputBaseline: "cd do",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveAutocompletePopupSelectedIndex keeps navigation when suggestions are unchanged", () => {
|
||||
assert.equal(
|
||||
resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: 3,
|
||||
previousSelectedIndex: 2,
|
||||
keepPreviousSelection: true,
|
||||
}),
|
||||
2,
|
||||
);
|
||||
assert.equal(
|
||||
resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: 3,
|
||||
previousSelectedIndex: -1,
|
||||
keepPreviousSelection: true,
|
||||
}),
|
||||
-1,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveAutocompletePopupSelectedIndex clamps a stale selection when the list shrinks", () => {
|
||||
assert.equal(
|
||||
resolveAutocompletePopupSelectedIndex({
|
||||
suggestionCount: 1,
|
||||
previousSelectedIndex: 4,
|
||||
keepPreviousSelection: true,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("isPreviewlessSelectionCurrentForLine keeps fetch-baseline fuzzy rows", () => {
|
||||
assert.equal(
|
||||
isPreviewlessSelectionCurrentForLine({
|
||||
currentLine: "gst",
|
||||
suggestionBaseline: "gst",
|
||||
suggestionText: "git status",
|
||||
suggestionSource: "history",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isPreviewlessSelectionCurrentForLine rejects edits that no longer complete", () => {
|
||||
assert.equal(
|
||||
isPreviewlessSelectionCurrentForLine({
|
||||
currentLine: "gstx",
|
||||
suggestionBaseline: "gst",
|
||||
suggestionText: "git status",
|
||||
suggestionSource: "history",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isPreviewlessSelectionCurrentForLine keeps prefix progress during debounce", () => {
|
||||
assert.equal(
|
||||
isPreviewlessSelectionCurrentForLine({
|
||||
currentLine: "sho",
|
||||
suggestionBaseline: "sh",
|
||||
suggestionText: "show version",
|
||||
suggestionSource: "history",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("selected popup row prefetches sub-dir panels after selectedIndex commits", () => {
|
||||
// Default-select (#2821) and ↑/↓ must prefetch via a post-commit effect.
|
||||
// Prefetching inside the startTransition refresh path races: fetchDirEntries
|
||||
// can resolve before selectedIndex flushes, and the result handler drops the
|
||||
// panels when prev.selectedIndex still mismatches.
|
||||
const source = readFileSync(
|
||||
new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/useEffect\(\(\) => \{\s*if \(!state\.popupVisible \|\| state\.selectedIndex < 0\) return;[\s\S]*if \(state\.subDirPanels\.length > 0 \|\| state\.subDirFocusLevel >= 0\) return;[\s\S]*fetchSubDirForIndex\(state\.selectedIndex\);/,
|
||||
);
|
||||
// Escape clears subDirPanels without changing selection; if cascade emptiness
|
||||
// is a dependency, the effect re-prefetches and the dismissal never sticks.
|
||||
const effectMatch = source.match(
|
||||
/useEffect\(\(\) => \{\s*if \(!state\.popupVisible \|\| state\.selectedIndex < 0\) return;[\s\S]*?\},\s*\[([\s\S]*?)\]\);/,
|
||||
);
|
||||
assert.ok(effectMatch, "expected sub-dir prefetch useEffect deps");
|
||||
const deps = effectMatch[1] ?? "";
|
||||
assert.match(deps, /state\.popupVisible/);
|
||||
assert.match(deps, /state\.selectedIndex/);
|
||||
assert.match(deps, /state\.suggestions/);
|
||||
assert.match(deps, /fetchSubDirForIndex/);
|
||||
assert.doesNotMatch(deps, /state\.subDirPanels/);
|
||||
assert.doesNotMatch(deps, /state\.subDirFocusLevel/);
|
||||
});
|
||||
|
||||
test("typing clear invalidates in-flight sub-dir fetches and restores default on baseline change", () => {
|
||||
const source = readFileSync(
|
||||
new URL("./autocomplete/useTerminalAutocomplete.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const clearPopupSelection = useCallback\(\(\) => \{[\s\S]*?subDirFetchVersionRef\.current\+\+;[\s\S]*?selectedIndex: -1,/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/shouldPreserveAutocompletePopupSelection\(\{\s*suggestionsUnchanged,\s*previousInputBaseline,\s*nextInputBaseline: input,/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const dismissSubDirCascade = useCallback\(\([\s\S]*?subDirFetchVersionRef\.current\+\+;/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/shouldApplySubDirPrefetchResult\(\{/,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldApplySubDirPrefetchResult rejects stale version, index, or suggestion identity", () => {
|
||||
assert.equal(
|
||||
shouldApplySubDirPrefetchResult({
|
||||
requestVersion: 3,
|
||||
currentVersion: 3,
|
||||
requestIndex: 0,
|
||||
selectedIndex: 0,
|
||||
requestSuggestionText: "cd docs/",
|
||||
selectedSuggestionText: "cd docs/",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldApplySubDirPrefetchResult({
|
||||
requestVersion: 3,
|
||||
currentVersion: 4,
|
||||
requestIndex: 0,
|
||||
selectedIndex: 0,
|
||||
requestSuggestionText: "cd docs/",
|
||||
selectedSuggestionText: "cd docs/",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldApplySubDirPrefetchResult({
|
||||
requestVersion: 3,
|
||||
currentVersion: 3,
|
||||
requestIndex: 0,
|
||||
selectedIndex: 1,
|
||||
requestSuggestionText: "cd docs/",
|
||||
selectedSuggestionText: "cd docs/",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldApplySubDirPrefetchResult({
|
||||
requestVersion: 3,
|
||||
currentVersion: 3,
|
||||
requestIndex: 0,
|
||||
selectedIndex: 0,
|
||||
requestSuggestionText: "cd docs/",
|
||||
selectedSuggestionText: "cd src/",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user