diff options
Diffstat (limited to 'packages/cli/src/ui/hooks/useAtCompletion.ts')
| -rw-r--r-- | packages/cli/src/ui/hooks/useAtCompletion.ts | 228 |
1 files changed, 228 insertions, 0 deletions
diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts new file mode 100644 index 00000000..eaa2a5e6 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -0,0 +1,228 @@ +/** + * @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) { + 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 + }); + 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]); +} |
