Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/server/src/workspace/WorkspaceSearchIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
),
);
67 changes: 45 additions & 22 deletions apps/server/src/workspace/WorkspaceSearchIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -175,11 +175,21 @@ function withDirectoryAncestors(entries: ReadonlyArray<ProjectEntry>): 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<ProjectEntry>,
) {
const entryByPath = new Map(entries.map((entry) => [entry.path, entry]));
const existingPaths = new Set(entries.map((entry) => entry.path));
const discovered = new Map<string, ProjectEntry>();
const queue = [
{ path: "", walkContents: false },
...entries
Expand All @@ -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(
Expand All @@ -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 });
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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 };
},
);

Expand Down
Loading