diff options
Diffstat (limited to 'packages/cli/src/ui/hooks/useCompletion.ts')
| -rw-r--r-- | packages/cli/src/ui/hooks/useCompletion.ts | 187 |
1 files changed, 187 insertions, 0 deletions
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts new file mode 100644 index 00000000..92841028 --- /dev/null +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback } from 'react'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { isNodeError } from '@gemini-code/server'; + +const MAX_SUGGESTIONS_TO_SHOW = 8; + +export interface UseCompletionReturn { + suggestions: string[]; + activeSuggestionIndex: number; + visibleStartIndex: number; + showSuggestions: boolean; + isLoadingSuggestions: boolean; + setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>; + setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>; + resetCompletionState: () => void; + navigateUp: () => void; + navigateDown: () => void; +} + +export function useCompletion( + query: string, + cwd: string, + isActive: boolean, +): UseCompletionReturn { + const [suggestions, setSuggestions] = useState<string[]>([]); + const [activeSuggestionIndex, setActiveSuggestionIndex] = + useState<number>(-1); + const [visibleStartIndex, setVisibleStartIndex] = useState<number>(0); + const [showSuggestions, setShowSuggestions] = useState<boolean>(false); + const [isLoadingSuggestions, setIsLoadingSuggestions] = + useState<boolean>(false); + + const resetCompletionState = useCallback(() => { + setSuggestions([]); + setActiveSuggestionIndex(-1); + setVisibleStartIndex(0); + setShowSuggestions(false); + setIsLoadingSuggestions(false); + }, []); + + // --- Navigation Logic --- + const navigateUp = useCallback(() => { + if (suggestions.length === 0) return; + + setActiveSuggestionIndex((prevIndex) => { + const newIndex = prevIndex <= 0 ? suggestions.length - 1 : prevIndex - 1; + + // Adjust visible window if needed (scrolling up) + if (newIndex < visibleStartIndex) { + setVisibleStartIndex(newIndex); + } else if ( + newIndex === suggestions.length - 1 && + suggestions.length > MAX_SUGGESTIONS_TO_SHOW + ) { + // Handle wrapping from first to last item + setVisibleStartIndex( + Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW), + ); + } + + return newIndex; + }); + }, [suggestions.length, visibleStartIndex]); + + const navigateDown = useCallback(() => { + if (suggestions.length === 0) return; + + setActiveSuggestionIndex((prevIndex) => { + const newIndex = prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 1; + + // Adjust visible window if needed (scrolling down) + if (newIndex >= visibleStartIndex + MAX_SUGGESTIONS_TO_SHOW) { + setVisibleStartIndex(visibleStartIndex + 1); + } else if ( + newIndex === 0 && + suggestions.length > MAX_SUGGESTIONS_TO_SHOW + ) { + // Handle wrapping from last to first item + setVisibleStartIndex(0); + } + + return newIndex; + }); + }, [suggestions.length, visibleStartIndex]); + // --- End Navigation Logic --- + + useEffect(() => { + if (!isActive) { + resetCompletionState(); + return; + } + + const atIndex = query.lastIndexOf('@'); + if (atIndex === -1) { + resetCompletionState(); + return; + } + + const partialPath = query.substring(atIndex + 1); + const lastSlashIndex = partialPath.lastIndexOf('/'); + const baseDirRelative = + lastSlashIndex === -1 + ? '.' + : partialPath.substring(0, lastSlashIndex + 1); + const prefix = + lastSlashIndex === -1 + ? partialPath + : partialPath.substring(lastSlashIndex + 1); + const baseDirAbsolute = path.resolve(cwd, baseDirRelative); + + let isMounted = true; + const fetchSuggestions = async () => { + setIsLoadingSuggestions(true); + try { + const entries = await fs.readdir(baseDirAbsolute, { + withFileTypes: true, + }); + const filteredSuggestions = entries + .filter((entry) => entry.name.startsWith(prefix)) + .map((entry) => (entry.isDirectory() ? entry.name + '/' : entry.name)) + .sort((a, b) => { + // Sort directories first, then alphabetically + const aIsDir = a.endsWith('/'); + const bIsDir = b.endsWith('/'); + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + return a.localeCompare(b); + }); + + if (isMounted) { + setSuggestions(filteredSuggestions); + setShowSuggestions(filteredSuggestions.length > 0); + setActiveSuggestionIndex(-1); // Reset selection on new suggestions + setVisibleStartIndex(0); // Reset scroll on new suggestions + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + // Directory doesn't exist, likely mid-typing, clear suggestions + if (isMounted) { + setSuggestions([]); + setShowSuggestions(false); + } + } else { + console.error( + `Error fetching completion suggestions for ${baseDirAbsolute}:`, + error, + ); + if (isMounted) { + resetCompletionState(); + } + } + } + if (isMounted) { + setIsLoadingSuggestions(false); + } + }; + + // Debounce the fetch slightly + const debounceTimeout = setTimeout(fetchSuggestions, 100); + + return () => { + isMounted = false; + clearTimeout(debounceTimeout); + // Don't reset loading state here, let the next effect handle it or resetCompletionState + }; + }, [query, cwd, isActive, resetCompletionState]); + + return { + suggestions, + activeSuggestionIndex, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + setActiveSuggestionIndex, + setShowSuggestions, + resetCompletionState, + navigateUp, + navigateDown, + }; +} |
