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 @@...@@ -1,9 +1,10 @@
1import fs from "node:fs";1import fs from "node:fs";
2import fsPromises from "node:fs/promises";
2import os from "node:os";3import os from "node:os";
3import path from "node:path";4import path from "node:path";
4import { spawnSync } from "node:child_process";5import { spawnSync } from "node:child_process";
56
6import { afterEach, assert, describe, it } from "vitest";7import { afterEach, assert, describe, it, vi } from "vitest";
78
8import { searchWorkspaceEntries } from "./workspaceEntries";9import { searchWorkspaceEntries } from "./workspaceEntries";
910
...@@ -30,6 +31,7 @@ function runGit(cwd: string, args: string[]): void {...@@ -30,6 +31,7 @@ function runGit(cwd: string, args: string[]): void {
3031
31describe("searchWorkspaceEntries", () => {32describe("searchWorkspaceEntries", () => {
32 afterEach(() => {33 afterEach(() => {
34 vi.restoreAllMocks();
33 for (const dir of tempDirs.splice(0, tempDirs.length)) {35 for (const dir of tempDirs.splice(0, tempDirs.length)) {
34 fs.rmSync(dir, { recursive: true, force: true });36 fs.rmSync(dir, { recursive: true, force: true });
35 }37 }
...@@ -115,4 +117,60 @@ describe("searchWorkspaceEntries", () => {...@@ -115,4 +117,60 @@ describe("searchWorkspaceEntries", () => {
115 assert.include(paths, "src/keep.ts");117 assert.include(paths, "src/keep.ts");
116 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));118 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
117 });119 });
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 });
118});176});
apps/server/src/workspaceEntries.ts+51-10
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13const WORKSPACE_CACHE_TTL_MS = 15_000;13const WORKSPACE_CACHE_TTL_MS = 15_000;
14const WORKSPACE_CACHE_MAX_KEYS = 4;14const WORKSPACE_CACHE_MAX_KEYS = 4;
15const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;15const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
16const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32;
16const IGNORED_DIRECTORY_NAMES = new Set([17const IGNORED_DIRECTORY_NAMES = new Set([
17 ".git",18 ".git",
18 ".convex",19 ".convex",
...@@ -32,6 +33,7 @@ interface WorkspaceIndex {...@@ -32,6 +33,7 @@ interface WorkspaceIndex {
32}33}
3334
34const workspaceIndexCache = new Map<string, WorkspaceIndex>();35const workspaceIndexCache = new Map<string, WorkspaceIndex>();
36const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>();
3537
36function toPosixPath(input: string): string {38function toPosixPath(input: string): string {
37 return input.split(path.sep).join("/");39 return input.split(path.sep).join("/");
...@@ -101,6 +103,31 @@ function directoryAncestorsOf(relativePath: string): string[] {...@@ -101,6 +103,31 @@ function directoryAncestorsOf(relativePath: string): string[] {
101 return directories;103 return directories;
102}104}
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
104async function isInsideGitWorkTree(cwd: string): Promise<boolean> {131async function isInsideGitWorkTree(cwd: string): Promise<boolean> {
105 const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {132 const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
106 cwd,133 cwd,
...@@ -220,8 +247,10 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {...@@ -220,8 +247,10 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
220 while (pendingDirectories.length > 0 && !truncated) {247 while (pendingDirectories.length > 0 && !truncated) {
221 const currentDirectories = pendingDirectories;248 const currentDirectories = pendingDirectories;
222 pendingDirectories = [];249 pendingDirectories = [];
223 const directoryEntries = await Promise.all(250 const directoryEntries = await mapWithConcurrency(
224 currentDirectories.map(async (relativeDir) => {251 currentDirectories,
252 WORKSPACE_SCAN_READDIR_CONCURRENCY,
253 async (relativeDir) => {
225 const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd;254 const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd;
226 try {255 try {
227 const dirents = await fs.readdir(absoluteDir, { withFileTypes: true });256 const dirents = await fs.readdir(absoluteDir, { withFileTypes: true });
...@@ -235,7 +264,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {...@@ -235,7 +264,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
235 }264 }
236 return { relativeDir, dirents: null };265 return { relativeDir, dirents: null };
237 }266 }
238 }),267 },
239 );268 );
240269
241 const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => {270 const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => {
...@@ -315,14 +344,26 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {...@@ -315,14 +344,26 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
315 return cached;344 return cached;
316 }345 }
317346
318 const next = await buildWorkspaceIndex(cwd);347 const inFlight = inFlightWorkspaceIndexBuilds.get(cwd);
319 workspaceIndexCache.set(cwd, next);348 if (inFlight) {
320 while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) {349 return inFlight;
321 const oldestKey = workspaceIndexCache.keys().next().value;
322 if (!oldestKey) break;
323 workspaceIndexCache.delete(oldestKey);
324 }350 }
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;
326}367}
327368
328export async function searchWorkspaceEntries(369export async function searchWorkspaceEntries(
apps/web/src/components/ChatView.tsx+12-10
...@@ -329,6 +329,7 @@ export default function ChatView() {...@@ -329,6 +329,7 @@ export default function ChatView() {
329 gitCreateWorktreeMutationOptions({ api, queryClient }),329 gitCreateWorktreeMutationOptions({ api, queryClient }),
330 );330 );
331 const [prompt, setPrompt] = useState("");331 const [prompt, setPrompt] = useState("");
332 const promptRef = useRef(prompt);
332 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);333 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
333 const [isDragOverComposer, setIsDragOverComposer] = useState(false);334 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
334 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);335 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
...@@ -1126,6 +1127,7 @@ export default function ChatView() {...@@ -1126,6 +1127,7 @@ export default function ChatView() {
1126 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),1127 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
1127 });1128 });
1128 const previousMessages = activeThread.messages;1129 const previousMessages = activeThread.messages;
1130 promptRef.current = "";
1129 setPrompt("");1131 setPrompt("");
1130 setComposerImages([]);1132 setComposerImages([]);
1131 setComposerCursor(0);1133 setComposerCursor(0);
...@@ -1238,16 +1240,15 @@ export default function ChatView() {...@@ -1238,16 +1240,15 @@ export default function ChatView() {
12381240
1239 const applyPromptReplacement = useCallback(1241 const applyPromptReplacement = useCallback(
1240 (rangeStart: number, rangeEnd: number, replacement: string) => {1242 (rangeStart: number, rangeEnd: number, replacement: string) => {
1241 setPrompt((existing) => {1243 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
1242 const next = replaceTextRange(existing, rangeStart, rangeEnd, replacement);1244 promptRef.current = next.text;
1243 window.requestAnimationFrame(() => {1245 setPrompt(next.text);
1244 const textarea = textareaRef.current;1246 window.requestAnimationFrame(() => {
1245 if (!textarea) return;1247 const textarea = textareaRef.current;
1246 textarea.focus();1248 if (!textarea) return;
1247 textarea.setSelectionRange(next.cursor, next.cursor);1249 textarea.focus();
1248 setComposerCursor(next.cursor);1250 textarea.setSelectionRange(next.cursor, next.cursor);
1249 });1251 setComposerCursor(next.cursor);
1250 return next.text;
1251 });1252 });
1252 },1253 },
1253 [],1254 [],
...@@ -1284,6 +1285,7 @@ export default function ChatView() {...@@ -1284,6 +1285,7 @@ export default function ChatView() {
1284 workspaceEntriesQuery.isFetching);1285 workspaceEntriesQuery.isFetching);
12851286
1286 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {1287 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
1288 promptRef.current = nextPrompt;
1287 setPrompt(nextPrompt);1289 setPrompt(nextPrompt);
1288 setComposerCursor(nextCursor);1290 setComposerCursor(nextCursor);
1289 }, []);1291 }, []);