feat(classifier): direct+total counts in tree; toast scan errors

- Counts now read "direct+total" — e.g. "(2+10 folders, 15+300 files)". The
  direct number (immediate children) shows as soon as a folder's own directory
  is read; the total (whole-subtree) is accumulated progressively and flashes
  grey until the subtree is fully scanned, then goes solid. The "+total" is
  omitted once done and there's nothing deeper.
- Scan errors (permission denied, network hiccups on a share) now surface as a
  toast (de-duped per path) instead of only console noise; a failed folder/zip
  is marked done-empty so it doesn't wedge the walk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-09 10:28:01 -05:00
parent ecb0a270cc
commit 3d02084397
3 changed files with 82 additions and 34 deletions

View file

@ -175,14 +175,14 @@
background-color: var(--bg-hover); background-color: var(--bg-hover);
} }
/* A folder whose subtree isn't fully scanned yet: greyed name + counts, /* Counts read "direct+total". The direct number stays solid (immediate info);
turning solid once scanState hits 'done'. A faint pulse signals active the "+total" subtree count is muted and pulses while its subtree is still
scanning. */ being scanned, then goes solid once final. */
.folder-item.scanning .folder-name, .folder-count .ct-total {
.folder-item.scanning .folder-count { color: var(--text-secondary, #6b7280);
color: var(--text-muted, #8a8a8a);
} }
.folder-item.scanning .folder-count { .folder-count .ct-total.pending {
color: var(--text-muted, #9aa0a6);
font-style: italic; font-style: italic;
animation: scan-pulse 1.2s ease-in-out infinite; animation: scan-pulse 1.2s ease-in-out infinite;
} }

View file

@ -59,10 +59,10 @@
handle: handle, handle: handle,
parent: parent || null, parent: parent || null,
files: [], files: [],
fileCount: 0, fileCount: 0, // direct files in this folder
subdirCount: 0, subdirCount: 0, // direct subfolders
totalFiles: 0, runFiles: 0, // files in the whole subtree (grows as scanned; final on 'done')
totalDirs: 0, runDirs: 0, // subfolders in the whole subtree
children: [], children: [],
expanded: false, expanded: false,
scanState: 'pending', scanState: 'pending',
@ -78,11 +78,10 @@
// children are done). This is what turns a folder from grey to solid. // children are done). This is what turns a folder from grey to solid.
function markDone(node) { function markDone(node) {
if (node.scanState === 'done') return; if (node.scanState === 'done') return;
// runFiles/runDirs were accumulated into this node (and its ancestors)
// as each descendant was scanned, so by the time the subtree is
// complete they already hold the final totals — nothing to compute.
node.scanState = 'done'; node.scanState = 'done';
let tf = node.fileCount, td = node.children.length;
for (const c of node.children) { tf += c.totalFiles; td += c.totalDirs; }
node.totalFiles = tf;
node.totalDirs = td;
const p = node.parent; const p = node.parent;
if (p && p.scanState !== 'done') { if (p && p.scanState !== 'done') {
p.pending -= 1; p.pending -= 1;
@ -92,6 +91,19 @@
} }
} }
// One-shot toast for scan errors (permission denied, network hiccups on a
// share). De-duped per path so a flaky folder doesn't spam.
const scanErrorsSeen = new Set();
function reportScanError(path, err) {
console.error('Scan error:', path, err);
if (scanErrorsSeen.has(path)) return;
scanErrorsSeen.add(path);
const msg = 'Couldnt scan ' + path + ': ' + (err && err.message ? err.message : err);
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, 'error');
}
}
/** /**
* Scan directory and build folder tree with files * Scan directory and build folder tree with files
*/ */
@ -237,7 +249,7 @@
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath }; const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
const zipNode = makeNode(zh, zipPath, node); const zipNode = makeNode(zh, zipPath, node);
try { await scanZipIntoNode(zipNode, fo); } try { await scanZipIntoNode(zipNode, fo); }
catch (e) { console.error('Error scanning ZIP:', zipPath, e); } catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; }
childDirs.push(zipNode); childDirs.push(zipNode);
if (scanStats) scanStats.folders++; if (scanStats) scanStats.folders++;
} }
@ -248,12 +260,23 @@
} }
} }
} catch (err) { } catch (err) {
console.error('Error scanning folder:', node.path, err); node.scanError = true;
reportScanError(node.path, err);
} }
node.files = files; node.files = files;
node.fileCount = files.length; node.fileCount = files.length;
node.children = childDirs; node.children = childDirs;
node.subdirCount = childDirs.length; node.subdirCount = childDirs.length;
// Roll this folder's own files/dirs (plus the full contents of any
// inline-zip children) into the running subtree totals of this node
// and every ancestor. Regular child dirs add their own share when they
// get scanned — that's how the total fills in progressively.
let addF = files.length;
let addD = childDirs.length;
for (const c of childDirs) {
if (c.scanState === 'done') { addF += c.runFiles; addD += c.runDirs; }
}
for (let a = node; a; a = a.parent) { a.runFiles += addF; a.runDirs += addD; }
// Zip children are scanned inline ('done'); real dirs are still pending. // Zip children are scanned inline ('done'); real dirs are still pending.
node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length; node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length;
if (node.pending === 0) { if (node.pending === 0) {
@ -316,10 +339,10 @@
function finalizeZipNode(node) { function finalizeZipNode(node) {
node.fileCount = node.files.length; node.fileCount = node.files.length;
node.subdirCount = node.children.length; node.subdirCount = node.children.length;
let tf = node.fileCount, td = node.children.length; let rf = node.files.length, rd = node.children.length;
for (const c of node.children) { finalizeZipNode(c); tf += c.totalFiles; td += c.totalDirs; } for (const c of node.children) { finalizeZipNode(c); rf += c.runFiles; rd += c.runDirs; }
node.totalFiles = tf; node.runFiles = rf;
node.totalDirs = td; node.runDirs = rd;
node.scanState = 'done'; node.scanState = 'done';
node.pending = 0; node.pending = 0;
} }

View file

@ -26,20 +26,45 @@
} }
/** /**
* Count label for a folder row. While the folder is still being scanned * Populate a folder row's count element with "direct+total" counts, e.g.
* its counts are unknown; once its own directory has been read we show the * "(2+10 folders, 15+300 files)" direct (immediate children) shows as
* immediate subfolder/file counts (greyed until the whole subtree is done). * soon as the folder's own directory is read; the total (whole subtree)
* grows and flashes grey until the subtree is fully scanned, then goes
* solid. The "+total" part is omitted once scanning is done and there's
* nothing deeper (direct == total).
*/ */
function folderCountLabel(folder) { function populateCount(el, folder) {
el.textContent = '';
const st = folder.scanState; const st = folder.scanState;
if (st === 'pending') return ''; if (st === 'pending') return;
if (st === 'scanning') return 'scanning…'; if (st === 'scanning') { el.textContent = 'scanning…'; return; }
const d = folder.subdirCount || 0;
const f = folder.fileCount || 0; const done = st === 'done';
const parts = []; const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
if (d) parts.push(d + (d === 1 ? ' folder' : ' folders')); const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
parts.push(f + (f === 1 ? ' file' : ' files'));
return '(' + parts.join(', ') + ')'; const frag = document.createDocumentFragment();
frag.appendChild(document.createTextNode('('));
if (dDir > 0 || tDir > 0) {
appendPair(frag, dDir, tDir, done);
frag.appendChild(document.createTextNode(tDir === 1 ? ' folder, ' : ' folders, '));
}
appendPair(frag, dFile, tFile, done);
frag.appendChild(document.createTextNode(tFile === 1 ? ' file)' : ' files)'));
el.appendChild(frag);
}
// Append "<direct>" and, when there's a subtree (or scanning is ongoing),
// "+<total>" with the total in a span that greys + pulses until final.
function appendPair(frag, direct, total, done) {
frag.appendChild(document.createTextNode(String(direct)));
if (!done || total > direct) {
frag.appendChild(document.createTextNode('+'));
const t = document.createElement('span');
t.className = 'ct-total' + (done ? '' : ' pending');
t.textContent = String(total);
frag.appendChild(t);
}
} }
/** /**
@ -103,7 +128,7 @@
// class until the subtree is fully scanned. // class until the subtree is fully scanned.
const count = document.createElement('span'); const count = document.createElement('span');
count.className = 'folder-count'; count.className = 'folder-count';
count.textContent = folderCountLabel(folder); populateCount(count, folder);
item.appendChild(count); item.appendChild(count);
// Extract button for ZIP roots // Extract button for ZIP roots