- 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
Unified · Split
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import fs from "node:fs"; |
| 2 | +import fsPromises from "node:fs/promises"; | |
| 2 | 3 | import os from "node:os"; |
| 3 | 4 | import path from "node:path"; |
| 4 | 5 | import { spawnSync } from "node:child_process"; |
| 5 | 6 | |
| 6 | −import { afterEach, assert, describe, it } from "vitest"; | |
| 7 | +import { afterEach, assert, describe, it, vi } from "vitest"; | |
| 7 | 8 | |
| 8 | 9 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 9 | 10 | |
| @@ -30,6 +31,7 @@ function runGit(cwd: string, args: string[]): void { · 20 unmodified lines | ||
| 30 | 31 | |
| 31 | 32 | describe("searchWorkspaceEntries", () => { |
| 32 | 33 | afterEach(() => { |
| 34 | + vi.restoreAllMocks(); | |
| 33 | 35 | for (const dir of tempDirs.splice(0, tempDirs.length)) { |
| 34 | 36 | fs.rmSync(dir, { recursive: true, force: true }); |
| 35 | 37 | } |
| @@ -115,4 +117,60 @@ describe("searchWorkspaceEntries", () => { · 79 unmodified lines | ||
| 115 | 117 | assert.include(paths, "src/keep.ts"); |
| 116 | 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 | }); |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | const WORKSPACE_CACHE_TTL_MS = 15_000; |
| 14 | 14 | const WORKSPACE_CACHE_MAX_KEYS = 4; |
| 15 | 15 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; |
| 16 | +const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; | |
| 16 | 17 | const IGNORED_DIRECTORY_NAMES = new Set([ |
| 17 | 18 | ".git", |
| 18 | 19 | ".convex", |
| @@ -32,6 +33,7 @@ interface WorkspaceIndex { · 13 unmodified lines | ||
| 32 | 33 | } |
| 33 | 34 | |
| 34 | 35 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); |
| 36 | +const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>(); | |
| 35 | 37 | |
| 36 | 38 | function toPosixPath(input: string): string { |
| 37 | 39 | return input.split(path.sep).join("/"); |
| @@ -101,6 +103,31 @@ function directoryAncestorsOf(relativePath: string): string[] { · 63 unmodified lines | ||
| 101 | 103 | return directories; |
| 102 | 104 | } |
| 103 | 105 | |
| 106 | +async 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 | + | |
| 104 | 131 | async function isInsideGitWorkTree(cwd: string): Promise<boolean> { |
| 105 | 132 | const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { |
| 106 | 133 | cwd, |
| @@ -220,8 +247,10 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 113 unmodified lines | ||
| 220 | 247 | while (pendingDirectories.length > 0 && !truncated) { |
| 221 | 248 | const currentDirectories = pendingDirectories; |
| 222 | 249 | 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) => { | |
| 225 | 254 | const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; |
| 226 | 255 | try { |
| 227 | 256 | const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); |
| @@ -235,7 +264,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 7 unmodified lines | ||
| 235 | 264 | } |
| 236 | 265 | return { relativeDir, dirents: null }; |
| 237 | 266 | } |
| 238 | − }), | |
| 267 | + }, | |
| 239 | 268 | ); |
| 240 | 269 | |
| 241 | 270 | const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { |
| @@ -315,14 +344,26 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 73 unmodified lines | ||
| 315 | 344 | return cached; |
| 316 | 345 | } |
| 317 | 346 | |
| 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; | |
| 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 | } |
| 327 | 368 | |
| 328 | 369 | export async function searchWorkspaceEntries( |
| @@ -329,6 +329,7 @@ export default function ChatView() { · 328 unmodified lines | ||
| 329 | 329 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| 330 | 330 | ); |
| 331 | 331 | const [prompt, setPrompt] = useState(""); |
| 332 | + const promptRef = useRef(prompt); | |
| 332 | 333 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 333 | 334 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 334 | 335 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| @@ -1126,6 +1127,7 @@ export default function ChatView() { · 791 unmodified lines | ||
| 1126 | 1127 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), |
| 1127 | 1128 | }); |
| 1128 | 1129 | const previousMessages = activeThread.messages; |
| 1130 | + promptRef.current = ""; | |
| 1129 | 1131 | setPrompt(""); |
| 1130 | 1132 | setComposerImages([]); |
| 1131 | 1133 | setComposerCursor(0); |
| @@ -1238,16 +1240,15 @@ export default function ChatView() { · 106 unmodified lines | ||
| 1238 | 1240 | |
| 1239 | 1241 | const applyPromptReplacement = useCallback( |
| 1240 | 1242 | (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); | |
| 1251 | 1252 | }); |
| 1252 | 1253 | }, |
| 1253 | 1254 | [], |
| @@ -1284,6 +1285,7 @@ export default function ChatView() { · 30 unmodified lines | ||
| 1284 | 1285 | workspaceEntriesQuery.isFetching); |
| 1285 | 1286 | |
| 1286 | 1287 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { |
| 1288 | + promptRef.current = nextPrompt; | |
| 1287 | 1289 | setPrompt(nextPrompt); |
| 1288 | 1290 | setComposerCursor(nextCursor); |
| 1289 | 1291 | }, []); |