summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/useAtCompletion.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks/useAtCompletion.ts')
-rw-r--r--packages/cli/src/ui/hooks/useAtCompletion.ts228
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]);
+}