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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 02:57:28 +01:00
1 parent bb42f8105e
commit a908be05ac
12 files changed
+370 -15

No files matched your search

+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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)
+8
View File
@@ -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
+2
View File
@@ -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.
+5
View File
@@ -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 |
+1
View File
@@ -1136,6 +1136,7 @@
Worktree: '工作树',
'Session folder': '会话文件夹',
'Back to worktree': '返回工作树',
'Not fully scanned (file limit reached)': '未完全扫描(已达到文件数量上限)',
'Any status': '任意状态',
'Any time': '任意时间',
'Last hour': '最近一小时',
+6
View File
@@ -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(`<div class="file-tree-incomplete" data-depth="${depth + 1}">${escapeHtml('Not fully scanned (file limit reached)')}</div>`);
}
};
for (const node of tree) {
+19 -9
View File
@@ -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 ========== */
+20 -4
View File
@@ -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 {
+167
View File
@@ -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('<div id="fileBrowserTree"></div><div id="fileBrowserPanel" class="visible"></div>');
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<string, any>;
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<string, any>, 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('<html></html>', { 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<string, string>): 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();
});
});
@@ -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({
+111
View File
@@ -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 });
}
});
});