diff --git a/AGENTS.md b/AGENTS.md index 4119c31a6..5063b1ff4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_ANNOTATE_HISTORY` | Set to `0` / `false` to disable per-file version history in annotate mode (no copies of annotated files are written to the data dir; the annotate version diff is unavailable). Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "annotateHistory": false }`); the env var takes precedence. | | `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. | | `PLANNOTATOR_DATA_DIR` | Override the base data directory. Supports `~` expansion. Default: `~/.plannotator`. All data (plans, history, drafts, config, hooks, sessions, debug logs, IPC registry) is stored under this directory. | +| `PLANNOTATOR_FILE_BROWSER_MAX_FILES` | File-discovery limit: regular files inspected by CLI markdown/folder resolution and startup code-file warming, and supported files returned by the file browser. Must be a positive integer; invalid, zero, or negative values use the default of `5000`. | | `PLANNOTATOR_GLIMPSE` | Set to `0` / `false` to disable the Glimpse native window even when `glimpseui` is installed. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "glimpse": false }`). | | `PLANNOTATOR_GLIMPSE_WIDTH` | Width in pixels for the Glimpse native window. Default: `1280`. | | `PLANNOTATOR_GLIMPSE_HEIGHT` | Height in pixels for the Glimpse native window. Default: `900`. | diff --git a/apps/pi-extension/server.test.ts b/apps/pi-extension/server.test.ts index 43b2b1927..af1190114 100644 --- a/apps/pi-extension/server.test.ts +++ b/apps/pi-extension/server.test.ts @@ -13,11 +13,13 @@ import { runGitDiff, runVcsDiff, stageFile, + startAnnotateServer, startPlanReviewServer, startReviewServer, unstageFile, } from "./server"; import { WorkspaceReviewSession } from "./generated/review-workspace.js"; +import { warmFileListCache } from "./generated/resolve-file.js"; const tempDirs: string[] = []; const originalCwd = process.cwd(); @@ -26,6 +28,7 @@ const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; const originalPort = process.env.PLANNOTATOR_PORT; const originalSemPath = process.env.PLANNOTATOR_SEM_PATH; const originalDataDir = process.env.PLANNOTATOR_DATA_DIR; +const originalFileBrowserLimit = process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; function makeTempDir(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); @@ -207,12 +210,70 @@ afterEach(() => { } else { process.env.PLANNOTATOR_DATA_DIR = originalDataDir; } + if (originalFileBrowserLimit === undefined) { + delete process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + } else { + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = originalFileBrowserLimit; + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); +function observePiWarmState(projectRoot: string): Promise<"ready" | "warm"> { + const warm = warmFileListCache(projectRoot, "code").then(() => "warm" as const); + const ready = new Promise<"ready">((resolve) => { + queueMicrotask(() => resolve("ready")); + }); + return Promise.race([warm, ready]); +} + +async function expectPiReadyBeforeWarm( + start: () => Promise<{ url: string; stop(): void }>, +): Promise { + const projectRoot = makeTempDir("plannotator-pi-startup-warm-"); + const dataRoot = makeTempDir("plannotator-pi-startup-data-"); + writeTempFile(projectRoot, "document.md", "# Test\n"); + writeTempFile(projectRoot, "source.ts", "export {};\n"); + process.chdir(projectRoot); + process.env.PLANNOTATOR_DATA_DIR = dataRoot; + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = "1"; + process.env.PLANNOTATOR_PORT = String(await reservePort()); + + const server = await start(); + try { + expect(await observePiWarmState(process.cwd())).toBe("ready"); + const response = await fetch(`${server.url}/api/plan`); + expect(response.status).toBe(200); + } finally { + server.stop(); + } +} + +describe("pi startup file-cache warm", () => { + test("plan server binds before its cache warm can settle", async () => { + await expectPiReadyBeforeWarm(() => + startPlanReviewServer({ + plan: "# Test plan", + origin: "pi", + htmlContent: "plan", + }), + ); + }); + + test("annotate server binds before its cache warm can settle", async () => { + await expectPiReadyBeforeWarm(() => + startAnnotateServer({ + markdown: "# Test document", + filePath: join(process.cwd(), "document.md"), + htmlContent: "annotate", + origin: "pi", + }), + ); + }); +}); + describe("pi review server", () => { const testIfJj = hasJj() ? test : test.skip; const semanticRawPatch = [ diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 300d69ae2..045885754 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -180,8 +180,6 @@ export async function startAnnotateServer(options: { /** Project name for keying per-file version history (powers the annotate version diff). */ project?: string; }): Promise { - // Side-channel pre-warm so /api/doc/exists POSTs land on warm cache. - void warmFileListCache(process.cwd(), "code"); const gitUser = detectGitUser(); const sharingEnabled = options.sharingEnabled ?? resolveSharingEnabled(loadConfig()); @@ -651,6 +649,9 @@ export async function startAnnotateServer(options: { const { port, portSource } = await listenOnPort(server); + // Mirror the Bun server: bind first, then warm through the async shared walk. + void warmFileListCache(process.cwd(), "code"); + return { port, portSource, diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index e56dc0a05..490968e7c 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -84,8 +84,6 @@ export async function startPlanReviewServer(options: { mode?: "archive"; customPlanPath?: string | null; }): Promise { - // Side-channel pre-warm so /api/doc/exists POSTs land on warm cache. - void warmFileListCache(process.cwd(), "code"); const gitUser = detectGitUser(); const sharingEnabled = options.sharingEnabled ?? resolveSharingEnabled(loadConfig()); @@ -453,6 +451,9 @@ export async function startPlanReviewServer(options: { const { port, portSource } = await listenOnPort(server); + // Mirror the Bun server: bind first, then warm through the async shared walk. + void warmFileListCache(process.cwd(), "code"); + return { reviewId, port, diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 4c5ce99a0..ed776a898 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -135,9 +135,6 @@ const RETRY_DELAY_MS = 500; export async function startAnnotateServer( options: AnnotateServerOptions ): Promise { - // Side-channel pre-warm so /api/doc/exists POSTs land on warm cache. - void warmFileListCache(process.cwd(), "code"); - const { markdown, filePath, @@ -747,6 +744,10 @@ export async function startAnnotateServer( const port = server.port!; const serverUrl = `http://localhost:${port}`; + // The cache warm must never gate the listening socket. Its async filesystem + // walk yields between directories while requests remain serviceable. + void warmFileListCache(process.cwd(), "code"); + // Notify caller that server is ready if (onReady) { onReady(serverUrl, isRemote, port); diff --git a/packages/server/index.ts b/packages/server/index.ts index 5519eaa6d..8bc95979a 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -135,10 +135,6 @@ export async function startPlannotatorServer( const wslFlag = await isWSL(); const gitUser = detectGitUser(); - // Side-channel pre-warm: kick off the code-file walk now so the - // renderer's POST /api/doc/exists lands on warm cache. - void warmFileListCache(process.cwd(), "code"); - // --- Archive mode setup --- let archivePlans: ArchivedPlan[] = []; let initialArchivePlan = ""; @@ -614,6 +610,10 @@ export async function startPlannotatorServer( const port = server.port!; const serverUrl = `http://localhost:${port}`; + // The cache warm must never gate the listening socket. Its async filesystem + // walk yields between directories while requests remain serviceable. + void warmFileListCache(process.cwd(), "code"); + // Notify caller that server is ready if (onReady) { onReady(serverUrl, isRemote, port); diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index bf509e8e8..d93e3b7cb 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -24,6 +24,7 @@ import { resolveMarkdownFile, resolveUserPath, isWithinProjectRoot, + getFileBrowserMaxFiles, warmFileListCache, } from "@plannotator/shared/resolve-file"; import { htmlToMarkdown } from "@plannotator/shared/html-to-markdown"; @@ -543,17 +544,11 @@ export async function handleObsidianDoc(req: Request): Promise { // --- File Browser --- const FILE_BROWSER_EXTENSIONS = /\.(mdx?|txt|html?)$/i; -const DEFAULT_FILE_BROWSER_MAX_FILES = 5_000; function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange): boolean { return FILE_BROWSER_EXTENSIONS.test(relativePath) && !isFileBrowserExcludedPath(relativePath); } -function getFileBrowserMaxFiles(): number { - const value = Number.parseInt(process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES ?? "", 10); - return Number.isFinite(value) && value > 0 ? value : DEFAULT_FILE_BROWSER_MAX_FILES; -} - type FileBrowserWalkState = { files: Set; limit: number; diff --git a/packages/server/startup-warm.test.ts b/packages/server/startup-warm.test.ts new file mode 100644 index 000000000..c819b4288 --- /dev/null +++ b/packages/server/startup-warm.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { warmFileListCache } from "@plannotator/shared/resolve-file"; +import { startAnnotateServer } from "./annotate"; +import { startPlannotatorServer } from "./index"; + +const MINIMAL_HTML = "Plannotator"; + +type StartedServer = { + readonly url: string; + stop(): void; +}; + +type ReadyCallback = (url: string, isRemote: boolean, port: number) => void; + +function observeWarmState(projectRoot: string): Promise<"ready" | "warm"> { + const warm = warmFileListCache(projectRoot, "code").then(() => "warm" as const); + const ready = new Promise<"ready">((resolve) => { + queueMicrotask(() => resolve("ready")); + }); + return Promise.race([warm, ready]); +} + +async function expectReadyBeforeWarm( + start: (onReady: ReadyCallback) => Promise, +): Promise { + const projectRoot = mkdtempSync(join(tmpdir(), "plannotator-startup-warm-")); + const dataRoot = mkdtempSync(join(tmpdir(), "plannotator-startup-data-")); + const previousCwd = process.cwd(); + const previousPort = process.env.PLANNOTATOR_PORT; + const previousRemote = process.env.PLANNOTATOR_REMOTE; + const previousDataDir = process.env.PLANNOTATOR_DATA_DIR; + const previousLimit = process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + let server: StartedServer | null = null; + let ordering: Promise<"ready" | "warm"> | null = null; + + try { + writeFileSync(join(projectRoot, "document.md"), "# Test\n"); + writeFileSync(join(projectRoot, "source.ts"), "export {};\n"); + process.chdir(projectRoot); + delete process.env.PLANNOTATOR_PORT; + process.env.PLANNOTATOR_REMOTE = "0"; + process.env.PLANNOTATOR_DATA_DIR = dataRoot; + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = "1"; + + server = await start(() => { + // Observe with the server's OWN cache key: process.cwd() inside onReady + // is the realpath (on macOS mkdtemp returns /var/... but cwd resolves to + // /private/var/...), and a key mismatch would race a FRESH warm instead + // of the server's, making the test pass on any code. + ordering = observeWarmState(process.cwd()); + }); + + const observedOrdering = ordering; + if (!observedOrdering) { + throw new Error("Server did not invoke its ready callback"); + } + expect(await observedOrdering).toBe("ready"); + + const response = await fetch(`${server.url}/api/plan`); + expect(response.status).toBe(200); + } finally { + server?.stop(); + process.chdir(previousCwd); + if (previousPort === undefined) delete process.env.PLANNOTATOR_PORT; + else process.env.PLANNOTATOR_PORT = previousPort; + if (previousRemote === undefined) delete process.env.PLANNOTATOR_REMOTE; + else process.env.PLANNOTATOR_REMOTE = previousRemote; + if (previousDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR; + else process.env.PLANNOTATOR_DATA_DIR = previousDataDir; + if (previousLimit === undefined) { + delete process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + } else { + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = previousLimit; + } + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(dataRoot, { recursive: true, force: true }); + } +} + +describe("startup file-cache warm", () => { + test("Bun plan server binds before its cache warm can settle", async () => { + await expectReadyBeforeWarm((onReady) => + startPlannotatorServer({ + plan: "# Test plan", + origin: "codex", + htmlContent: MINIMAL_HTML, + onReady, + }), + ); + }); + + test("Bun annotate server binds before its cache warm can settle", async () => { + await expectReadyBeforeWarm((onReady) => + startAnnotateServer({ + markdown: "# Test document", + filePath: join(process.cwd(), "document.md"), + htmlContent: MINIMAL_HTML, + onReady, + }), + ); + }); +}); diff --git a/packages/shared/resolve-file.test.ts b/packages/shared/resolve-file.test.ts index a9a9c98a2..33837da1c 100644 --- a/packages/shared/resolve-file.test.ts +++ b/packages/shared/resolve-file.test.ts @@ -2,7 +2,13 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { resolveCodeFile } from "./resolve-file"; +import { + getFileBrowserMaxFiles, + hasMarkdownFiles, + resolveCodeFile, + resolveMarkdownFile, + warmFileListCache, +} from "./resolve-file"; let root: string; @@ -111,3 +117,132 @@ describe("resolveCodeFile", () => { } }); }); + +describe("bounded file traversal", () => { + test("parses the shared file limit with the established fallback semantics", () => { + const previousLimit = process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + try { + for (const invalid of ["", "0", "-1", "not-a-number"]) { + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = invalid; + expect(getFileBrowserMaxFiles()).toBe(5_000); + } + + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = "12files"; + expect(getFileBrowserMaxFiles()).toBe(12); + } finally { + if (previousLimit === undefined) { + delete process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + } else { + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = previousLimit; + } + } + }); + + test("caps the async code-file cache warm", async () => { + const limitedRoot = mkdtempSync(join(tmpdir(), "plannotator-code-limit-")); + const previousLimit = process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + try { + for (let index = 0; index < 5; index += 1) { + writeFileSync(join(limitedRoot, `file-${index}.ts`), "export {};\n"); + } + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = "2"; + + const files = await warmFileListCache(limitedRoot, "code"); + expect(files).not.toBeNull(); + expect(files).toHaveLength(2); + } finally { + if (previousLimit === undefined) { + delete process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + } else { + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = previousLimit; + } + rmSync(limitedRoot, { recursive: true, force: true }); + } + }); + + test("caps fallback markdown discovery", () => { + const limitedRoot = mkdtempSync(join(tmpdir(), "plannotator-markdown-limit-")); + const previousLimit = process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + try { + for (const directory of ["one", "two", "three"]) { + mkdirSync(join(limitedRoot, directory)); + writeFileSync(join(limitedRoot, directory, "plan.md"), `# ${directory}\n`); + } + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = "2"; + + const result = resolveMarkdownFile("plan.md", limitedRoot); + expect(result.kind).toBe("ambiguous"); + if (result.kind === "ambiguous") { + expect(result.matches).toHaveLength(2); + } + } finally { + if (previousLimit === undefined) { + delete process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + } else { + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = previousLimit; + } + rmSync(limitedRoot, { recursive: true, force: true }); + } + }); + + test("caps folder-target discovery even when no files match", () => { + const limitedRoot = mkdtempSync(join(tmpdir(), "plannotator-folder-limit-")); + const previousLimit = process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + class CountingRegExp extends RegExp { + calls = 0; + + override test(value: string): boolean { + this.calls += 1; + return super.test(value); + } + } + + try { + for (let index = 0; index < 5; index += 1) { + writeFileSync(join(limitedRoot, `file-${index}.txt`), "not markdown\n"); + } + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = "2"; + const extensions = new CountingRegExp("^never-match$"); + + expect(hasMarkdownFiles(limitedRoot, [], extensions)).toBe(false); + expect(extensions.calls).toBe(2); + } finally { + if (previousLimit === undefined) { + delete process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + } else { + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = previousLimit; + } + rmSync(limitedRoot, { recursive: true, force: true }); + } + }); + + test("keeps exact and in-budget bare markdown resolution working", () => { + const exactRoot = mkdtempSync(join(tmpdir(), "plannotator-markdown-exact-")); + const bareRoot = mkdtempSync(join(tmpdir(), "plannotator-markdown-bare-")); + const previousLimit = process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + try { + mkdirSync(join(exactRoot, "docs")); + writeFileSync(join(exactRoot, "docs", "plan.md"), "# Exact\n"); + mkdirSync(join(bareRoot, "notes")); + writeFileSync(join(bareRoot, "notes", "Architecture.MD"), "# Bare\n"); + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = "1"; + + expect(resolveMarkdownFile("docs/plan.md", exactRoot)).toEqual({ + kind: "found", + path: join(exactRoot, "docs", "plan.md"), + }); + expect(resolveMarkdownFile("architecture.md", bareRoot)).toEqual({ + kind: "found", + path: join(bareRoot, "notes", "Architecture.MD"), + }); + } finally { + if (previousLimit === undefined) { + delete process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES; + } else { + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES = previousLimit; + } + rmSync(exactRoot, { recursive: true, force: true }); + rmSync(bareRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/shared/resolve-file.ts b/packages/shared/resolve-file.ts index 43112279d..f9270cd4d 100644 --- a/packages/shared/resolve-file.ts +++ b/packages/shared/resolve-file.ts @@ -12,6 +12,7 @@ import { homedir } from "os"; import { isAbsolute, join, resolve, win32 } from "path"; import { existsSync, readdirSync, type Dirent } from "fs"; +import { readdir } from "node:fs/promises"; const MARKDOWN_PATH_REGEX = /\.(mdx?|txt)$/i; @@ -45,6 +46,23 @@ const CODE_IGNORED_DIRS = [ ".pytest_cache/", ]; +const DEFAULT_FILE_BROWSER_MAX_FILES = 5_000; + +/** + * Return the shared file-traversal budget used by resolution, cache warming, + * and file-browser discovery. Invalid or non-positive overrides fall back to + * the default. + */ +export function getFileBrowserMaxFiles(): number { + const value = Number.parseInt( + process.env.PLANNOTATOR_FILE_BROWSER_MAX_FILES ?? "", + 10, + ); + return Number.isFinite(value) && value > 0 + ? value + : DEFAULT_FILE_BROWSER_MAX_FILES; +} + export type ResolveResult = | { kind: "found"; path: string } | { kind: "not_found"; input: string } @@ -194,6 +212,11 @@ function fileExists(filePath: string): boolean { } } +type FileWalkState = { + visitedFiles: number; + readonly limit: number; +}; + /** Recursively walk a directory collecting files matching `fileMatcher`, skipping ignored dirs. */ function walkFiles( dir: string, @@ -201,28 +224,48 @@ function walkFiles( results: string[], ignoredDirs: string[], fileMatcher: (name: string) => boolean, + state: FileWalkState, ): void { + if (state.visitedFiles >= state.limit) return; const entries = readdirSync(dir, { withFileTypes: true }) as Dirent[]; for (const entry of entries) { + if (state.visitedFiles >= state.limit) return; if (entry.isDirectory()) { if (ignoredDirs.some((d) => d === entry.name + "/")) continue; try { - walkFiles(join(dir, entry.name), root, results, ignoredDirs, fileMatcher); + walkFiles( + join(dir, entry.name), + root, + results, + ignoredDirs, + fileMatcher, + state, + ); } catch { /* skip dirs we can't read */ } - } else if (entry.isFile() && fileMatcher(entry.name)) { - const relative = join(dir, entry.name) - .slice(root.length + 1) - .replace(/\\/g, "/"); - results.push(relative); + } else if (entry.isFile()) { + state.visitedFiles += 1; + if (fileMatcher(entry.name)) { + const relative = join(dir, entry.name) + .slice(root.length + 1) + .replace(/\\/g, "/"); + results.push(relative); + } } } } function walkMarkdownFiles(dir: string, root: string, results: string[], ignoredDirs: string[]): void { try { - walkFiles(dir, root, results, ignoredDirs, (name) => /\.(mdx?|txt)$/i.test(name)); + walkFiles( + dir, + root, + results, + ignoredDirs, + (name) => /\.(mdx?|txt)$/i.test(name), + { visitedFiles: 0, limit: getFileBrowserMaxFiles() }, + ); } catch { /* fail soft for markdown — preserves existing behavior */ } @@ -240,18 +283,46 @@ function fileListCacheKey(projectRoot: string, kind: string): string { return `${projectRoot}|${kind}`; } -function startCodeWalk(projectRoot: string): Promise { - return Promise.resolve().then(() => { - try { - const results: string[] = []; - walkFiles(projectRoot, projectRoot, results, CODE_IGNORED_DIRS, (name) => - CODE_FILE_BASENAME_REGEX.test(name), - ); - return results; - } catch { - return null; +async function walkCodeFiles( + dir: string, + root: string, + results: string[], + state: FileWalkState, +): Promise { + if (state.visitedFiles >= state.limit) return; + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (state.visitedFiles >= state.limit) return; + if (entry.isDirectory()) { + if (CODE_IGNORED_DIRS.some((d) => d === entry.name + "/")) continue; + try { + await walkCodeFiles(join(dir, entry.name), root, results, state); + } catch { + /* skip dirs we can't read */ + } + } else if (entry.isFile()) { + state.visitedFiles += 1; + if (CODE_FILE_BASENAME_REGEX.test(entry.name)) { + const relative = join(dir, entry.name) + .slice(root.length + 1) + .replace(/\\/g, "/"); + results.push(relative); + } } - }); + } +} + +async function startCodeWalk(projectRoot: string): Promise { + try { + const results: string[] = []; + await walkCodeFiles(projectRoot, projectRoot, results, { + visitedFiles: 0, + limit: getFileBrowserMaxFiles(), + }); + return results; + } catch { + return null; + } } /** @@ -488,7 +559,13 @@ export function hasMarkdownFiles( excludedDirs: string[] = IGNORED_DIRS, extensions: RegExp = /\.mdx?$/i, ): boolean { + const state: FileWalkState = { + visitedFiles: 0, + limit: getFileBrowserMaxFiles(), + }; + function walk(dir: string): boolean { + if (state.visitedFiles >= state.limit) return false; let entries; try { entries = readdirSync(dir, { withFileTypes: true }); @@ -496,11 +573,13 @@ export function hasMarkdownFiles( return false; } for (const entry of entries) { + if (state.visitedFiles >= state.limit) return false; if (entry.isDirectory()) { if (excludedDirs.some((d) => d === entry.name + "/")) continue; if (walk(join(dir, entry.name))) return true; - } else if (entry.isFile() && extensions.test(entry.name)) { - return true; + } else if (entry.isFile()) { + state.visitedFiles += 1; + if (extensions.test(entry.name)) return true; } } return false;