gemini-cli/packages/cli/src/ui/hooks/useAtCompletion.ts

239 lines
6.4 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useReducer, useRef } from 'react';
import { Config, FileSearch, escapePath } from '@google/gemini-cli-core';
import {
Suggestion,
MAX_SUGGESTIONS_TO_SHOW,
} from '../components/SuggestionsDisplay.js';
export enum AtCompletionStatus {
IDLE = 'idle',
INITIALIZING = 'initializing',
READY = 'ready',
SEARCHING = 'searching',
ERROR = 'error',
}
interface AtCompletionState {
status: AtCompletionStatus;
suggestions: Suggestion[];
isLoading: boolean;
pattern: string | null;
}
type AtCompletionAction =
| { type: 'INITIALIZE' }
| { type: 'INITIALIZE_SUCCESS' }
| { type: 'SEARCH'; payload: string }
| { type: 'SEARCH_SUCCESS'; payload: Suggestion[] }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'ERROR' }
| { type: 'RESET' };
const initialState: AtCompletionState = {
status: AtCompletionStatus.IDLE,
suggestions: [],
isLoading: false,
pattern: null,
};
function atCompletionReducer(
state: AtCompletionState,
action: AtCompletionAction,
): AtCompletionState {
switch (action.type) {
case 'INITIALIZE':
return {
...state,
status: AtCompletionStatus.INITIALIZING,
isLoading: true,
};
case 'INITIALIZE_SUCCESS':
return { ...state, status: AtCompletionStatus.READY, isLoading: false };
case 'SEARCH':
// Keep old suggestions, don't set loading immediately
return {
...state,
status: AtCompletionStatus.SEARCHING,
pattern: action.payload,
};
case 'SEARCH_SUCCESS':
return {
...state,
status: AtCompletionStatus.READY,
suggestions: action.payload,
isLoading: false,
};
case 'SET_LOADING':
// Only show loading if we are still in a searching state
if (state.status === AtCompletionStatus.SEARCHING) {
return { ...state, isLoading: action.payload, suggestions: [] };
}
return state;
case 'ERROR':
return {
...state,
status: AtCompletionStatus.ERROR,
isLoading: false,
suggestions: [],
};
case 'RESET':
return initialState;
default:
return state;
}
}
export interface UseAtCompletionProps {
enabled: boolean;
pattern: string;
config: Config | undefined;
cwd: string;
setSuggestions: (suggestions: Suggestion[]) => void;
setIsLoadingSuggestions: (isLoading: boolean) => void;
}
export function useAtCompletion(props: UseAtCompletionProps): void {
const {
enabled,
pattern,
config,
cwd,
setSuggestions,
setIsLoadingSuggestions,
} = props;
const [state, dispatch] = useReducer(atCompletionReducer, initialState);
const fileSearch = useRef<FileSearch | null>(null);
const searchAbortController = useRef<AbortController | null>(null);
const slowSearchTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setSuggestions(state.suggestions);
}, [state.suggestions, setSuggestions]);
useEffect(() => {
setIsLoadingSuggestions(state.isLoading);
}, [state.isLoading, setIsLoadingSuggestions]);
useEffect(() => {
dispatch({ type: 'RESET' });
}, [cwd, config]);
// Reacts to user input (`pattern`) ONLY.
useEffect(() => {
if (!enabled) {
// reset when first getting out of completion suggestions
if (
state.status === AtCompletionStatus.READY ||
state.status === AtCompletionStatus.ERROR
) {
dispatch({ type: 'RESET' });
}
return;
}
if (pattern === null) {
dispatch({ type: 'RESET' });
return;
}
if (state.status === AtCompletionStatus.IDLE) {
dispatch({ type: 'INITIALIZE' });
} else if (
(state.status === AtCompletionStatus.READY ||
state.status === AtCompletionStatus.SEARCHING) &&
pattern !== state.pattern // Only search if the pattern has changed
) {
dispatch({ type: 'SEARCH', payload: pattern });
}
}, [enabled, pattern, state.status, state.pattern]);
// The "Worker" that performs async operations based on status.
useEffect(() => {
const initialize = async () => {
try {
const searcher = new FileSearch({
projectRoot: cwd,
ignoreDirs: [],
useGitignore:
config?.getFileFilteringOptions()?.respectGitIgnore ?? true,
useGeminiignore:
config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true,
cache: true,
cacheTtl: 30, // 30 seconds
maxDepth: !(config?.getEnableRecursiveFileSearch() ?? true)
? 0
: undefined,
});
await searcher.initialize();
fileSearch.current = searcher;
dispatch({ type: 'INITIALIZE_SUCCESS' });
if (state.pattern !== null) {
dispatch({ type: 'SEARCH', payload: state.pattern });
}
} catch (_) {
dispatch({ type: 'ERROR' });
}
};
const search = async () => {
if (!fileSearch.current || state.pattern === null) {
return;
}
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
const controller = new AbortController();
searchAbortController.current = controller;
slowSearchTimer.current = setTimeout(() => {
dispatch({ type: 'SET_LOADING', payload: true });
}, 100);
try {
const results = await fileSearch.current.search(state.pattern, {
signal: controller.signal,
maxResults: MAX_SUGGESTIONS_TO_SHOW * 3,
});
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
if (controller.signal.aborted) {
return;
}
const suggestions = results.map((p) => ({
label: p,
value: escapePath(p),
}));
dispatch({ type: 'SEARCH_SUCCESS', payload: suggestions });
} catch (error) {
if (!(error instanceof Error && error.name === 'AbortError')) {
dispatch({ type: 'ERROR' });
}
}
};
if (state.status === AtCompletionStatus.INITIALIZING) {
initialize();
} else if (state.status === AtCompletionStatus.SEARCHING) {
search();
}
return () => {
searchAbortController.current?.abort();
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
};
}, [state.status, state.pattern, config, cwd]);
}