diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 1d366b59407..2aad691a2b6 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -200,3 +200,42 @@ it.effect("walks dot-directories without descending into .git", () => }), ), ); + +it.effect("surfaces dotfiles even when the native results already fill the entry cap", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* Effect.acquireRelease( + Effect.tryPromise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-dotfiles-cap-"))), + (directory) => + Effect.promise(() => NodeFSP.rm(directory, { recursive: true, force: true })), + ); + yield* Effect.promise(() => NodeFSP.writeFile(NodePath.join(cwd, ".env"), "TOKEN=test")); + + // Simulate a tree (e.g. the home directory) whose gitignore-filtered + // native scan alone reaches the entry cap. Before dotfiles were given + // priority, this starved dotfile discovery so `.env` never appeared. + const nativeItems = Array.from( + { length: WorkspaceSearchIndex.WORKSPACE_INDEX_MAX_ENTRIES }, + (_, index) => ({ type: "file", item: { relativePath: `native-${index}.txt` } }), + ); + const finder = { + destroy: vi.fn(), + isScanning: vi.fn(() => false), + mixedSearch: vi.fn(() => ({ + ok: true, + value: { items: nativeItems, totalMatched: nativeItems.length }, + })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + const result = yield* searchIndex.list(true); + + expect(result.entries).toContainEqual({ path: ".env", kind: "file" }); + expect(result.entries.length).toBeLessThanOrEqual( + WorkspaceSearchIndex.WORKSPACE_INDEX_MAX_ENTRIES, + ); + expect(result.truncated).toBe(true); + }), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index d0e95fe5f1b..34e49769d08 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -15,7 +15,7 @@ import type { ProjectSearchEntriesResult, } from "@t3tools/contracts"; -const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; +export const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; @@ -175,11 +175,21 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } +/** + * Walk the filesystem to discover the dotfiles the native finder omits and + * return ONLY the newly discovered entries. `entries` (the finder's results) is + * used to avoid re-adding known paths and to seed the directories to scan, but + * it does not count against this walk's own entry budget — otherwise a large + * tree (e.g. the home directory) that already fills the finder's cap would + * starve dotfile discovery entirely. Prioritisation of these entries against the + * native results happens in `list`. + */ const supplementDotfiles = Effect.fn("WorkspaceSearchIndex.supplementDotfiles")(function* ( cwd: string, entries: ReadonlyArray, ) { - const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); + const existingPaths = new Set(entries.map((entry) => entry.path)); + const discovered = new Map(); const queue = [ { path: "", walkContents: false }, ...entries @@ -190,7 +200,7 @@ const supplementDotfiles = Effect.fn("WorkspaceSearchIndex.supplementDotfiles")( let queueIndex = 0; let truncated = false; - while (queueIndex < queue.length && entryByPath.size < WORKSPACE_INDEX_MAX_ENTRIES) { + while (queueIndex < queue.length && discovered.size < WORKSPACE_INDEX_MAX_ENTRIES) { const batch = queue.slice(queueIndex, queueIndex + 16); queueIndex += batch.length; const batchEntries = yield* Effect.forEach( @@ -209,21 +219,17 @@ const supplementDotfiles = Effect.fn("WorkspaceSearchIndex.supplementDotfiles")( if (DOTFILE_SCAN_DENYLIST.has(dirent.name)) continue; if (!directory.walkContents && !dirent.name.startsWith(".")) continue; const entryPath = toPosixPath(NodePath.join(directory.path, dirent.name)); - if (!entryByPath.has(entryPath)) { - if (entryByPath.size >= WORKSPACE_INDEX_MAX_ENTRIES) { + if (!existingPaths.has(entryPath) && !discovered.has(entryPath)) { + if (discovered.size >= WORKSPACE_INDEX_MAX_ENTRIES) { truncated = true; break; } - entryByPath.set(entryPath, { + discovered.set(entryPath, { path: entryPath, kind: dirent.isDirectory() ? "directory" : "file", }); } if (dirent.isDirectory() && !queuedPaths.has(entryPath)) { - if (entryByPath.size >= WORKSPACE_INDEX_MAX_ENTRIES) { - truncated = true; - break; - } queuedPaths.add(entryPath); queue.push({ path: entryPath, walkContents: true }); } @@ -232,7 +238,7 @@ const supplementDotfiles = Effect.fn("WorkspaceSearchIndex.supplementDotfiles")( } } if (queueIndex < queue.length) truncated = true; - return { entries: [...entryByPath.values()], truncated }; + return { entries: [...discovered.values()], truncated }; }); const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { @@ -355,19 +361,36 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin function* (showDotfiles) { const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); - const entriesWithAncestors = withDirectoryAncestors(mapped.entries); - const supplemented = showDotfiles - ? yield* supplementDotfiles(cwd, entriesWithAncestors) - : { entries: entriesWithAncestors, truncated: false }; - const sortedEntries = supplemented.entries.toSorted((left, right) => + const nativeEntries = withDirectoryAncestors(mapped.entries); + if (!showDotfiles) { + const sortedEntries = nativeEntries.toSorted((left, right) => + left.path.localeCompare(right.path), + ); + const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); + return { + entries, + truncated: mapped.truncated || entries.length < sortedEntries.length, + }; + } + + const supplemented = yield* supplementDotfiles(cwd, nativeEntries); + // Insert the dotfiles first so a large native (gitignore-filtered) tree — + // e.g. the home directory, which alone can exceed the entry cap — can't + // exhaust the budget before any dotfiles make it into the listing. + const entryByPath = new Map(supplemented.entries.map((entry) => [entry.path, entry])); + let truncated = mapped.truncated || supplemented.truncated; + for (const entry of nativeEntries) { + if (entryByPath.has(entry.path)) continue; + if (entryByPath.size >= WORKSPACE_INDEX_MAX_ENTRIES) { + truncated = true; + break; + } + entryByPath.set(entry.path, entry); + } + const entries = [...entryByPath.values()].toSorted((left, right) => left.path.localeCompare(right.path), ); - const entries = sortedEntries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES); - return { - entries, - truncated: - mapped.truncated || supplemented.truncated || entries.length < sortedEntries.length, - }; + return { entries, truncated }; }, );