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}`);
|
onDebugMessage(`Path resolved to file: ${pathSpec}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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') {
|
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 {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
`Error stating path ${pathPart}: ${getErrorMessage(error)}`,
|
`Error stating path ${pathPart}: ${getErrorMessage(error)}`,
|
||||||
|
|
|
@ -180,44 +180,114 @@ export function useCompletion(
|
||||||
const baseDirAbsolute = path.resolve(cwd, baseDirRelative);
|
const baseDirAbsolute = path.resolve(cwd, baseDirRelative);
|
||||||
|
|
||||||
let isMounted = true;
|
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 () => {
|
const fetchSuggestions = async () => {
|
||||||
setIsLoadingSuggestions(true);
|
setIsLoadingSuggestions(true);
|
||||||
|
let fetchedSuggestions: Suggestion[] = [];
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(baseDirAbsolute, {
|
// If there's no slash, or it's the root, do a recursive search from cwd
|
||||||
withFileTypes: true,
|
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) {
|
if (isMounted) {
|
||||||
setSuggestions(filteredSuggestions);
|
setSuggestions(fetchedSuggestions);
|
||||||
setShowSuggestions(filteredSuggestions.length > 0);
|
setShowSuggestions(fetchedSuggestions.length > 0);
|
||||||
setActiveSuggestionIndex(filteredSuggestions.length > 0 ? 0 : -1);
|
setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1);
|
||||||
setVisibleStartIndex(0);
|
setVisibleStartIndex(0);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||||
// Directory doesn't exist, likely mid-typing, clear suggestions
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
`Error fetching completion suggestions for ${baseDirAbsolute}: ${getErrorMessage(error)}`,
|
`Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`,
|
||||||
);
|
);
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
|
|
Loading…
Reference in New Issue