From 2c4e443ad78ab899dfda09d972e3dd296d2fe8e6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 20 Jun 2026 19:13:25 +0700 Subject: [PATCH] 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. --- .../src/utils/directory-suggestions.test.ts | 86 ++++++++++++++++- .../server/src/utils/directory-suggestions.ts | 96 ++++++++++++++++++- 2 files changed, 176 insertions(+), 6 deletions(-) diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index c27254d0b..bb6e1d3f7 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -332,11 +332,63 @@ describe("searchWorkspaceEntries", () => { ]); }); - it("suffix mode finds files under hidden workspace directories", async () => { - mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); - writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); + it("suffix mode resolves exact workspace file paths before broad traversal", async () => { + const targetPath = path.join( + workspaceDir, + "packages", + "server", + "src", + "services", + "quota-fetcher", + "providers", + "local.ts", + ); + mkdirSync(path.dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, ""); const results = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "packages/server/src/services/quota-fetcher/providers/local.ts", + limit: 20, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + maxEntriesScanned: 1, + }); + + expect(results).toEqual([ + { + path: "packages/server/src/services/quota-fetcher/providers/local.ts", + kind: "file", + }, + ]); + }); + + it("suffix mode resolves explicit hidden file paths without broad hidden traversal", async () => { + const targetPath = path.join(workspaceDir, ".dev", "paseo-home", "daemon.log"); + mkdirSync(path.dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, "daemon log\n"); + + const results = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: ".dev/paseo-home/daemon.log", + limit: 20, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + maxEntriesScanned: 1, + }); + + expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]); + }); + + it("suffix mode finds files under allowlisted hidden workspace directories", async () => { + mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); + mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true }); + writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); + writeFileSync(path.join(workspaceDir, ".github", "workflows", "ci.yml"), ""); + + const claudeResults = await searchWorkspaceEntries({ cwd: workspaceDir, query: "settings.local.json", limit: 20, @@ -344,8 +396,34 @@ describe("searchWorkspaceEntries", () => { includeDirectories: false, matchMode: "suffix", }); + const githubResults = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "ci.yml", + limit: 20, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + }); - expect(results).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]); + expect(claudeResults).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]); + expect(githubResults).toEqual([{ path: ".github/workflows/ci.yml", kind: "file" }]); + }); + + it("does not broadly traverse unlisted hidden workspace directories", async () => { + mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true }); + writeFileSync(path.join(workspaceDir, ".dev", "cache", "needle.ts"), ""); + writeFileSync(path.join(workspaceDir, "src", "needle.ts"), ""); + + const results = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "needle.ts", + limit: 20, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + }); + + expect(results).toEqual([{ path: "src/needle.ts", kind: "file" }]); }); it("does not suggest hidden directories even when includeDirectories is true", async () => { diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 9e8d6a40d..0554af692 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -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 { + 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();