/** * @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(null); const searchAbortController = useRef(null); const slowSearchTimer = useRef(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]); }