authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 15:33:56-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-17 19:47:09-08:00
log2743e45dbed2895e47aed18bbabb36e8dae54247
treee6637b6e825a2aa48fecf040eec5e4681fcb9b8f
parent1b409c39468298a2a0c01d988fc2d90340877f13

Deduplicate workspace index builds and stabilize prompt replacement

- Reuse in-flight workspace index builds per cwd and cap `readdir` scan concurrency at 32 - Add server tests for build deduplication and directory read concurrency limits - Track composer text in a ref so prompt replacements use current text after send/reset

3 files changed, 122 insertions(+), 21 deletions(-)

apps/server/src/workspaceEntries.test.ts+59-1
......@@ -1,9 +1,10 @@
11import fs from "node:fs";
2import fsPromises from "node:fs/promises";
23import os from "node:os";
34import path from "node:path";
45import { spawnSync } from "node:child_process";
56
6import { afterEach, assert, describe, it } from "vitest";
7import { afterEach, assert, describe, it, vi } from "vitest";
78
89import { searchWorkspaceEntries } from "./workspaceEntries";
910
......@@ -30,6 +31,7 @@ function runGit(cwd: string, args: string[]): void {
3031
3132describe("searchWorkspaceEntries", () => {
3233 afterEach(() => {
34 vi.restoreAllMocks();
3335 for (const dir of tempDirs.splice(0, tempDirs.length)) {
3436 fs.rmSync(dir, { recursive: true, force: true });
3537 }
......@@ -115,4 +117,60 @@ describe("searchWorkspaceEntries", () => {
115117 assert.include(paths, "src/keep.ts");
116118 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
117119 });
120
121 it("deduplicates concurrent index builds for the same cwd", async () => {
122 const cwd = makeTempDir("t3code-workspace-concurrent-build-");
123 writeFile(cwd, "src/components/Composer.tsx");
124
125 let rootReadCount = 0;
126 const originalReaddir = fsPromises.readdir.bind(fsPromises);
127 vi.spyOn(fsPromises, "readdir").mockImplementation(
128 (async (...args: Parameters<typeof fsPromises.readdir>) => {
129 if (args[0] === cwd) {
130 rootReadCount += 1;
131 await new Promise((resolve) => setTimeout(resolve, 20));
132 }
133 return originalReaddir(...args);
134 }) as typeof fsPromises.readdir,
135 );
136
137 await Promise.all([
138 searchWorkspaceEntries({ cwd, query: "", limit: 100 }),
139 searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }),
140 searchWorkspaceEntries({ cwd, query: "src", limit: 100 }),
141 ]);
142
143 assert.equal(rootReadCount, 1);
144 });
145
146 it("limits concurrent directory reads while walking the filesystem", async () => {
147 const cwd = makeTempDir("t3code-workspace-read-concurrency-");
148 for (let index = 0; index < 80; index += 1) {
149 writeFile(cwd, `group-${index}/entry-${index}.ts`, "export {};");
150 }
151
152 let activeReads = 0;
153 let peakReads = 0;
154 const originalReaddir = fsPromises.readdir.bind(fsPromises);
155 vi.spyOn(fsPromises, "readdir").mockImplementation(
156 (async (...args: Parameters<typeof fsPromises.readdir>) => {
157 const target = args[0];
158 if (typeof target === "string" && target.startsWith(cwd)) {
159 activeReads += 1;
160 peakReads = Math.max(peakReads, activeReads);
161 await new Promise((resolve) => setTimeout(resolve, 4));
162 try {
163 return await originalReaddir(...args);
164 } finally {
165 activeReads -= 1;
166 }
167 }
168 return originalReaddir(...args);
169 }) as typeof fsPromises.readdir,
170 );
171
172 await searchWorkspaceEntries({ cwd, query: "", limit: 200 });
173
174 assert.isAtMost(peakReads, 32);
175 });
118176});
apps/server/src/workspaceEntries.ts+51-10
......@@ -13,6 +13,7 @@ import {
1313const WORKSPACE_CACHE_TTL_MS = 15_000;
1414const WORKSPACE_CACHE_MAX_KEYS = 4;
1515const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
16const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32;
1617const IGNORED_DIRECTORY_NAMES = new Set([
1718 ".git",
1819 ".convex",
......@@ -32,6 +33,7 @@ interface WorkspaceIndex {
3233}
3334
3435const workspaceIndexCache = new Map<string, WorkspaceIndex>();
36const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>();
3537
3638function toPosixPath(input: string): string {
3739 return input.split(path.sep).join("/");
......@@ -101,6 +103,31 @@ function directoryAncestorsOf(relativePath: string): string[] {
101103 return directories;
102104}
103105
106async function mapWithConcurrency<TInput, TOutput>(
107 items: readonly TInput[],
108 concurrency: number,
109 mapper: (item: TInput, index: number) => Promise<TOutput>,
110): Promise<TOutput[]> {
111 if (items.length === 0) {
112 return [];
113 }
114
115 const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length));
116 const results = Array.from({ length: items.length }) as TOutput[];
117 let nextIndex = 0;
118
119 const workers = Array.from({ length: boundedConcurrency }, async () => {
120 while (nextIndex < items.length) {
121 const currentIndex = nextIndex;
122 nextIndex += 1;
123 results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex);
124 }
125 });
126
127 await Promise.all(workers);
128 return results;
129}
130
104131async function isInsideGitWorkTree(cwd: string): Promise<boolean> {
105132 const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
106133 cwd,
......@@ -220,8 +247,10 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
220247 while (pendingDirectories.length > 0 && !truncated) {
221248 const currentDirectories = pendingDirectories;
222249 pendingDirectories = [];
223 const directoryEntries = await Promise.all(
224 currentDirectories.map(async (relativeDir) => {
250 const directoryEntries = await mapWithConcurrency(
251 currentDirectories,
252 WORKSPACE_SCAN_READDIR_CONCURRENCY,
253 async (relativeDir) => {
225254 const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd;
226255 try {
227256 const dirents = await fs.readdir(absoluteDir, { withFileTypes: true });
......@@ -235,7 +264,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
235264 }
236265 return { relativeDir, dirents: null };
237266 }
238 }),
267 },
239268 );
240269
241270 const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => {
......@@ -315,14 +344,26 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
315344 return cached;
316345 }
317346
318 const next = await buildWorkspaceIndex(cwd);
319 workspaceIndexCache.set(cwd, next);
320 while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) {
321 const oldestKey = workspaceIndexCache.keys().next().value;
322 if (!oldestKey) break;
323 workspaceIndexCache.delete(oldestKey);
347 const inFlight = inFlightWorkspaceIndexBuilds.get(cwd);
348 if (inFlight) {
349 return inFlight;
324350 }
325 return next;
351
352 const nextPromise = buildWorkspaceIndex(cwd)
353 .then((next) => {
354 workspaceIndexCache.set(cwd, next);
355 while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) {
356 const oldestKey = workspaceIndexCache.keys().next().value;
357 if (!oldestKey) break;
358 workspaceIndexCache.delete(oldestKey);
359 }
360 return next;
361 })
362 .finally(() => {
363 inFlightWorkspaceIndexBuilds.delete(cwd);
364 });
365 inFlightWorkspaceIndexBuilds.set(cwd, nextPromise);
366 return nextPromise;
326367}
327368
328369export async function searchWorkspaceEntries(
apps/web/src/components/ChatView.tsx+12-10
......@@ -329,6 +329,7 @@ export default function ChatView() {
329329 gitCreateWorktreeMutationOptions({ api, queryClient }),
330330 );
331331 const [prompt, setPrompt] = useState("");
332 const promptRef = useRef(prompt);
332333 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
333334 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
334335 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
......@@ -1126,6 +1127,7 @@ export default function ChatView() {
11261127 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
11271128 });
11281129 const previousMessages = activeThread.messages;
1130 promptRef.current = "";
11291131 setPrompt("");
11301132 setComposerImages([]);
11311133 setComposerCursor(0);
......@@ -1238,16 +1240,15 @@ export default function ChatView() {
12381240
12391241 const applyPromptReplacement = useCallback(
12401242 (rangeStart: number, rangeEnd: number, replacement: string) => {
1241 setPrompt((existing) => {
1242 const next = replaceTextRange(existing, rangeStart, rangeEnd, replacement);
1243 window.requestAnimationFrame(() => {
1244 const textarea = textareaRef.current;
1245 if (!textarea) return;
1246 textarea.focus();
1247 textarea.setSelectionRange(next.cursor, next.cursor);
1248 setComposerCursor(next.cursor);
1249 });
1250 return next.text;
1243 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
1244 promptRef.current = next.text;
1245 setPrompt(next.text);
1246 window.requestAnimationFrame(() => {
1247 const textarea = textareaRef.current;
1248 if (!textarea) return;
1249 textarea.focus();
1250 textarea.setSelectionRange(next.cursor, next.cursor);
1251 setComposerCursor(next.cursor);
12511252 });
12521253 },
12531254 [],
......@@ -1284,6 +1285,7 @@ export default function ChatView() {
12841285 workspaceEntriesQuery.isFetching);
12851286
12861287 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
1288 promptRef.current = nextPrompt;
12871289 setPrompt(nextPrompt);
12881290 setComposerCursor(nextCursor);
12891291 }, []);