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 });
+ }
+ });
});