fix: keep @ file mention responsive on very large projects (#600)

Typing @ in the chat input to reference a file froze the app on huge
workspaces. Two compounding causes:

1. The autocomplete hook fed fileFilterQuery straight into the React
   Query key, so every keystroke fired a new directory_suggestions
   request and a fresh BFS scan on the daemon. Added a 180 ms debounce
   that mirrors the existing pattern in new-workspace-screen so rapid
   typing coalesces into one request.

2. searchWorkspaceEntries only ignored node_modules, so dist, build,
   target, out, coverage, vendor and __pycache__ each consumed the
   5,000-entry scan budget before any real source file was reached.
   Extended WORKSPACE_IGNORED_DIRECTORY_NAMES with those names and
   added a regression test that proves a deep source file is still
   reachable when those directories are full of bait entries.

Refs #599

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yurui Zhou
2026-04-28 20:43:38 +08:00
committed by GitHub
parent 3ba4d09294
commit 2a4945ad0b
3 changed files with 56 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { AutocompleteOption } from "@/components/ui/autocomplete";
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query";
@@ -185,6 +185,12 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
);
const showFileAutocomplete = activeFileMention !== null;
const fileFilterQuery = activeFileMention?.query ?? "";
const [debouncedFileFilterQuery, setDebouncedFileFilterQuery] = useState(fileFilterQuery);
useEffect(() => {
const timer = setTimeout(() => setDebouncedFileFilterQuery(fileFilterQuery), 180);
return () => clearTimeout(timer);
}, [fileFilterQuery]);
const normalizedDraftConfig = useMemo(
() => normalizeDraftCommandConfig(draftConfig),
@@ -229,14 +235,21 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
});
const fileSuggestionsQuery = useQuery({
queryKey: ["directorySuggestions", serverId, autocompleteCwd, fileFilterQuery, true, true],
queryKey: [
"directorySuggestions",
serverId,
autocompleteCwd,
debouncedFileFilterQuery,
true,
true,
],
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const response = await client.getDirectorySuggestions({
cwd: autocompleteCwd,
query: fileFilterQuery,
query: debouncedFileFilterQuery,
limit: 50,
includeFiles: true,
includeDirectories: true,

View File

@@ -265,4 +265,34 @@ describe("searchWorkspaceEntries", () => {
});
expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false);
});
it("ignores common build/cache directories so large generated trees do not exhaust scan budget", async () => {
mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true });
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "needle.ts"), "");
const heavyDirs = ["dist", "build", "target", "out", "coverage", "vendor", "__pycache__"];
for (const heavyDir of heavyDirs) {
for (let index = 0; index < 30; index += 1) {
mkdirSync(path.join(workspaceDir, heavyDir, `bundle-${index}`), { recursive: true });
writeFileSync(path.join(workspaceDir, heavyDir, `bundle-${index}`, "needle.ts"), "");
}
}
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "needle.ts",
limit: 20,
includeFiles: true,
includeDirectories: true,
maxEntriesScanned: 80,
});
expect(results).toContainEqual({
path: "packages/app/src/needle.ts",
kind: "file",
});
for (const heavyDir of heavyDirs) {
expect(results.some((entry) => entry.path.startsWith(`${heavyDir}/`))).toBe(false);
}
});
});

View File

@@ -73,7 +73,16 @@ const directoryListCache = new Map<string, DirectoryListCacheEntry>();
const workspaceEntryListCache = new Map<string, WorkspaceEntryListCacheEntry>();
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set(["node_modules"]);
const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set([
"node_modules",
"dist",
"build",
"target",
"out",
"coverage",
"vendor",
"__pycache__",
]);
export async function searchHomeDirectories(
options: SearchHomeDirectoriesOptions,