Fix exact workspace file suggestions
Resolve exact suffix path queries before falling back to broad search, and keep broad hidden-directory traversal limited to known workspace config dirs.
This commit is contained in:
@@ -92,6 +92,14 @@ const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([
|
||||
"__pycache__",
|
||||
".git",
|
||||
]);
|
||||
const TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES = new Set([
|
||||
".agents",
|
||||
".claude",
|
||||
".codex",
|
||||
".github",
|
||||
".paseo",
|
||||
".vscode",
|
||||
]);
|
||||
|
||||
export async function searchHomeDirectories(
|
||||
options: SearchHomeDirectoriesOptions,
|
||||
@@ -161,6 +169,19 @@ export async function searchWorkspaceEntries(
|
||||
}
|
||||
|
||||
const matchMode = options.matchMode ?? "fuzzy";
|
||||
const exactEntry =
|
||||
queryParts.isPathQuery && matchMode === "suffix"
|
||||
? await resolveWorkspaceExactEntry({
|
||||
workspaceRoot,
|
||||
query: options.query,
|
||||
includeDirectories,
|
||||
includeFiles,
|
||||
})
|
||||
: null;
|
||||
if (exactEntry && limit <= 1) {
|
||||
return [exactEntry];
|
||||
}
|
||||
|
||||
if (queryParts.isPathQuery && matchMode !== "suffix") {
|
||||
return searchWorkspaceWithinParentDirectory({
|
||||
workspaceRoot,
|
||||
@@ -176,7 +197,7 @@ export async function searchWorkspaceEntries(
|
||||
matchMode === "suffix"
|
||||
? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/")
|
||||
: queryParts.searchTerm;
|
||||
return searchWorkspaceAcrossTree({
|
||||
const entries = await searchWorkspaceAcrossTree({
|
||||
workspaceRoot,
|
||||
searchTerm,
|
||||
limit,
|
||||
@@ -186,6 +207,66 @@ export async function searchWorkspaceEntries(
|
||||
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
||||
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
|
||||
});
|
||||
return exactEntry ? prependWorkspaceEntry(exactEntry, entries).slice(0, limit) : entries;
|
||||
}
|
||||
|
||||
async function resolveWorkspaceExactEntry(input: {
|
||||
workspaceRoot: string;
|
||||
query: string;
|
||||
includeDirectories: boolean;
|
||||
includeFiles: boolean;
|
||||
}): Promise<WorkspaceSuggestionEntry | null> {
|
||||
const normalized = input.query
|
||||
.trim()
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidatePath = path.isAbsolute(normalized)
|
||||
? path.resolve(normalized)
|
||||
: path.resolve(input.workspaceRoot, normalized);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(candidatePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await stat(resolvedPath).catch(() => null);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
if (stats.isFile() && input.includeFiles) {
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, resolvedPath),
|
||||
kind: "file",
|
||||
};
|
||||
}
|
||||
if (stats.isDirectory() && input.includeDirectories) {
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, resolvedPath),
|
||||
kind: "directory",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prependWorkspaceEntry(
|
||||
entry: WorkspaceSuggestionEntry,
|
||||
entries: WorkspaceSuggestionEntry[],
|
||||
): WorkspaceSuggestionEntry[] {
|
||||
return [
|
||||
entry,
|
||||
...entries.filter(
|
||||
(candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined): number {
|
||||
@@ -879,7 +960,14 @@ async function listWorkspaceChildEntries(input: {
|
||||
if (isIgnoredSuggestionDirectoryName(dirent.name)) {
|
||||
return false;
|
||||
}
|
||||
// Hidden directories must remain traversable so file links like
|
||||
if (
|
||||
isHiddenDirectoryName(dirent.name) &&
|
||||
!dirent.isFile() &&
|
||||
!isTraversableHiddenWorkspaceDirectoryName(dirent.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Allowlisted hidden directories remain traversable so file links like
|
||||
// `.claude/settings.local.json` can resolve, but hidden files (e.g.
|
||||
// `.DS_Store`) should never be suggested.
|
||||
if (dirent.isFile() && isHiddenDirectoryName(dirent.name)) {
|
||||
@@ -985,6 +1073,10 @@ function isIgnoredSuggestionDirectoryName(name: string): boolean {
|
||||
return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name);
|
||||
}
|
||||
|
||||
function isTraversableHiddenWorkspaceDirectoryName(name: string): boolean {
|
||||
return TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES.has(name);
|
||||
}
|
||||
|
||||
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
||||
directoryListCache.set(cacheKey, entry);
|
||||
pruneDirectoryListCache();
|
||||
|
||||
Reference in New Issue
Block a user