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.
This commit is contained in:
DeWitt Clinton 2025-05-21 12:22:18 -07:00 committed by GitHub
parent e1a64b41e8
commit 01c28df8b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 152 additions and 25 deletions

View File

@ -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)}`,

View File

@ -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<Suggestion[]> => {
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();