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:
parent
e1a64b41e8
commit
01c28df8b2
|
@ -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)}`,
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in New Issue