From 01c28df8b2bcdb34d5a4ee5f330bbb8d1df0b334 Mon Sep 17 00:00:00 2001 From: DeWitt Clinton Date: Wed, 21 May 2025 12:22:18 -0700 Subject: [PATCH] Add globbing support to @-command file suggestions and resolution. (#462) Implements recursive glob-based file search for both suggestions and execution of the `@` command. - When typing `@filename`, suggestions will now include files matching `filename` in nested directories. - Suggestions are sorted by path depth (shallowest first), then directories before files, then alphabetically. - The maximum recursion depth for suggestions is set to 10. - When executing an `@filename` command, if the file is not found directly, a recursive search (using the glob tool) is performed to locate the file. This addresses the first request in issue #461 by allowing users to quickly reference deeply nested files without typing the full path. Also addresses b/416292478. --- .../cli/src/ui/hooks/atCommandProcessor.ts | 63 +++++++++- packages/cli/src/ui/hooks/useCompletion.ts | 114 ++++++++++++++---- 2 files changed, 152 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index a5b602ad..dd97a0d6 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -150,10 +150,67 @@ export async function handleAtCommand({ onDebugMessage(`Path resolved to file: ${pathSpec}`); } } catch (error) { - // If stat fails (e.g., not found), proceed with original path. - // The tool itself will handle the error during execution. if (isNodeError(error) && error.code === 'ENOENT') { - onDebugMessage(`Path not found, proceeding with original: ${pathSpec}`); + onDebugMessage( + `Path ${pathPart} not found directly, attempting glob search.`, + ); + const globTool = toolRegistry.getTool('glob'); + if (globTool) { + try { + const globResult = await globTool.execute( + { + pattern: `**/*${pathPart}*`, + path: config.getTargetDir(), // Ensure glob searches from the root + }, + signal, + ); + // Assuming llmContent contains the list of files or a "no files found" message. + // And that paths are absolute. + if ( + globResult.llmContent && + typeof globResult.llmContent === 'string' && + !globResult.llmContent.startsWith('No files found') && + !globResult.llmContent.startsWith('Error:') + ) { + // Extract the first line after the header + const lines = globResult.llmContent.split('\n'); + if (lines.length > 1 && lines[1]) { + const firstMatchAbsolute = lines[1].trim(); + // Convert absolute path from glob to relative path for read_many_files + pathSpec = path.relative( + config.getTargetDir(), + firstMatchAbsolute, + ); + onDebugMessage( + `Glob search found ${firstMatchAbsolute}, using relative path: ${pathSpec}`, + ); + } else { + onDebugMessage( + `Glob search for '**/*${pathPart}*' did not return a usable path. Proceeding with original: ${pathPart}`, + ); + // pathSpec remains pathPart + } + } else { + onDebugMessage( + `Glob search for '**/*${pathPart}*' found no files or an error occurred. Proceeding with original: ${pathPart}`, + ); + // pathSpec remains pathPart + } + } catch (globError) { + console.error( + `Error during glob search: ${getErrorMessage(globError)}`, + ); + onDebugMessage( + `Error during glob search. Proceeding with original: ${pathPart}`, + ); + // pathSpec remains pathPart + } + } else { + onDebugMessage( + 'Glob tool not found. Proceeding with original path: ${pathPart}', + ); + // pathSpec remains pathPart + } } else { console.error( `Error stating path ${pathPart}: ${getErrorMessage(error)}`, diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index c707adef..182ee4e4 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -180,44 +180,114 @@ export function useCompletion( const baseDirAbsolute = path.resolve(cwd, baseDirRelative); let isMounted = true; + + const findFilesRecursively = async ( + startDir: string, + searchPrefix: string, + currentRelativePath = '', + depth = 0, + maxDepth = 10, // Limit recursion depth + maxResults = 50, // Limit number of results + ): Promise => { + if (depth > maxDepth) { + return []; + } + + 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); + if (entry.name.startsWith(searchPrefix)) { + 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, + entryPathRelative, + depth + 1, + maxDepth, + maxResults - foundSuggestions.length, + ), + ); + } + } + } + } catch (_err) { + // Ignore errors like permission denied or ENOENT during recursive search + } + return foundSuggestions.slice(0, maxResults); + }; + const fetchSuggestions = async () => { setIsLoadingSuggestions(true); + let fetchedSuggestions: Suggestion[] = []; try { - const entries = await fs.readdir(baseDirAbsolute, { - withFileTypes: true, + // If there's no slash, or it's the root, do a recursive search from cwd + if (partialPath.indexOf('/') === -1 && prefix) { + fetchedSuggestions = await findFilesRecursively(cwd, prefix); + } else { + // Original behavior: list files in the specific directory + const entries = await fs.readdir(baseDirAbsolute, { + withFileTypes: true, + }); + fetchedSuggestions = entries + .filter((entry) => entry.name.startsWith(prefix)) + .map((entry) => { + const label = entry.isDirectory() ? entry.name + '/' : entry.name; + return { + label, + value: escapePath(label), // Value for completion should be just the name part + }; + }); + } + + // Sort by depth, then directories first, then alphabetically + 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); }); - 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(filteredSuggestions.length > 0 ? 0 : -1); + setSuggestions(fetchedSuggestions); + setShowSuggestions(fetchedSuggestions.length > 0); + setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1); setVisibleStartIndex(0); } } catch (error: unknown) { 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}: ${getErrorMessage(error)}`, + `Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`, ); if (isMounted) { resetCompletionState();