| |
| |
| |
| |
| |
|
|
| import { useCallback, useEffect, useReducer, useRef } from 'react'; |
| import { setTimeout as setTimeoutPromise } from 'node:timers/promises'; |
| import * as path from 'node:path'; |
| import { |
| FileSearchFactory, |
| escapePath, |
| FileDiscoveryService, |
| type Config, |
| type FileSearch, |
| } from '@google/gemini-cli-core'; |
| import { |
| MAX_SUGGESTIONS_TO_SHOW, |
| type Suggestion, |
| } from '../components/SuggestionsDisplay.js'; |
| import { CommandKind } from '../commands/types.js'; |
| import { AsyncFzf } from 'fzf'; |
|
|
| const DEFAULT_SEARCH_TIMEOUT_MS = 5000; |
|
|
| 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': |
| |
| return { |
| ...state, |
| status: AtCompletionStatus.SEARCHING, |
| pattern: action.payload, |
| }; |
| case 'SEARCH_SUCCESS': |
| return { |
| ...state, |
| status: AtCompletionStatus.READY, |
| suggestions: action.payload, |
| isLoading: false, |
| }; |
| case 'SET_LOADING': |
| |
| 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; |
| } |
|
|
| interface ResourceSuggestionCandidate { |
| searchKey: string; |
| suggestion: Suggestion; |
| } |
|
|
| function buildResourceCandidates( |
| config?: Config, |
| ): ResourceSuggestionCandidate[] { |
| const registry = config?.getResourceRegistry?.(); |
| if (!registry) { |
| return []; |
| } |
|
|
| const resources = registry.getAllResources().map((resource) => { |
| |
| const prefixedUri = `${resource.serverName}:${resource.uri}`; |
| return { |
| |
| searchKey: `${prefixedUri} ${resource.name ?? ''}`.toLowerCase(), |
| suggestion: { |
| label: prefixedUri, |
| value: prefixedUri, |
| }, |
| } satisfies ResourceSuggestionCandidate; |
| }); |
|
|
| return resources; |
| } |
|
|
| function buildAgentCandidates(config?: Config): Suggestion[] { |
| const registry = config?.getAgentRegistry?.(); |
| if (!registry) { |
| return []; |
| } |
| return registry.getAllDefinitions().map((def) => ({ |
| label: def.name, |
| value: def.name, |
| commandKind: CommandKind.AGENT, |
| })); |
| } |
|
|
| async function searchResourceCandidates( |
| pattern: string, |
| candidates: ResourceSuggestionCandidate[], |
| ): Promise<Suggestion[]> { |
| if (candidates.length === 0) { |
| return []; |
| } |
|
|
| const normalizedPattern = pattern.toLowerCase(); |
| if (!normalizedPattern) { |
| return candidates |
| .slice(0, MAX_SUGGESTIONS_TO_SHOW) |
| .map((candidate) => candidate.suggestion); |
| } |
|
|
| const fzf = new AsyncFzf(candidates, { |
| selector: (candidate: ResourceSuggestionCandidate) => candidate.searchKey, |
| }); |
| |
| const results: Array<{ item: ResourceSuggestionCandidate }> = await fzf.find( |
| normalizedPattern, |
| { |
| limit: MAX_SUGGESTIONS_TO_SHOW * 3, |
| }, |
| ); |
| return results.map((result) => result.item.suggestion); |
| } |
|
|
| async function searchAgentCandidates( |
| pattern: string, |
| candidates: Suggestion[], |
| ): Promise<Suggestion[]> { |
| if (candidates.length === 0) { |
| return []; |
| } |
| const normalizedPattern = pattern.toLowerCase(); |
| if (!normalizedPattern) { |
| return candidates.slice(0, MAX_SUGGESTIONS_TO_SHOW); |
| } |
| const fzf = new AsyncFzf(candidates, { |
| selector: (s: Suggestion) => s.label, |
| }); |
| |
| const results: Array<{ item: Suggestion }> = await fzf.find( |
| normalizedPattern, |
| { |
| limit: MAX_SUGGESTIONS_TO_SHOW, |
| }, |
| ); |
| return results.map((r) => r.item); |
| } |
|
|
| export function useAtCompletion(props: UseAtCompletionProps): void { |
| const { |
| enabled, |
| pattern, |
| config, |
| cwd, |
| setSuggestions, |
| setIsLoadingSuggestions, |
| } = props; |
| const [state, dispatch] = useReducer(atCompletionReducer, initialState); |
| const fileSearchMap = useRef<Map<string, FileSearch>>(new Map()); |
| const initEpoch = useRef(0); |
| 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]); |
|
|
| const disposeFileSearchers = useCallback(async () => { |
| const searchers = [...fileSearchMap.current.values()]; |
| fileSearchMap.current.clear(); |
| initEpoch.current += 1; |
|
|
| const closePromises: Array<Promise<void>> = []; |
| for (const searcher of searchers) { |
| if (searcher.close) { |
| closePromises.push(searcher.close()); |
| } |
| } |
| await Promise.all(closePromises); |
| }, []); |
|
|
| const resetFileSearchState = useCallback(() => { |
| void disposeFileSearchers(); |
| dispatch({ type: 'RESET' }); |
| }, [disposeFileSearchers]); |
|
|
| useEffect(() => { |
| resetFileSearchState(); |
| }, [cwd, config, resetFileSearchState]); |
|
|
| useEffect(() => { |
| const workspaceContext = config?.getWorkspaceContext?.(); |
| if (!workspaceContext) return; |
|
|
| const unsubscribe = |
| workspaceContext.onDirectoriesChanged(resetFileSearchState); |
|
|
| return unsubscribe; |
| }, [config, resetFileSearchState]); |
|
|
| useEffect( |
| () => () => { |
| void disposeFileSearchers(); |
| searchAbortController.current?.abort(); |
| if (slowSearchTimer.current) { |
| clearTimeout(slowSearchTimer.current); |
| } |
| }, |
| [disposeFileSearchers], |
| ); |
|
|
| |
| useEffect(() => { |
| if (!enabled) { |
| |
| 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.toLowerCase() !== state.pattern |
| ) { |
| dispatch({ type: 'SEARCH', payload: pattern.toLowerCase() }); |
| } |
| }, [enabled, pattern, state.status, state.pattern]); |
|
|
| |
| useEffect(() => { |
| const initialize = async () => { |
| const currentEpoch = initEpoch.current; |
| try { |
| const directories = config |
| ?.getWorkspaceContext?.() |
| ?.getDirectories() ?? [cwd]; |
|
|
| const initPromises: Array<Promise<void>> = []; |
|
|
| for (const dir of directories) { |
| if (fileSearchMap.current.has(dir)) continue; |
|
|
| const searcher = FileSearchFactory.create({ |
| projectRoot: dir, |
| ignoreDirs: [], |
| fileDiscoveryService: new FileDiscoveryService( |
| dir, |
| config?.getFileFilteringOptions(), |
| ), |
| cache: true, |
| cacheTtl: 30, |
| enableFileWatcher: |
| config?.getFileFilteringOptions()?.enableFileWatcher ?? false, |
| enableRecursiveFileSearch: |
| config?.getEnableRecursiveFileSearch() ?? true, |
| enableFuzzySearch: |
| config?.getFileFilteringEnableFuzzySearch() ?? true, |
| maxFiles: config?.getFileFilteringOptions()?.maxFileCount, |
| }); |
|
|
| initPromises.push( |
| searcher.initialize().then(() => { |
| if (initEpoch.current === currentEpoch) { |
| fileSearchMap.current.set(dir, searcher); |
| } |
| }), |
| ); |
| } |
|
|
| await Promise.all(initPromises); |
|
|
| if (initEpoch.current !== currentEpoch) return; |
|
|
| dispatch({ type: 'INITIALIZE_SUCCESS' }); |
| if (state.pattern !== null) { |
| dispatch({ type: 'SEARCH', payload: state.pattern }); |
| } |
| } catch { |
| if (initEpoch.current === currentEpoch) { |
| dispatch({ type: 'ERROR' }); |
| } |
| } |
| }; |
|
|
| const search = async () => { |
| if (fileSearchMap.current.size === 0 || state.pattern === null) { |
| return; |
| } |
|
|
| const currentPattern = state.pattern; |
|
|
| if (slowSearchTimer.current) { |
| clearTimeout(slowSearchTimer.current); |
| } |
|
|
| const controller = new AbortController(); |
| searchAbortController.current = controller; |
|
|
| slowSearchTimer.current = setTimeout(() => { |
| dispatch({ type: 'SET_LOADING', payload: true }); |
| }, 200); |
|
|
| const timeoutMs = |
| config?.getFileFilteringOptions()?.searchTimeout ?? |
| DEFAULT_SEARCH_TIMEOUT_MS; |
|
|
| |
| (async () => { |
| try { |
| await setTimeoutPromise(timeoutMs, undefined, { |
| signal: controller.signal, |
| }); |
| controller.abort(); |
| } catch { |
| |
| } |
| })(); |
|
|
| try { |
| const directories = config |
| ?.getWorkspaceContext?.() |
| ?.getDirectories() ?? [cwd]; |
| const cwdRealpath = directories[0]; |
|
|
| const allSearchPromises = [...fileSearchMap.current.entries()].map( |
| async ([dir, searcher]): Promise<string[]> => { |
| const results = await searcher.search(currentPattern, { |
| signal: controller.signal, |
| maxResults: MAX_SUGGESTIONS_TO_SHOW * 3, |
| }); |
|
|
| if (dir !== cwdRealpath) { |
| return results.map((p: string) => path.join(dir, p)); |
| } |
| return results; |
| }, |
| ); |
|
|
| const allResults = await Promise.all(allSearchPromises); |
|
|
| if (slowSearchTimer.current) { |
| clearTimeout(slowSearchTimer.current); |
| } |
|
|
| if (controller.signal.aborted) { |
| return; |
| } |
|
|
| const mergedResults = allResults.flat(); |
|
|
| const fileSuggestions = mergedResults.map((p) => ({ |
| label: p, |
| value: escapePath(p), |
| })); |
|
|
| const resourceCandidates = buildResourceCandidates(config); |
| const resourceSuggestions = ( |
| await searchResourceCandidates( |
| currentPattern ?? '', |
| resourceCandidates, |
| ) |
| ).map((suggestion) => ({ |
| ...suggestion, |
| label: suggestion.label.replace(/^@/, ''), |
| value: suggestion.value.replace(/^@/, ''), |
| })); |
|
|
| const agentCandidates = buildAgentCandidates(config); |
| const agentSuggestions = await searchAgentCandidates( |
| currentPattern ?? '', |
| agentCandidates, |
| ); |
|
|
| |
| if (controller.signal.aborted) { |
| return; |
| } |
|
|
| const combinedSuggestions = [ |
| ...agentSuggestions, |
| ...fileSuggestions, |
| ...resourceSuggestions, |
| ]; |
| dispatch({ type: 'SEARCH_SUCCESS', payload: combinedSuggestions }); |
| } catch (error) { |
| if (!(error instanceof Error && error.name === 'AbortError')) { |
| dispatch({ type: 'ERROR' }); |
| } |
| } finally { |
| controller.abort(); |
| } |
| }; |
|
|
| 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]); |
| } |
|
|