fix: flag a Files panel folder that did not answer the probe (release review)

A subfolder whose bounded probe answered `unknown` came back as `children: []`
with no `incomplete` and `truncated` false, so a dead mount, or a healthy one
slower than the 1.5 s probe on first touch, read as an empty folder and a
search missed it in silence. `unknown` is never `absent` (#516).

Such a folder now goes on the walk's `unread` list, which flags the node and
sets `truncated` in both walks. A folder the filesystem says is gone drops out
quietly as before. The row said "file limit reached", which would be false
here, so it now reads "Not fully scanned" for either reason.

Reproduced on a real disk with a parent at mode 0444, where the child's stat
answers EACCES: the API, the search and the panel all report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 08:41:15 +01:00
1 parent f2f4952c34
commit 37dfb73b1e
9 files changed
+55 -26

No files matched your search

+5 -4
View File
@@ -1,8 +1,9 @@
/**
* @fileoverview The Files panel's row for a folder the scan's file limit stopped short of.
* @fileoverview The Files panel's row for a folder the scan 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
* budget ends, or one that did not answer, has `children: []` or a partial list
* (test/routes/file-search-mode.test.ts, test/routes/file-walk-bounds.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.
@@ -76,7 +77,7 @@ const render = (app: Record<string, any>, tree: unknown[]) => {
return [...treeEl().querySelectorAll('.file-tree-incomplete')] as HTMLElement[];
};
describe('Files panel, a folder the file limit stopped short of', () => {
describe('Files panel, a folder the scan 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]));
@@ -89,7 +90,7 @@ describe('Files panel, a folder the file limit stopped short of', () => {
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].textContent).toBe('Not fully scanned');
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']);
+20 -2
View File
@@ -143,9 +143,11 @@ describe('files endpoint read bounds', () => {
it('answers without it, and costs it one stuck stat however often the panel asks', async () => {
const first = await deadline(get(''));
// Nothing was read from it, so `children: []` alone would pass it off as an empty folder (#516:
// unknown is never absent).
const dead = first.tree.find((node: { name: string }) => node.name === 'z-nas');
expect(dead).toEqual({ name: 'z-nas', path: 'z-nas', type: 'directory', children: [] });
expect(first.truncated).toBe(false);
expect(dead).toEqual({ name: 'z-nas', path: 'z-nas', type: 'directory', children: [], incomplete: true });
expect(first.truncated).toBe(true);
expect(first.tree[0].children.map((node: { name: string }) => node.name)).toEqual(['needle.ts']);
expect(deadReads).toBe(0);
@@ -153,8 +155,24 @@ describe('files endpoint read bounds', () => {
const stats = statCalls();
const search = await deadline(get('?q=needle'));
expect(search.matches.map((match: { path: string }) => match.path)).toEqual([join('a-fine', 'needle.ts')]);
// A hit could be behind it, which is what the search's own `truncated` is for.
expect(search.truncated).toBe(true);
expect(statCalls()).toBe(stats);
expect(deadReads).toBe(0);
});
it('leaves a folder alone once the filesystem says it is gone: that is not an unfinished scan', async () => {
vi.mocked(fs.stat).mockImplementation((async (path: string) => {
if (path === deadFolder) throw Object.assign(new Error('gone'), { code: 'ENOENT' });
return { size: 1, isDirectory: () => true };
}) as never);
const data = await deadline(get(''));
const gone = data.tree.find((node: { name: string }) => node.name === 'z-nas');
expect(gone).toEqual({ name: 'z-nas', path: 'z-nas', type: 'directory', children: [] });
expect(data.truncated).toBe(false);
expect(deadReads).toBe(0);
});
});
});