diff --git a/.changeset/quiet-folders-share.md b/.changeset/quiet-folders-share.md index 677c2c83..59ae2dd7 100644 --- a/.changeset/quiet-folders-share.md +++ b/.changeset/quiet-folders-share.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -Files panel: one large folder no longer uses up the 5,000-entry listing limit before the folders after it, or the ones inside it, get a turn. 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. One listing also stops opening folders after reading 250,000 entries, so folders that add up to that can leave later ones unread, and a folder below the workspace that does not answer is skipped rather than waited on. A folder a 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. +Files panel: one large folder no longer uses up the 5,000-entry listing limit before the folders after it, or the ones inside it, get a turn. 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. One listing also stops opening folders after reading 250,000 entries, so folders that add up to that can leave later ones unread, and a folder below the workspace that does not answer is skipped rather than waited on. A folder the scan stopped short of for any of these reasons no longer looks empty: `GET /api/sessions/:id/files` marks it `incomplete: true` (additive), sets `truncated`, and, when expanded, it shows a dim "Not fully scanned" row. diff --git a/docs/api-reference.md b/docs/api-reference.md index f484247c..d8de8665 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1024,11 +1024,11 @@ as one more share beside the subfolders it has just found, so one huge folder ca listing limit before its later siblings or its own subfolders are read, 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, and folders that add up to the read limit can leave later ones -unread. In the tree, a directory node either limit stopped short of also carries -`incomplete: true`: its `children` hold what was listed before the limit, which can be nothing. -The key is absent on every other node. A folder below the start of the walk that does not answer a -bounded probe (a mount that stopped responding) is skipped like an unreadable one: it is listed -with empty `children` and no `incomplete`. +unread. A folder below the start of the walk that does not answer a bounded probe (a mount that +stopped responding) is not read either, and counts as left over the same way. In the tree, a +directory node the walk stopped short of for either reason also carries `incomplete: true`: its +`children` hold what was listed before it stopped, which can be nothing. The key is absent on every +other node. ## Session model (`displayModel`) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 4673753b..2c29b215 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -762,9 +762,9 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden` ⚠️ **The budget caps what a listing returns, never what it reads.** Taking turns means opening every folder found so far, where the depth-first walk stopped reading soon after its budget ran out, and a share of 16 entries costs the same read as a share of 5,000. So `readWalkFolder()` keeps only what the listing can use (the entries it lists, sorted, no more than the budget has left, with `more` recording that it dropped some: such a cursor stays queued and is flagged), and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders. The folder that crosses that line is read whole, as any folder always was, the ones not opened yet wait in `unread` and are flagged, and what was already read keeps its turns. Without the two, 100 folders of 50,000 files cost one GET 5 million entries and a 400 MB heap died (which ends the server, not the request). The price is that folders adding up to the ceiling leave later ones unread: flagged, not hidden, but not the fairness the budget gets, and a depth-first walk over the same tree showed less. -⚠️ **A folder below the walk's root is probed before it is read** (rule #516), because the round-robin reaches folders the depth-first walk stopped short of, each with an unbounded `readdir`. It is a bulk scan, so default options and the stall cap apply. Anything but `directory` is skipped like an unreadable folder, with no flag, since the row says "file limit". With the cap engaged (two unrelated paths stalled, by default) that is every folder below the root, until one answers. The root is not probed: a refused probe there would blank a healthy workspace. +⚠️ **A folder below the walk's root is probed before it is read** (rule #516), because the round-robin reaches folders the depth-first walk stopped short of, each with an unbounded `readdir`. It is a bulk scan, so default options and the stall cap apply. `unknown` is never `absent`, so a folder the probe could not answer for goes on `unread` (`cursor.unanswered`), which flags it `incomplete` and sets `truncated`: a healthy mount that merely took longer than `PATH_PROBE_TIMEOUT_MS` on first touch would otherwise list as empty, and a search would miss it in silence. It stays unread until a later listing asks again. Only `absent` or `file`, a folder that left while the walk ran, drops out with no flag. With the cap engaged (two unrelated paths stalled, by default) every folder below the root is flagged, until one answers, which is the honest outcome: none of them was read. The root is not probed: a refused probe there would blank a healthy workspace. A `readdir` that fails after a good probe (permission denied) is still treated as an empty folder, as it always was. -⚠️ 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, and every one in `unread`, 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 inside the round runs BEFORE each `shift()`, so a directory the round skips stays queued and keeps its flag. Hidden and excluded entries are dropped at the read, so they never leave a folder looking unfinished, but a folder found as the budget ended is flagged unread even if it would have turned out to be empty: only reading it could tell. 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. +⚠️ 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, and every one in `unread`, 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, worded to fit either reason, and the row is not a `.file-tree-item`, so the click wiring leaves it alone. The budget check inside the round runs BEFORE each `shift()`, so a directory the round skips stays queued and keeps its flag. Hidden and excluded entries are dropped at the read, so they never leave a folder looking unfinished, but a folder found as the budget ended is flagged unread even if it would have turned out to be empty: only reading it could tell. The walk's root has no node, so `truncated` is its only signal, and a folder dropped at the depth cap is not flagged: 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. diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 4b99f892..3d9d02d8 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -15,9 +15,9 @@ taking turns between folders, so one huge folder cannot use up that limit before or the folders inside it, get a turn. They also stop opening folders after reading 250,000 entries, so a workspace with several enormous folders can still leave later ones unread. When a workspace is bigger than either limit 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. A folder that -does not answer, such as a network mount that went away, is skipped rather than waited on, and -looks empty. +not finish shows **Not fully scanned** rather than looking empty. A folder that does not answer, +such as a network mount that went away, is skipped rather than waited on and is marked the same way, +so a search can miss what is inside it. The panel's Refresh button tries it again. It renders what it can: diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 34c7a1fb..a3730c91 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1144,7 +1144,7 @@ Worktree: '工作树', 'Session folder': '会话文件夹', 'Back to worktree': '返回工作树', - 'Not fully scanned (file limit reached)': '未完全扫描(已达到文件数量上限)', + 'Not fully scanned': '未完全扫描', 'Any status': '任意状态', 'Any time': '任意时间', 'Last hour': '最近一小时', diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 482d4fbf..a1522d45 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3795,10 +3795,10 @@ Object.assign(CodemanApp.prototype, { } } - // 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. + // The scan stopped at a limit or the folder did not answer, 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(`