diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index 90ed8b30706975e9b5a9369829067a2b80c69891..e3660609dfedac710decffe58022416fbd0a0690 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -1,9 +1,10 @@ import fs from "node:fs"; +import fsPromises from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { afterEach, assert, describe, it } from "vitest"; +import { afterEach, assert, describe, it, vi } from "vitest"; import { searchWorkspaceEntries } from "./workspaceEntries"; @@ -30,6 +31,7 @@ function runGit(cwd: string, args: string[]): void { describe("searchWorkspaceEntries", () => { afterEach(() => { + vi.restoreAllMocks(); for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -115,4 +117,60 @@ describe("searchWorkspaceEntries", () => { assert.include(paths, "src/keep.ts"); assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); }); + + it("deduplicates concurrent index builds for the same cwd", async () => { + const cwd = makeTempDir("t3code-workspace-concurrent-build-"); + writeFile(cwd, "src/components/Composer.tsx"); + + let rootReadCount = 0; + const originalReaddir = fsPromises.readdir.bind(fsPromises); + vi.spyOn(fsPromises, "readdir").mockImplementation( + (async (...args: Parameters) => { + if (args[0] === cwd) { + rootReadCount += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir, + ); + + await Promise.all([ + searchWorkspaceEntries({ cwd, query: "", limit: 100 }), + searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), + searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), + ]); + + assert.equal(rootReadCount, 1); + }); + + it("limits concurrent directory reads while walking the filesystem", async () => { + const cwd = makeTempDir("t3code-workspace-read-concurrency-"); + for (let index = 0; index < 80; index += 1) { + writeFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"); + } + + let activeReads = 0; + let peakReads = 0; + const originalReaddir = fsPromises.readdir.bind(fsPromises); + vi.spyOn(fsPromises, "readdir").mockImplementation( + (async (...args: Parameters) => { + const target = args[0]; + if (typeof target === "string" && target.startsWith(cwd)) { + activeReads += 1; + peakReads = Math.max(peakReads, activeReads); + await new Promise((resolve) => setTimeout(resolve, 4)); + try { + return await originalReaddir(...args); + } finally { + activeReads -= 1; + } + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir, + ); + + await searchWorkspaceEntries({ cwd, query: "", limit: 200 }); + + assert.isAtMost(peakReads, 32); + }); }); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index 1064a7bed55971f08e260d1adad693ffcbcacb01..5c0ab3cfaee350e99236e3d100334f7f23bab079 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -13,6 +13,7 @@ import { const WORKSPACE_CACHE_TTL_MS = 15_000; const WORKSPACE_CACHE_MAX_KEYS = 4; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; +const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; const IGNORED_DIRECTORY_NAMES = new Set([ ".git", ".convex", @@ -32,6 +33,7 @@ interface WorkspaceIndex { } const workspaceIndexCache = new Map(); +const inFlightWorkspaceIndexBuilds = new Map>(); function toPosixPath(input: string): string { return input.split(path.sep).join("/"); @@ -101,6 +103,31 @@ function directoryAncestorsOf(relativePath: string): string[] { return directories; } +async function mapWithConcurrency( + items: readonly TInput[], + concurrency: number, + mapper: (item: TInput, index: number) => Promise, +): Promise { + if (items.length === 0) { + return []; + } + + const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length)); + const results = Array.from({ length: items.length }) as TOutput[]; + let nextIndex = 0; + + const workers = Array.from({ length: boundedConcurrency }, async () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex); + } + }); + + await Promise.all(workers); + return results; +} + async function isInsideGitWorkTree(cwd: string): Promise { const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { cwd, @@ -220,8 +247,10 @@ async function buildWorkspaceIndex(cwd: string): Promise { while (pendingDirectories.length > 0 && !truncated) { const currentDirectories = pendingDirectories; pendingDirectories = []; - const directoryEntries = await Promise.all( - currentDirectories.map(async (relativeDir) => { + const directoryEntries = await mapWithConcurrency( + currentDirectories, + WORKSPACE_SCAN_READDIR_CONCURRENCY, + async (relativeDir) => { const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; try { const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); @@ -235,7 +264,7 @@ async function buildWorkspaceIndex(cwd: string): Promise { } return { relativeDir, dirents: null }; } - }), + }, ); const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { @@ -315,14 +344,26 @@ async function getWorkspaceIndex(cwd: string): Promise { return cached; } - const next = await buildWorkspaceIndex(cwd); - workspaceIndexCache.set(cwd, next); - while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { - const oldestKey = workspaceIndexCache.keys().next().value; - if (!oldestKey) break; - workspaceIndexCache.delete(oldestKey); + const inFlight = inFlightWorkspaceIndexBuilds.get(cwd); + if (inFlight) { + return inFlight; } - return next; + + const nextPromise = buildWorkspaceIndex(cwd) + .then((next) => { + workspaceIndexCache.set(cwd, next); + while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { + const oldestKey = workspaceIndexCache.keys().next().value; + if (!oldestKey) break; + workspaceIndexCache.delete(oldestKey); + } + return next; + }) + .finally(() => { + inFlightWorkspaceIndexBuilds.delete(cwd); + }); + inFlightWorkspaceIndexBuilds.set(cwd, nextPromise); + return nextPromise; } export async function searchWorkspaceEntries( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab042183a5f30244d9cb385e0879096e3fbcc2f3..aba52e27db41eadd68a772e9f5b09ba8b32c296f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -329,6 +329,7 @@ export default function ChatView() { gitCreateWorktreeMutationOptions({ api, queryClient }), ); const [prompt, setPrompt] = useState(""); + const promptRef = useRef(prompt); const [composerImages, setComposerImages] = useState([]); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); @@ -1126,6 +1127,7 @@ export default function ChatView() { ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), }); const previousMessages = activeThread.messages; + promptRef.current = ""; setPrompt(""); setComposerImages([]); setComposerCursor(0); @@ -1238,16 +1240,15 @@ export default function ChatView() { const applyPromptReplacement = useCallback( (rangeStart: number, rangeEnd: number, replacement: string) => { - setPrompt((existing) => { - const next = replaceTextRange(existing, rangeStart, rangeEnd, replacement); - window.requestAnimationFrame(() => { - const textarea = textareaRef.current; - if (!textarea) return; - textarea.focus(); - textarea.setSelectionRange(next.cursor, next.cursor); - setComposerCursor(next.cursor); - }); - return next.text; + const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + promptRef.current = next.text; + setPrompt(next.text); + window.requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.focus(); + textarea.setSelectionRange(next.cursor, next.cursor); + setComposerCursor(next.cursor); }); }, [], @@ -1284,6 +1285,7 @@ export default function ChatView() { workspaceEntriesQuery.isFetching); const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { + promptRef.current = nextPrompt; setPrompt(nextPrompt); setComposerCursor(nextCursor); }, []);