fix(cli): Shell autocomplete polish (#20411)

This commit is contained in:
Jacob Richman
2026-02-27 11:03:37 -08:00
committed by GitHub
parent b2cbf518e8
commit e00e8f4728
5 changed files with 183 additions and 87 deletions

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useRef, useCallback } from 'react';
import { useEffect, useRef, useCallback, useMemo } from 'react';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
@@ -196,6 +196,60 @@ export async function scanPathExecutables(
const seen = new Set<string>();
const executables: string[] = [];
// Add Windows shell built-ins
if (isWindows) {
const builtins = [
'assoc',
'break',
'call',
'cd',
'chcp',
'chdir',
'cls',
'color',
'copy',
'date',
'del',
'dir',
'echo',
'endlocal',
'erase',
'exit',
'for',
'ftype',
'goto',
'if',
'md',
'mkdir',
'mklink',
'move',
'path',
'pause',
'popd',
'prompt',
'pushd',
'rd',
'rem',
'ren',
'rename',
'rmdir',
'set',
'setlocal',
'shift',
'start',
'time',
'title',
'type',
'ver',
'verify',
'vol',
];
for (const builtin of builtins) {
seen.add(builtin);
executables.push(builtin);
}
}
const dirResults = await Promise.all(
dirs.map(async (dir) => {
if (signal?.aborted) return [];
@@ -365,16 +419,10 @@ export async function resolvePathCompletions(
export interface UseShellCompletionProps {
/** Whether shell completion is active. */
enabled: boolean;
/** The partial query string (the token under the cursor). */
query: string;
/** Whether the token is in command position (first word). */
isCommandPosition: boolean;
/** The full list of parsed tokens */
tokens: string[];
/** The cursor index in the full list of parsed tokens */
cursorIndex: number;
/** The root command token */
commandToken: string;
/** The current line text. */
line: string;
/** The current cursor column. */
cursorCol: number;
/** The current working directory for path resolution. */
cwd: string;
/** Callback to set suggestions on the parent state. */
@@ -383,33 +431,53 @@ export interface UseShellCompletionProps {
setIsLoadingSuggestions: (isLoading: boolean) => void;
}
export interface UseShellCompletionReturn {
completionStart: number;
completionEnd: number;
query: string;
}
const EMPTY_TOKENS: string[] = [];
export function useShellCompletion({
enabled,
query,
isCommandPosition,
tokens,
cursorIndex,
commandToken,
line,
cursorCol,
cwd,
setSuggestions,
setIsLoadingSuggestions,
}: UseShellCompletionProps): void {
const pathCacheRef = useRef<string[] | null>(null);
}: UseShellCompletionProps): UseShellCompletionReturn {
const pathCachePromiseRef = useRef<Promise<string[]> | null>(null);
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tokenInfo = useMemo(
() => (enabled ? getTokenAtCursor(line, cursorCol) : null),
[enabled, line, cursorCol],
);
const {
token: query = '',
start: completionStart = -1,
end: completionEnd = -1,
isFirstToken: isCommandPosition = false,
tokens = EMPTY_TOKENS,
cursorIndex = -1,
commandToken = '',
} = tokenInfo || {};
// Invalidate PATH cache when $PATH changes
useEffect(() => {
const currentPath = process.env['PATH'] ?? '';
if (currentPath !== pathEnvRef.current) {
pathCacheRef.current = null;
pathCachePromiseRef.current = null;
pathEnvRef.current = currentPath;
}
});
const performCompletion = useCallback(async () => {
if (!enabled) {
if (!enabled || !tokenInfo) {
setSuggestions([]);
return;
}
@@ -434,15 +502,25 @@ export function useShellCompletion({
if (isCommandPosition) {
setIsLoadingSuggestions(true);
if (!pathCacheRef.current) {
pathCacheRef.current = await scanPathExecutables(signal);
if (!pathCachePromiseRef.current) {
// We don't pass the signal here because we want the cache to finish
// even if this specific completion request is aborted.
pathCachePromiseRef.current = scanPathExecutables();
}
const executables = await pathCachePromiseRef.current;
if (signal.aborted) return;
const queryLower = query.toLowerCase();
results = pathCacheRef.current
results = executables
.filter((cmd) => cmd.toLowerCase().startsWith(queryLower))
.sort((a, b) => {
// Prioritize shorter commands as they are likely common built-ins
if (a.length !== b.length) {
return a.length - b.length;
}
return a.localeCompare(b);
})
.slice(0, MAX_SHELL_SUGGESTIONS)
.map((cmd) => ({
label: cmd,
@@ -501,6 +579,7 @@ export function useShellCompletion({
}
}, [
enabled,
tokenInfo,
query,
isCommandPosition,
tokens,
@@ -511,13 +590,17 @@ export function useShellCompletion({
setIsLoadingSuggestions,
]);
// Debounced effect to trigger completion
useEffect(() => {
if (!enabled) {
abortRef.current?.abort();
setSuggestions([]);
setIsLoadingSuggestions(false);
return;
}
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
// Debounced effect to trigger completion
useEffect(() => {
if (!enabled) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
@@ -533,7 +616,7 @@ export function useShellCompletion({
clearTimeout(debounceRef.current);
}
};
}, [enabled, performCompletion, setSuggestions, setIsLoadingSuggestions]);
}, [enabled, performCompletion]);
// Cleanup on unmount
useEffect(
@@ -545,4 +628,10 @@ export function useShellCompletion({
},
[],
);
return {
completionStart,
completionEnd,
query,
};
}