/** * @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, escapePath, unescapePath } from '@gemini-code/server'; import { MAX_SUGGESTIONS_TO_SHOW, Suggestion, } from '../components/SuggestionsDisplay.js'; import { SlashCommand } from './slashCommandProcessor.js'; export interface UseCompletionReturn { suggestions: Suggestion[]; activeSuggestionIndex: number; visibleStartIndex: number; showSuggestions: boolean; isLoadingSuggestions: boolean; setActiveSuggestionIndex: React.Dispatch>; setShowSuggestions: React.Dispatch>; resetCompletionState: () => void; navigateUp: () => void; navigateDown: () => void; } export function useCompletion( query: string, cwd: string, isActive: boolean, slashCommands: SlashCommand[], ): UseCompletionReturn { const [suggestions, setSuggestions] = useState([]); const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1); const [visibleStartIndex, setVisibleStartIndex] = useState(0); const [showSuggestions, setShowSuggestions] = useState(false); const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const resetCompletionState = useCallback(() => { setSuggestions([]); setActiveSuggestionIndex(-1); setVisibleStartIndex(0); setShowSuggestions(false); setIsLoadingSuggestions(false); }, []); const navigateUp = useCallback(() => { if (suggestions.length === 0) return; setActiveSuggestionIndex((prevActiveIndex) => { // Calculate new active index, handling wrap-around const newActiveIndex = prevActiveIndex <= 0 ? suggestions.length - 1 : prevActiveIndex - 1; // Adjust scroll position based on the new active index setVisibleStartIndex((prevVisibleStart) => { // Case 1: Wrapped around to the last item if ( newActiveIndex === suggestions.length - 1 && suggestions.length > MAX_SUGGESTIONS_TO_SHOW ) { return Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW); } // Case 2: Scrolled above the current visible window if (newActiveIndex < prevVisibleStart) { return newActiveIndex; } // Otherwise, keep the current scroll position return prevVisibleStart; }); return newActiveIndex; }); }, [suggestions.length]); const navigateDown = useCallback(() => { if (suggestions.length === 0) return; setActiveSuggestionIndex((prevActiveIndex) => { // Calculate new active index, handling wrap-around const newActiveIndex = prevActiveIndex >= suggestions.length - 1 ? 0 : prevActiveIndex + 1; // Adjust scroll position based on the new active index setVisibleStartIndex((prevVisibleStart) => { // Case 1: Wrapped around to the first item if ( newActiveIndex === 0 && suggestions.length > MAX_SUGGESTIONS_TO_SHOW ) { return 0; } // Case 2: Scrolled below the current visible window const visibleEndIndex = prevVisibleStart + MAX_SUGGESTIONS_TO_SHOW; if (newActiveIndex >= visibleEndIndex) { return newActiveIndex - MAX_SUGGESTIONS_TO_SHOW + 1; } // Otherwise, keep the current scroll position return prevVisibleStart; }); return newActiveIndex; }); }, [suggestions.length]); useEffect(() => { if (!isActive) { resetCompletionState(); return; } const trimmedQuery = query.trimStart(); // Trim leading whitespace // --- Handle Slash Command Completion --- if (trimmedQuery.startsWith('/')) { const partialCommand = trimmedQuery.substring(1); const commands = slashCommands .map((cmd) => cmd.name) .concat( slashCommands .map((cmd) => cmd.altName) .filter((cmd) => cmd !== undefined), ); const filteredSuggestions = commands .filter((name) => name.startsWith(partialCommand)) // Filter out ? and any other single character commands .filter((name) => name.length > 1) .map((name) => ({ label: name, value: name })) .sort(); setSuggestions(filteredSuggestions); setShowSuggestions(filteredSuggestions.length > 0); setActiveSuggestionIndex(-1); setVisibleStartIndex(0); setIsLoadingSuggestions(false); return; } // --- Handle At Command Completion --- 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 = unescapePath( 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); }) .map((entry) => ({ label: entry, value: escapePath(entry), })); if (isMounted) { setSuggestions(filteredSuggestions); setShowSuggestions(filteredSuggestions.length > 0); setActiveSuggestionIndex(-1); setVisibleStartIndex(0); } } 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); } }; const debounceTimeout = setTimeout(fetchSuggestions, 100); return () => { isMounted = false; clearTimeout(debounceTimeout); }; }, [query, cwd, isActive, resetCompletionState, slashCommands]); return { suggestions, activeSuggestionIndex, visibleStartIndex, showSuggestions, isLoadingSuggestions, setActiveSuggestionIndex, setShowSuggestions, resetCompletionState, navigateUp, navigateDown, }; }