|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useCallback } from 'react'; |
|
|
import * as fs from 'fs/promises'; |
|
|
import * as path from 'path'; |
|
|
import { glob } from 'glob'; |
|
|
import { |
|
|
isNodeError, |
|
|
escapePath, |
|
|
unescapePath, |
|
|
getErrorMessage, |
|
|
Config, |
|
|
FileDiscoveryService, |
|
|
} from '@google/gemini-cli-core'; |
|
|
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<React.SetStateAction<number>>; |
|
|
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>; |
|
|
resetCompletionState: () => void; |
|
|
navigateUp: () => void; |
|
|
navigateDown: () => void; |
|
|
} |
|
|
|
|
|
export function useCompletion( |
|
|
query: string, |
|
|
cwd: string, |
|
|
isActive: boolean, |
|
|
slashCommands: SlashCommand[], |
|
|
config?: Config, |
|
|
): UseCompletionReturn { |
|
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]); |
|
|
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); |
|
|
}, []); |
|
|
|
|
|
const navigateUp = useCallback(() => { |
|
|
if (suggestions.length === 0) return; |
|
|
|
|
|
setActiveSuggestionIndex((prevActiveIndex) => { |
|
|
|
|
|
const newActiveIndex = |
|
|
prevActiveIndex <= 0 ? suggestions.length - 1 : prevActiveIndex - 1; |
|
|
|
|
|
|
|
|
setVisibleStartIndex((prevVisibleStart) => { |
|
|
|
|
|
if ( |
|
|
newActiveIndex === suggestions.length - 1 && |
|
|
suggestions.length > MAX_SUGGESTIONS_TO_SHOW |
|
|
) { |
|
|
return Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW); |
|
|
} |
|
|
|
|
|
if (newActiveIndex < prevVisibleStart) { |
|
|
return newActiveIndex; |
|
|
} |
|
|
|
|
|
return prevVisibleStart; |
|
|
}); |
|
|
|
|
|
return newActiveIndex; |
|
|
}); |
|
|
}, [suggestions.length]); |
|
|
|
|
|
const navigateDown = useCallback(() => { |
|
|
if (suggestions.length === 0) return; |
|
|
|
|
|
setActiveSuggestionIndex((prevActiveIndex) => { |
|
|
|
|
|
const newActiveIndex = |
|
|
prevActiveIndex >= suggestions.length - 1 ? 0 : prevActiveIndex + 1; |
|
|
|
|
|
|
|
|
setVisibleStartIndex((prevVisibleStart) => { |
|
|
|
|
|
if ( |
|
|
newActiveIndex === 0 && |
|
|
suggestions.length > MAX_SUGGESTIONS_TO_SHOW |
|
|
) { |
|
|
return 0; |
|
|
} |
|
|
|
|
|
const visibleEndIndex = prevVisibleStart + MAX_SUGGESTIONS_TO_SHOW; |
|
|
if (newActiveIndex >= visibleEndIndex) { |
|
|
return newActiveIndex - MAX_SUGGESTIONS_TO_SHOW + 1; |
|
|
} |
|
|
|
|
|
return prevVisibleStart; |
|
|
}); |
|
|
|
|
|
return newActiveIndex; |
|
|
}); |
|
|
}, [suggestions.length]); |
|
|
|
|
|
useEffect(() => { |
|
|
if (!isActive) { |
|
|
resetCompletionState(); |
|
|
return; |
|
|
} |
|
|
|
|
|
const trimmedQuery = query.trimStart(); |
|
|
|
|
|
|
|
|
if (trimmedQuery.startsWith('/')) { |
|
|
const parts = trimmedQuery.substring(1).split(' '); |
|
|
const commandName = parts[0]; |
|
|
const subCommand = parts.slice(1).join(' '); |
|
|
|
|
|
const command = slashCommands.find( |
|
|
(cmd) => cmd.name === commandName || cmd.altName === commandName, |
|
|
); |
|
|
|
|
|
if (command && command.completion) { |
|
|
const fetchAndSetSuggestions = async () => { |
|
|
setIsLoadingSuggestions(true); |
|
|
if (command.completion) { |
|
|
const results = await command.completion(); |
|
|
const filtered = results.filter((r) => r.startsWith(subCommand)); |
|
|
const newSuggestions = filtered.map((s) => ({ |
|
|
label: s, |
|
|
value: s, |
|
|
})); |
|
|
setSuggestions(newSuggestions); |
|
|
setShowSuggestions(newSuggestions.length > 0); |
|
|
setActiveSuggestionIndex(newSuggestions.length > 0 ? 0 : -1); |
|
|
} |
|
|
setIsLoadingSuggestions(false); |
|
|
}; |
|
|
fetchAndSetSuggestions(); |
|
|
return; |
|
|
} |
|
|
|
|
|
const partialCommand = trimmedQuery.substring(1); |
|
|
const filteredSuggestions = slashCommands |
|
|
.filter( |
|
|
(cmd) => |
|
|
cmd.name.startsWith(partialCommand) || |
|
|
cmd.altName?.startsWith(partialCommand), |
|
|
) |
|
|
|
|
|
.filter((cmd) => { |
|
|
const nameMatch = cmd.name.startsWith(partialCommand); |
|
|
const altNameMatch = cmd.altName?.startsWith(partialCommand); |
|
|
if (partialCommand.length === 1) { |
|
|
return nameMatch || altNameMatch; |
|
|
} |
|
|
return ( |
|
|
(nameMatch && cmd.name.length > 1) || |
|
|
(altNameMatch && cmd.altName && cmd.altName.length > 1) |
|
|
); |
|
|
}) |
|
|
.filter((cmd) => cmd.description) |
|
|
.map((cmd) => ({ |
|
|
label: cmd.name, |
|
|
value: cmd.name, |
|
|
description: cmd.description, |
|
|
})) |
|
|
.sort((a, b) => a.label.localeCompare(b.label)); |
|
|
|
|
|
setSuggestions(filteredSuggestions); |
|
|
setShowSuggestions(filteredSuggestions.length > 0); |
|
|
setActiveSuggestionIndex(filteredSuggestions.length > 0 ? 0 : -1); |
|
|
setVisibleStartIndex(0); |
|
|
setIsLoadingSuggestions(false); |
|
|
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 = unescapePath( |
|
|
lastSlashIndex === -1 |
|
|
? partialPath |
|
|
: partialPath.substring(lastSlashIndex + 1), |
|
|
); |
|
|
|
|
|
const baseDirAbsolute = path.resolve(cwd, baseDirRelative); |
|
|
|
|
|
let isMounted = true; |
|
|
|
|
|
const findFilesRecursively = async ( |
|
|
startDir: string, |
|
|
searchPrefix: string, |
|
|
fileDiscovery: { shouldGitIgnoreFile: (path: string) => boolean } | null, |
|
|
currentRelativePath = '', |
|
|
depth = 0, |
|
|
maxDepth = 10, |
|
|
maxResults = 50, |
|
|
): Promise<Suggestion[]> => { |
|
|
if (depth > maxDepth) { |
|
|
return []; |
|
|
} |
|
|
|
|
|
const lowerSearchPrefix = searchPrefix.toLowerCase(); |
|
|
let foundSuggestions: Suggestion[] = []; |
|
|
try { |
|
|
const entries = await fs.readdir(startDir, { withFileTypes: true }); |
|
|
for (const entry of entries) { |
|
|
if (foundSuggestions.length >= maxResults) break; |
|
|
|
|
|
const entryPathRelative = path.join(currentRelativePath, entry.name); |
|
|
const entryPathFromRoot = path.relative( |
|
|
cwd, |
|
|
path.join(startDir, entry.name), |
|
|
); |
|
|
|
|
|
|
|
|
if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
|
|
|
if ( |
|
|
fileDiscovery && |
|
|
fileDiscovery.shouldGitIgnoreFile(entryPathFromRoot) |
|
|
) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) { |
|
|
foundSuggestions.push({ |
|
|
label: entryPathRelative + (entry.isDirectory() ? '/' : ''), |
|
|
value: escapePath( |
|
|
entryPathRelative + (entry.isDirectory() ? '/' : ''), |
|
|
), |
|
|
}); |
|
|
} |
|
|
if ( |
|
|
entry.isDirectory() && |
|
|
entry.name !== 'node_modules' && |
|
|
!entry.name.startsWith('.') |
|
|
) { |
|
|
if (foundSuggestions.length < maxResults) { |
|
|
foundSuggestions = foundSuggestions.concat( |
|
|
await findFilesRecursively( |
|
|
path.join(startDir, entry.name), |
|
|
searchPrefix, |
|
|
fileDiscovery, |
|
|
entryPathRelative, |
|
|
depth + 1, |
|
|
maxDepth, |
|
|
maxResults - foundSuggestions.length, |
|
|
), |
|
|
); |
|
|
} |
|
|
} |
|
|
} |
|
|
} catch (_err) { |
|
|
|
|
|
} |
|
|
return foundSuggestions.slice(0, maxResults); |
|
|
}; |
|
|
|
|
|
const findFilesWithGlob = async ( |
|
|
searchPrefix: string, |
|
|
fileDiscoveryService: FileDiscoveryService, |
|
|
maxResults = 50, |
|
|
): Promise<Suggestion[]> => { |
|
|
const globPattern = `**/${searchPrefix}*`; |
|
|
const files = await glob(globPattern, { |
|
|
cwd, |
|
|
dot: searchPrefix.startsWith('.'), |
|
|
nocase: true, |
|
|
}); |
|
|
|
|
|
const suggestions: Suggestion[] = files |
|
|
.map((file: string) => { |
|
|
const relativePath = path.relative(cwd, file); |
|
|
return { |
|
|
label: relativePath, |
|
|
value: escapePath(relativePath), |
|
|
}; |
|
|
}) |
|
|
.filter((s) => { |
|
|
if (fileDiscoveryService) { |
|
|
return !fileDiscoveryService.shouldGitIgnoreFile(s.label); |
|
|
} |
|
|
return true; |
|
|
}) |
|
|
.slice(0, maxResults); |
|
|
|
|
|
return suggestions; |
|
|
}; |
|
|
|
|
|
const fetchSuggestions = async () => { |
|
|
setIsLoadingSuggestions(true); |
|
|
let fetchedSuggestions: Suggestion[] = []; |
|
|
|
|
|
const fileDiscoveryService = config ? config.getFileService() : null; |
|
|
const enableRecursiveSearch = |
|
|
config?.getEnableRecursiveFileSearch() ?? true; |
|
|
|
|
|
try { |
|
|
|
|
|
if ( |
|
|
partialPath.indexOf('/') === -1 && |
|
|
prefix && |
|
|
enableRecursiveSearch |
|
|
) { |
|
|
if (fileDiscoveryService) { |
|
|
fetchedSuggestions = await findFilesWithGlob( |
|
|
prefix, |
|
|
fileDiscoveryService, |
|
|
); |
|
|
} else { |
|
|
fetchedSuggestions = await findFilesRecursively( |
|
|
cwd, |
|
|
prefix, |
|
|
fileDiscoveryService, |
|
|
); |
|
|
} |
|
|
} else { |
|
|
|
|
|
const lowerPrefix = prefix.toLowerCase(); |
|
|
const entries = await fs.readdir(baseDirAbsolute, { |
|
|
withFileTypes: true, |
|
|
}); |
|
|
|
|
|
|
|
|
const filteredEntries = []; |
|
|
for (const entry of entries) { |
|
|
|
|
|
if (!prefix.startsWith('.') && entry.name.startsWith('.')) { |
|
|
continue; |
|
|
} |
|
|
if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue; |
|
|
|
|
|
const relativePath = path.relative( |
|
|
cwd, |
|
|
path.join(baseDirAbsolute, entry.name), |
|
|
); |
|
|
if ( |
|
|
fileDiscoveryService && |
|
|
fileDiscoveryService.shouldGitIgnoreFile(relativePath) |
|
|
) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
filteredEntries.push(entry); |
|
|
} |
|
|
|
|
|
fetchedSuggestions = filteredEntries.map((entry) => { |
|
|
const label = entry.isDirectory() ? entry.name + '/' : entry.name; |
|
|
return { |
|
|
label, |
|
|
value: escapePath(label), |
|
|
}; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
fetchedSuggestions.sort((a, b) => { |
|
|
const depthA = (a.label.match(/\//g) || []).length; |
|
|
const depthB = (b.label.match(/\//g) || []).length; |
|
|
|
|
|
if (depthA !== depthB) { |
|
|
return depthA - depthB; |
|
|
} |
|
|
|
|
|
const aIsDir = a.label.endsWith('/'); |
|
|
const bIsDir = b.label.endsWith('/'); |
|
|
if (aIsDir && !bIsDir) return -1; |
|
|
if (!aIsDir && bIsDir) return 1; |
|
|
|
|
|
return a.label.localeCompare(b.label); |
|
|
}); |
|
|
|
|
|
if (isMounted) { |
|
|
setSuggestions(fetchedSuggestions); |
|
|
setShowSuggestions(fetchedSuggestions.length > 0); |
|
|
setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1); |
|
|
setVisibleStartIndex(0); |
|
|
} |
|
|
} catch (error: unknown) { |
|
|
if (isNodeError(error) && error.code === 'ENOENT') { |
|
|
if (isMounted) { |
|
|
setSuggestions([]); |
|
|
setShowSuggestions(false); |
|
|
} |
|
|
} else { |
|
|
console.error( |
|
|
`Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`, |
|
|
); |
|
|
if (isMounted) { |
|
|
resetCompletionState(); |
|
|
} |
|
|
} |
|
|
} |
|
|
if (isMounted) { |
|
|
setIsLoadingSuggestions(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
const debounceTimeout = setTimeout(fetchSuggestions, 100); |
|
|
|
|
|
return () => { |
|
|
isMounted = false; |
|
|
clearTimeout(debounceTimeout); |
|
|
}; |
|
|
}, [query, cwd, isActive, resetCompletionState, slashCommands, config]); |
|
|
|
|
|
return { |
|
|
suggestions, |
|
|
activeSuggestionIndex, |
|
|
visibleStartIndex, |
|
|
showSuggestions, |
|
|
isLoadingSuggestions, |
|
|
setActiveSuggestionIndex, |
|
|
setShowSuggestions, |
|
|
resetCompletionState, |
|
|
navigateUp, |
|
|
navigateDown, |
|
|
}; |
|
|
} |
|
|
|