diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index d18cab97f..c27254d0b 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -332,6 +332,82 @@ 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"), "{}"); + + const results = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "settings.local.json", + limit: 20, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + }); + + expect(results).toEqual([{ path: ".claude/settings.local.json", kind: "file" }]); + }); + + it("does not suggest hidden directories even when includeDirectories is true", async () => { + mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); + writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); + + const results = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "claude", + limit: 20, + includeFiles: true, + includeDirectories: true, + matchMode: "fuzzy", + }); + + expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe( + false, + ); + expect(results).toContainEqual({ + path: ".claude/settings.local.json", + kind: "file", + }); + }); + + it("path mode does not suggest hidden workspace directories", async () => { + mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); + writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); + + const results = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "./", + limit: 20, + includeFiles: true, + includeDirectories: true, + matchMode: "fuzzy", + }); + + expect(results).toContainEqual({ + path: "README.md", + kind: "file", + }); + expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe( + false, + ); + }); + + it("does not traverse .git while searching workspace files", async () => { + mkdirSync(path.join(workspaceDir, ".git", "objects", "ab"), { recursive: true }); + writeFileSync(path.join(workspaceDir, ".git", "objects", "ab", "deadbeef"), ""); + + const results = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "deadbeef", + limit: 20, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + }); + + expect(results).toEqual([]); + }); + // POSIX-only: creates and follows a symlink escape fixture. it.skipIf(isWindows)( "supports path-style queries and does not escape cwd through symlinks", diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index c244535a2..9e8d6a40d 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -90,6 +90,7 @@ const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([ "coverage", "vendor", "__pycache__", + ".git", ]); export async function searchHomeDirectories( @@ -318,6 +319,9 @@ async function searchWorkspaceWithinParentDirectory(input: { if (entry.kind === "file" && !input.includeFiles) { continue; } + if (isHiddenWorkspaceSuggestion(entry)) { + continue; + } const rankedEntry = rankWorkspaceEntry({ absolutePath: entry.absolutePath, kind: entry.kind, @@ -385,6 +389,10 @@ async function searchWorkspaceAcrossTree(input: { if (entry.kind === "directory" && !input.includeDirectories) { continue; } + // Hidden directories are traversed, but not offered as suggestions. + if (isHiddenWorkspaceSuggestion(entry)) { + continue; + } if (entry.kind === "file" && !input.includeFiles) { continue; } @@ -867,10 +875,19 @@ async function listWorkspaceChildEntries(input: { const dirents = await readdir(input.directory, { withFileTypes: true }).catch( () => [] as Dirent[], ); - const candidates = dirents.filter( - (dirent) => - !isHiddenDirectoryName(dirent.name) && !isIgnoredSuggestionDirectoryName(dirent.name), - ); + const candidates = dirents.filter((dirent) => { + if (isIgnoredSuggestionDirectoryName(dirent.name)) { + return false; + } + // Hidden directories must 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)) { + return false; + } + return true; + }); + const resolved = await Promise.all( candidates.map(async (dirent) => { const candidatePath = path.join(input.directory, dirent.name); @@ -960,6 +977,10 @@ function isHiddenDirectoryName(name: string): boolean { return name.startsWith("."); } +function isHiddenWorkspaceSuggestion(entry: ChildWorkspaceEntry): boolean { + return isHiddenDirectoryName(entry.name); +} + function isIgnoredSuggestionDirectoryName(name: string): boolean { return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name); }