Files
gemini-cli/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
2026-02-05 20:38:29 +00:00

155 lines
4.0 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useCompletion } from './useCompletion.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
function useDebouncedValue<T>(value: T, delay = 200): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handle = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handle);
}, [value, delay]);
return debounced;
}
export interface UseReverseSearchCompletionReturn {
suggestions: Suggestion[];
activeSuggestionIndex: number;
visibleStartIndex: number;
showSuggestions: boolean;
isLoadingSuggestions: boolean;
navigateUp: () => void;
navigateDown: () => void;
handleAutocomplete: (i: number) => void;
resetCompletionState: () => void;
}
export function useReverseSearchCompletion(
buffer: TextBuffer,
history: readonly string[],
reverseSearchActive: boolean,
): UseReverseSearchCompletionReturn {
const {
suggestions,
activeSuggestionIndex,
visibleStartIndex,
isLoadingSuggestions,
setSuggestions,
setActiveSuggestionIndex,
resetCompletionState,
navigateUp,
navigateDown,
setVisibleStartIndex,
} = useCompletion();
const debouncedQuery = useDebouncedValue(buffer.text, 100);
// incremental search
const prevQueryRef = useRef<string>('');
const prevMatchesRef = useRef<Suggestion[]>([]);
// Clear incremental cache when activating reverse search
useEffect(() => {
if (reverseSearchActive) {
prevQueryRef.current = '';
prevMatchesRef.current = [];
}
}, [reverseSearchActive]);
// Also clear cache when history changes so new items are considered
useEffect(() => {
prevQueryRef.current = '';
prevMatchesRef.current = [];
}, [history]);
const searchHistory = useCallback(
(query: string, items: readonly string[]) => {
const out: Suggestion[] = [];
for (let i = 0; i < items.length; i++) {
const cmd = items[i];
const idx = cmd.toLowerCase().indexOf(query);
if (idx !== -1) {
out.push({ label: cmd, value: cmd, matchedIndex: idx });
}
}
return out;
},
[],
);
const matches = useMemo<Suggestion[]>(() => {
if (!reverseSearchActive) return [];
if (debouncedQuery.length === 0)
return history.map((cmd) => ({
label: cmd,
value: cmd,
matchedIndex: -1,
}));
const query = debouncedQuery.toLowerCase();
const canUseCache =
prevQueryRef.current &&
query.startsWith(prevQueryRef.current) &&
prevMatchesRef.current.length > 0;
const source = canUseCache
? prevMatchesRef.current.map((m) => m.value)
: history;
return searchHistory(query, source);
}, [debouncedQuery, history, reverseSearchActive, searchHistory]);
useEffect(() => {
if (!reverseSearchActive) {
resetCompletionState();
return;
}
setSuggestions(matches);
const hasAny = matches.length > 0;
setActiveSuggestionIndex(hasAny ? 0 : -1);
setVisibleStartIndex(0);
prevQueryRef.current = debouncedQuery.toLowerCase();
prevMatchesRef.current = matches;
}, [
debouncedQuery,
matches,
reverseSearchActive,
setSuggestions,
setActiveSuggestionIndex,
setVisibleStartIndex,
resetCompletionState,
]);
const showSuggestions =
reverseSearchActive && (isLoadingSuggestions || suggestions.length > 0);
const handleAutocomplete = useCallback(
(i: number) => {
if (i < 0 || i >= suggestions.length) return;
buffer.setText(suggestions[i].value);
resetCompletionState();
},
[buffer, suggestions, resetCompletionState],
);
return {
suggestions,
activeSuggestionIndex,
visibleStartIndex,
showSuggestions,
isLoadingSuggestions,
navigateUp,
navigateDown,
handleAutocomplete,
resetCompletionState,
};
}