From a908be05ac9dcbaed7a1df6326386359a04f1b84 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 02:57:28 +0100 Subject: [PATCH] fix(files): mark folders the scan limit stopped short of The fair rounds give a directory its node before reading it, so every folder still queued when the 5,000-node budget ends sat in the tree with empty or partial children and read as an empty folder. Keep the node on the tree cursor and set an additive incomplete: true on every cursor left in the queue, then draw one dim, inert row under such a folder while it is expanded. The envelope check ignores keys it does not know, so old and new clients stay compatible. Stop each round at the top once the budget is spent, before the shift: the rest stay queued unread, and flagged, rather than read and sorted for nothing. Say what the walk is: a per-directory round-robin (breadth-first), fair per directory and not per subtree, with search results now shallow-first. Co-Authored-By: Claude Opus 5.5 --- .changeset/quiet-folders-share.md | 2 +- CLAUDE.md | 2 +- docs/api-reference.md | 8 + docs/architecture-invariants.md | 2 + docs/wiki/Working-With-Files.md | 5 + src/web/public/i18n.js | 1 + src/web/public/panels-ui.js | 6 + src/web/public/styles.css | 28 ++- src/web/routes/file-routes.ts | 24 ++- test/file-browser-incomplete.test.ts | 167 ++++++++++++++++++ test/routes/file-routes-live-worktree.test.ts | 29 +++ test/routes/file-search-mode.test.ts | 111 ++++++++++++ 12 files changed, 370 insertions(+), 15 deletions(-) create mode 100644 test/file-browser-incomplete.test.ts diff --git a/.changeset/quiet-folders-share.md b/.changeset/quiet-folders-share.md index 967b6b15..e319fd85 100644 --- a/.changeset/quiet-folders-share.md +++ b/.changeset/quiet-folders-share.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -Share File Viewer scan budgets across sibling directories so large early folders do not hide later results. +Files panel: one large folder no longer uses up the 5,000-entry scan limit and hides the folders after it. The tree and its search now walk folders in a per-directory round-robin (breadth-first), so search results come back shallow-first rather than depth-first. This is fair per directory, not per subtree: a folder made of thousands of small subfolders can still use most of the limit one level down. A folder the limit stopped short of no longer looks empty: `GET /api/sessions/:id/files` marks it `incomplete: true` (additive) and, when expanded, it shows a dim "Not fully scanned (file limit reached)" row. diff --git a/CLAUDE.md b/CLAUDE.md index 28c54e8d..009fc993 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,7 +236,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` -**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search) +**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget. ⚠️ A directory has its node before it is read, so every cursor still queued when the budget ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search) **Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. ⚠️ The security argument has two halves. The lookup only ever answers a folder strictly below `workingDir`, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make `liveWorktree` a confinement root (a worktree outside the folder stays unfollowed). ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree) diff --git a/docs/api-reference.md b/docs/api-reference.md index af502fe3..81f6271a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1014,6 +1014,14 @@ while a `q` containing `/` is matched against paths relative to the folder walke also carries `worktree`, the same `{ name, path }`, whenever the session has a `liveWorktree`, whichever folder was walked and with or without `root=live`. +The walk reads at most 5,000 files and folders and sets `truncated` when it stops early. It is +a per-directory round-robin (breadth-first): each folder found so far gets an equal share of what +is left before any of them gets more, so one huge folder cannot hide its later siblings, and the +`matches` of a `q` search come back shallow-first. A folder made of thousands of small subfolders +can still use most of the limit one level down. In the tree, a directory node the limit stopped +short of also carries `incomplete: true`: its `children` hold what was read before the limit, +which can be nothing. The key is absent on every other node. + ## Session model (`displayModel`) Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 44e43406..ce45c38c 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -734,6 +734,8 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden` ⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested. +⚠️ **Both walks are a per-directory round-robin (breadth-first) over ONE 5,000-node budget.** They used to recurse depth-first through name-sorted folders, so an early folder could spend the whole budget: 4 of 57 top-level folders were shown in one real workspace. Each round divides what is left equally among the directories queued so far. That is fair per directory, not per subtree: a folder of thousands of one-file subfolders still crowds out its neighbours one level down (`a/b/c/d/needle.ts` beside such a `z/` is found depth-first and missed here), and that trade is accepted, since a file browser wants the shallow levels first. Search results therefore arrive shallow-first. ⚠️ A directory gets its node, with `children: []`, when it is counted and is read in a later round, so every cursor still queued after the loop is a folder that is unread or half read: it must carry `incomplete: true` or it reads as an empty folder, the failure that went from 0 to 635 such folders before the flag existed. `renderFileBrowserTree` draws one dim, inert row under it while it is expanded, and the row is not a `.file-tree-item`, so the click wiring leaves it alone. The budget check at the top of each round runs BEFORE the `shift()`, so a directory the round skips stays queued and keeps its flag. The walk's root has no node, so `truncated` is its only signal, and a folder dropped at the depth cap is not flagged, since the row says "file limit" and it looks the same as it always has. + ⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running. Matching semantics: a query containing `/` matches the path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` listing; the returned `path`s stay relative to `workingDir` either way), otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring. diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 7fb1e5a1..05f57566 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -10,6 +10,11 @@ A panel that browses the active session's working directory, or the git worktree moved into ([Git Worktrees](Git-Worktrees#when-an-agent-moves-into-a-worktree)). Its header button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**. +The panel and its search scan up to 5,000 files and folders per load, shallow levels first and +taking turns between folders, so one huge folder cannot hide the ones after it. When a workspace +is bigger than that the status line says `(truncated)`, and an expanded folder the scan did not +finish shows **Not fully scanned (file limit reached)** rather than looking empty. + It renders what it can: | Kind | Behaviour | diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index d1c7e191..aad4cb01 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1136,6 +1136,7 @@ Worktree: '工作树', 'Session folder': '会话文件夹', 'Back to worktree': '返回工作树', + 'Not fully scanned (file limit reached)': '未完全扫描(已达到文件数量上限)', 'Any status': '任意状态', 'Any time': '任意时间', 'Last hour': '最近一小时', diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index f2cf18e4..f8a56444 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3794,6 +3794,12 @@ Object.assign(CodemanApp.prototype, { renderNode(child, depth + 1); } } + + // The scan's file limit stopped short of this folder, so what is listed above is not all of + // it. Not a .file-tree-item: the click wiring below would try to open it as a file. + if (isDir && isExpanded && node.incomplete === true) { + html.push(`
${escapeHtml('Not fully scanned (file limit reached)')}
`); + } }; for (const node of tree) { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 03f59ab5..a21cecf3 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -12338,6 +12338,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat display: none; } +/* Under an expanded folder the scan's file limit stopped short of. Inert: no hover, no click. */ +.file-tree-incomplete { + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + font-style: italic; + color: var(--text-muted); + white-space: nowrap; + cursor: default; +} + .file-tree-icon { width: 1rem; text-align: center; @@ -12394,15 +12404,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat /* File tree indent levels */ .file-tree-item[data-depth="0"] { padding-left: 0.5rem; } -.file-tree-item[data-depth="1"] { padding-left: 1.25rem; } -.file-tree-item[data-depth="2"] { padding-left: 2rem; } -.file-tree-item[data-depth="3"] { padding-left: 2.75rem; } -.file-tree-item[data-depth="4"] { padding-left: 3.5rem; } -.file-tree-item[data-depth="5"] { padding-left: 4.25rem; } -.file-tree-item[data-depth="6"] { padding-left: 5rem; } -.file-tree-item[data-depth="7"] { padding-left: 5.75rem; } -.file-tree-item[data-depth="8"] { padding-left: 6.5rem; } -.file-tree-item[data-depth="9"] { padding-left: 7.25rem; } +.file-tree-item[data-depth="1"], .file-tree-incomplete[data-depth="1"] { padding-left: 1.25rem; } +.file-tree-item[data-depth="2"], .file-tree-incomplete[data-depth="2"] { padding-left: 2rem; } +.file-tree-item[data-depth="3"], .file-tree-incomplete[data-depth="3"] { padding-left: 2.75rem; } +.file-tree-item[data-depth="4"], .file-tree-incomplete[data-depth="4"] { padding-left: 3.5rem; } +.file-tree-item[data-depth="5"], .file-tree-incomplete[data-depth="5"] { padding-left: 4.25rem; } +.file-tree-item[data-depth="6"], .file-tree-incomplete[data-depth="6"] { padding-left: 5rem; } +.file-tree-item[data-depth="7"], .file-tree-incomplete[data-depth="7"] { padding-left: 5.75rem; } +.file-tree-item[data-depth="8"], .file-tree-incomplete[data-depth="8"] { padding-left: 6.5rem; } +.file-tree-item[data-depth="9"], .file-tree-incomplete[data-depth="9"] { padding-left: 7.25rem; } /* ========== File Preview Overlay ========== */ diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index cd5b1b47..11084bd2 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -1665,6 +1665,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even size?: number; extension?: string; children?: FileTreeNode[]; + /** Set only on a folder the file limit stopped the walk short of, so `children` is not all of it. */ + incomplete?: boolean; } let totalFiles = 0; @@ -1691,7 +1693,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even const queue: SearchDirectoryCursor[] = [{ dirPath: walkRoot, depth: 1, index: 0 }]; while (queue.length > 0 && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) { - // Give each discovered directory a slice before returning to a large sibling. + // Per-directory round-robin (breadth-first): each queued directory gets an equal share of what + // is left before any of them gets more, so results arrive shallow-first. Fair per directory, + // not per subtree: a folder of thousands of small subfolders queues thousands of directories + // and can still take most of the budget one level down. const directoriesInRound = queue.length; const nodesPerDirectory = Math.max( 1, @@ -1699,6 +1704,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even ); for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) { + // Spent part way through a round: the rest stay queued unread rather than sorted for nothing. + if (totalFiles + totalDirectories >= maxFiles || matches.length >= maxMatches) break; const cursor = queue.shift(); if (!cursor) break; if (cursor.depth > maxDepth) { @@ -1734,7 +1741,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even if (matcher(entry.name, listedPath)) { matches.push({ name: entry.name, path: relativePath, type: 'directory' }); } - // Queue siblings together so one large subtree cannot starve later branches. + // Queued, not entered: it waits for a later round with every other directory found. queue.push({ dirPath: fullPath, depth: cursor.depth + 1, index: 0 }); } else { totalFiles++; @@ -1756,7 +1763,6 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even } } if (cursor.index < cursor.entries.length) queue.push(cursor); - if (matches.length >= maxMatches) break; } } if (queue.length > 0) truncated = true; @@ -1781,6 +1787,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even interface TreeDirectoryCursor { dirPath: string; depth: number; + /** This directory's node. The root of the walk has none: `tree` is its list of children. */ + node?: FileTreeNode; nodes: FileTreeNode[]; entries?: import('node:fs').Dirent[]; index: number; @@ -1789,6 +1797,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even const tree: FileTreeNode[] = []; const queue: TreeDirectoryCursor[] = [{ dirPath: walkRoot, depth: 1, nodes: tree, index: 0 }]; while (queue.length > 0 && totalFiles + totalDirectories < maxFiles) { + // The same per-directory round-robin as the search walk above, with the same limits. const directoriesInRound = queue.length; const nodesPerDirectory = Math.max( 1, @@ -1796,6 +1805,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even ); for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) { + // Before the shift, so a cursor this skips stays queued and is flagged below. + if (totalFiles + totalDirectories >= maxFiles) break; const cursor = queue.shift(); if (!cursor) break; if (cursor.depth > maxDepth) { @@ -1833,7 +1844,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even children: [], }; cursor.nodes.push(node); - queue.push({ dirPath: fullPath, depth: cursor.depth + 1, nodes: node.children!, index: 0 }); + queue.push({ dirPath: fullPath, depth: cursor.depth + 1, node, nodes: node.children!, index: 0 }); } else { totalFiles++; const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined; @@ -1850,6 +1861,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even if (cursor.index < cursor.entries.length) queue.push(cursor); } } + // Every directory still queued is unread or half read, and an empty `children` would read as an + // empty folder. The root has no node to flag: `truncated` says the same for it. + for (const cursor of queue) { + if (cursor.node) cursor.node.incomplete = true; + } if (queue.length > 0) truncated = true; return { diff --git a/test/file-browser-incomplete.test.ts b/test/file-browser-incomplete.test.ts new file mode 100644 index 00000000..c31d6fe3 --- /dev/null +++ b/test/file-browser-incomplete.test.ts @@ -0,0 +1,167 @@ +/** + * @fileoverview The Files panel's row for a folder the scan's file limit stopped short of. + * + * `GET .../files` gives a directory its node before reading it, so a folder still queued when the + * budget ends has `children: []` or a partial list (test/routes/file-search-mode.test.ts). Without + * `incomplete` that reads as an empty folder. What can go wrong here is the client: the flag lost + * on the way in, the row drawn for a collapsed or finished folder, or the click wiring treating it + * as a file. + * + * panels-ui.js loads via `vm` as in file-browser-worktree.test.ts, over a real DOM, since the row + * has to survive `innerHTML` and the `.file-tree-item` selector. Translation goes through the real + * i18n.js. Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'); + +const dom = new JSDOM('
'); +afterAll(() => dom.window.close()); +const document = dom.window.document; +const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement; + +function loadPanel(expanded: string[] = []) { + const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record; + const context = vm.createContext({ + CodemanApp, + console, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + escapeHtml: (s: string) => String(s), + document: { getElementById: () => null, addEventListener: vi.fn() }, + window: { addEventListener: vi.fn() }, + setTimeout, + clearTimeout, + CodemanBase: { url: (p: string) => p }, + }); + vm.runInContext(PANELS, context, { filename: 'panels-ui.js' }); + + const app = new CodemanApp(); + app.$ = (id: string) => document.getElementById(id); + app.activeSessionId = 'sess-1'; + app.fileBrowserExpandedDirs = new Set(expanded); + app.getFileIcon = () => ''; + app.formatFileSize = () => ''; + app.openFilePreview = vi.fn(); + return app; +} + +const bulk = { + name: 'a-bulk', + path: 'a-bulk', + type: 'directory', + incomplete: true, + children: [{ name: 'one.txt', path: 'a-bulk/one.txt', type: 'file', size: 1, extension: 'txt' }], +}; +const later = { + name: 'z-later', + path: 'z-later', + type: 'directory', + children: [{ name: 'needle.ts', path: 'z-later/needle.ts', type: 'file', size: 1, extension: 'ts' }], +}; +const envelope = (tree: unknown[]) => ({ + success: true, + data: { root: '/repo', tree, totalFiles: 2, totalDirectories: 2, truncated: true }, +}); + +const render = (app: Record, tree: unknown[]) => { + app.fileBrowserData = app._validateFileBrowserTreeEnvelope(envelope(tree)); + app.renderFileBrowserTree('sess-1'); + return [...treeEl().querySelectorAll('.file-tree-incomplete')] as HTMLElement[]; +}; + +describe('Files panel, a folder the file limit stopped short of', () => { + it('keeps the flag through the envelope check, which ignores what it does not know', () => { + const app = loadPanel(); + const data = app._validateFileBrowserTreeEnvelope(envelope([bulk, later])); + expect(data.tree[0].incomplete).toBe(true); + // An old tab on a new server, and a new tab on an old server: neither is refused. + expect(app._validateFileBrowserTreeEnvelope(envelope([later]))).not.toBeNull(); + }); + + it('draws one row under an expanded incomplete folder, after what was read', () => { + const rows = render(loadPanel(['a-bulk']), [bulk, later]); + + expect(rows).toHaveLength(1); + expect(rows[0].textContent).toBe('Not fully scanned (file limit reached)'); + expect(rows[0].dataset.depth).toBe('1'); + const items = [...treeEl().children].map((el) => (el as HTMLElement).dataset.path ?? 'ROW'); + expect(items).toEqual(['a-bulk', 'a-bulk/one.txt', 'ROW', 'z-later']); + }); + + it('draws it for a folder nothing was read from, which would otherwise look empty', () => { + const unread = { name: 'b-unread', path: 'b-unread', type: 'directory', incomplete: true, children: [] }; + const rows = render(loadPanel(['b-unread']), [unread]); + + expect(rows).toHaveLength(1); + expect(treeEl().querySelectorAll('.file-tree-item')).toHaveLength(1); + }); + + it('indents the row one level under a nested folder', () => { + const nested = { name: 'outer', path: 'outer', type: 'directory', children: [bulk] }; + const rows = render(loadPanel(['outer', 'a-bulk']), [nested]); + + expect(rows[0].dataset.depth).toBe('2'); + }); + + it('draws nothing while the folder is collapsed', () => { + expect(render(loadPanel(), [bulk, later])).toHaveLength(0); + }); + + it('draws nothing under an expanded folder that was finished', () => { + expect(render(loadPanel(['z-later']), [bulk, later])).toHaveLength(0); + }); + + it('accepts only a real true, so a stray truthy value draws nothing', () => { + const odd = { ...bulk, incomplete: 'false' }; + expect(render(loadPanel(['a-bulk']), [odd])).toHaveLength(0); + }); + + it('is inert: a click neither opens nor toggles anything', () => { + const app = loadPanel(['a-bulk']); + const toggle = vi.spyOn(app, 'toggleFileBrowserFolder'); + const [row] = render(app, [bulk, later]); + + row.click(); + + expect(row.matches('.file-tree-item')).toBe(false); + expect(row.dataset.path).toBeUndefined(); + expect(app.openFilePreview).not.toHaveBeenCalled(); + expect(toggle).not.toHaveBeenCalled(); + // The file beside it still opens, so the click wiring did run. + (treeEl().querySelector('[data-path="a-bulk/one.txt"]') as HTMLElement).click(); + expect(app.openFilePreview).toHaveBeenCalledWith('a-bulk/one.txt', 'sess-1'); + }); + + it('is indented by the same steps as the rows around it', () => { + for (let depth = 1; depth <= 9; depth++) { + expect(STYLES).toContain( + `.file-tree-item[data-depth="${depth}"], .file-tree-incomplete[data-depth="${depth}"] {` + ); + } + }); + + it('reads in Chinese, through the string the row really shows', () => { + const [row] = render(loadPanel(['a-bulk']), [bulk]); + const i18n = new JSDOM('', { runScripts: 'outside-only', url: 'http://localhost/' }); + vm.runInContext(I18N, i18n.getInternalVMContext(), { filename: 'i18n.js' }); + const api = ( + i18n.window as unknown as { + CodemanI18n: { t(s: string): string; configure(o: Record): void; start(): void }; + } + ).CodemanI18n; + api.start(); + api.configure({ language: 'zh-CN' }); + + const translated = api.t(row.textContent as string); + expect(translated).not.toBe(row.textContent); + expect(translated).not.toMatch(/[A-Za-z]/); + i18n.window.close(); + }); +}); diff --git a/test/routes/file-routes-live-worktree.test.ts b/test/routes/file-routes-live-worktree.test.ts index a5b8a86e..392c8530 100644 --- a/test/routes/file-routes-live-worktree.test.ts +++ b/test/routes/file-routes-live-worktree.test.ts @@ -163,6 +163,35 @@ describe('Files panel listing of a live worktree', () => { expect(data.worktree).toEqual({ name: 'probe', path: worktree }); }); + it('walks a large worktree in fair rounds, and the paths stay relative to the session folder', async () => { + mkdirSync(join(worktree, 'a-bulk')); + mkdirSync(join(worktree, 'z-later')); + for (let index = 0; index < 5100; index++) { + writeFileSync(join(worktree, 'a-bulk', `item-${index}.txt`), ''); + } + writeFileSync(join(worktree, 'z-later', 'needle.ts'), 'x'); + // The main checkout must play no part: this is the folder a walk seeded wrongly would list. + writeFileSync(join(repo, 'src', 'needle.ts'), 'main'); + + const data = await list('&root=live'); + expect(data.root).toBe(worktree); + expect(data.truncated).toBe(true); + expect(names(data.tree)).toEqual(['a-bulk', 'src', 'z-later']); + const [bulk, , later] = data.tree; + expect(bulk).toMatchObject({ path: `${PREFIX}/a-bulk`, incomplete: true }); + expect(bulk.children[0].path.startsWith(`${PREFIX}/a-bulk/`)).toBe(true); + expect(later).not.toHaveProperty('incomplete'); + expect(later.children[0].path).toBe(`${PREFIX}/z-later/needle.ts`); + + // A `/` in the query reads the listing's paths, and only the answer is in workingDir's terms. + const found = await list(`&root=live&q=${encodeURIComponent('z-later/*.ts')}`); + expect(found.root).toBe(worktree); + expect(found.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/z-later/needle.ts`]); + expect(found.truncated).toBe(true); + const anywhere = await list('&root=live&q=needle'); + expect(anywhere.matches.map((match: { path: string }) => match.path)).toEqual([`${PREFIX}/z-later/needle.ts`]); + }); + it('serves a listed path through the unchanged preview route', async () => { const path = `${PREFIX}/src/worktree-only.ts`; const res = await harness.app.inject({ diff --git a/test/routes/file-search-mode.test.ts b/test/routes/file-search-mode.test.ts index d9833c92..2efb116c 100644 --- a/test/routes/file-search-mode.test.ts +++ b/test/routes/file-search-mode.test.ts @@ -94,27 +94,138 @@ describe('files endpoint search mode', () => { it('shares the scan budget across sibling folders in both tree and search modes', async () => { const bulk = join(root, 'a-bulk'); + const middle = join(root, 'm-middle'); const later = join(root, 'z-later'); mkdirSync(bulk); + mkdirSync(middle); mkdirSync(later); for (let index = 0; index < 5100; index++) { writeFileSync(join(bulk, `item-${index}.txt`), ''); } + writeFileSync(join(middle, 'middle.ts'), 'x\n'); writeFileSync(join(later, 'needle.ts'), 'x\n'); try { const tree = await get(''); const treePaths = tree.data.tree.map((entry: { path: string }) => entry.path); expect(treePaths).toContain('a-bulk'); + expect(treePaths).toContain('m-middle'); + expect(treePaths).toContain('src'); expect(treePaths).toContain('z-later'); expect(tree.data.truncated).toBe(true); + expect(tree.data.totalFiles + tree.data.totalDirectories).toBeLessThanOrEqual(5000); + + const node = (path: string) => tree.data.tree.find((entry: { path: string }) => entry.path === path); + expect(node('a-bulk').incomplete).toBe(true); + expect(node('a-bulk').children.length).toBeGreaterThan(0); + // Finished folders do not carry the key at all, not even as false. + expect(node('z-later')).not.toHaveProperty('incomplete'); + expect(node('z-later').children.map((entry: { path: string }) => entry.path)).toEqual([ + join('z-later', 'needle.ts'), + ]); + expect(node('m-middle')).not.toHaveProperty('incomplete'); const search = await get('?q=needle'); expect(search.data.matches.map((match: { path: string }) => match.path)).toContain(join('z-later', 'needle.ts')); expect(search.data.truncated).toBe(true); } finally { rmSync(bulk, { recursive: true, force: true }); + rmSync(middle, { recursive: true, force: true }); rmSync(later, { recursive: true, force: true }); } }); + + it('adds nothing to a listing that fits the budget', async () => { + const { data } = await get(''); + + expect(data.truncated).toBe(false); + expect(JSON.stringify(data)).not.toContain('incomplete'); + const dir = data.tree.find((entry: { name: string }) => entry.name === 'src'); + expect(Object.keys(dir)).toEqual(['name', 'path', 'type', 'children']); + const file = data.tree.find((entry: { name: string }) => entry.name === 'README.md'); + expect(Object.keys(file)).toEqual(['name', 'path', 'type', 'size', 'extension']); + }); + + it('flags folders the budget ran out on part way through a round, read or not', async () => { + // 3,000 folders use 3,000 of the 5,000 in the first round. The 2,000 left then buy one file + // each, so the round ends with 1,000 folders never read: the last of those is the one a budget + // check after the shift would drop from the queue, and with it the flag. + const wide = mkdtempSync(join(tmpdir(), 'codeman-file-wide-')); + for (let index = 0; index < 3000; index++) { + const dir = join(wide, `dir-${String(index).padStart(4, '0')}`); + mkdirSync(dir); + writeFileSync(join(dir, 'one.txt'), ''); + writeFileSync(join(dir, 'two.txt'), ''); + } + harness.ctx._session.workingDir = wide; + + try { + const { data } = await get(''); + expect(data.truncated).toBe(true); + expect(data.tree).toHaveLength(3000); + expect(data.tree.filter((node: { incomplete?: boolean }) => node.incomplete === true)).toHaveLength(3000); + const sizes = data.tree.map((node: { children: unknown[] }) => node.children.length); + expect(sizes.filter((size: number) => size === 1)).toHaveLength(2000); + expect(sizes.filter((size: number) => size === 0)).toHaveLength(1000); + } finally { + rmSync(wide, { recursive: true, force: true }); + } + }); + + it('copes with the root itself being unfinished', async () => { + // The root has no node to flag. Its folders are listed before its files, and none is read. + const wide = mkdtempSync(join(tmpdir(), 'codeman-file-root-')); + mkdirSync(join(wide, 'first')); + writeFileSync(join(wide, 'first', 'inside.txt'), ''); + for (let index = 0; index < 5100; index++) { + writeFileSync(join(wide, `item-${index}.txt`), ''); + } + harness.ctx._session.workingDir = wide; + + try { + const { data } = await get(''); + expect(data.truncated).toBe(true); + expect(data.tree).toHaveLength(5000); + expect(data.tree[0]).toMatchObject({ name: 'first', incomplete: true, children: [] }); + expect(data.tree.filter((node: { incomplete?: boolean }) => node.incomplete)).toHaveLength(1); + } finally { + rmSync(wide, { recursive: true, force: true }); + } + }); + + it('returns search results shallow-first', async () => { + const deep = join(root, 'a-deep', 'b', 'c'); + mkdirSync(deep, { recursive: true }); + mkdirSync(join(root, 'z-shallow')); + writeFileSync(join(deep, 'order-probe.ts'), 'x\n'); + writeFileSync(join(root, 'z-shallow', 'order-probe.ts'), 'x\n'); + + try { + const { data } = await get('?q=order-probe'); + expect(data.matches.map((match: { path: string }) => match.path)).toEqual([ + join('z-shallow', 'order-probe.ts'), + join('a-deep', 'b', 'c', 'order-probe.ts'), + ]); + } finally { + rmSync(join(root, 'a-deep'), { recursive: true, force: true }); + rmSync(join(root, 'z-shallow'), { recursive: true, force: true }); + } + }); + + it('stops at the match limit and says so', async () => { + const many = mkdtempSync(join(tmpdir(), 'codeman-file-many-')); + for (let index = 0; index < 1100; index++) { + writeFileSync(join(many, `hit-${index}.txt`), ''); + } + harness.ctx._session.workingDir = many; + + try { + const { data } = await get('?q=hit-'); + expect(data.matches).toHaveLength(1000); + expect(data.matchCount).toBe(1000); + expect(data.truncated).toBe(true); + } finally { + rmSync(many, { recursive: true, force: true }); + } + }); });