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

View file

@ -59,10 +59,10 @@
handle: handle,
parent: parent || null,
files: [],
fileCount: 0,
subdirCount: 0,
totalFiles: 0,
totalDirs: 0,
fileCount: 0, // direct files in this folder
subdirCount: 0, // direct subfolders
runFiles: 0, // files in the whole subtree (grows as scanned; final on 'done')
runDirs: 0, // subfolders in the whole subtree
children: [],
expanded: false,
scanState: 'pending',
@ -78,11 +78,10 @@
// children are done). This is what turns a folder from grey to solid.
function markDone(node) {
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';
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;
if (p && p.scanState !== 'done') {
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
*/
@ -237,7 +249,7 @@
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
const zipNode = makeNode(zh, zipPath, node);
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);
if (scanStats) scanStats.folders++;
}
@ -248,12 +260,23 @@
}
}
} catch (err) {
console.error('Error scanning folder:', node.path, err);
node.scanError = true;
reportScanError(node.path, err);
}
node.files = files;
node.fileCount = files.length;
node.children = childDirs;
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.
node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length;
if (node.pending === 0) {
@ -316,10 +339,10 @@
function finalizeZipNode(node) {
node.fileCount = node.files.length;
node.subdirCount = node.children.length;
let tf = node.fileCount, td = node.children.length;
for (const c of node.children) { finalizeZipNode(c); tf += c.totalFiles; td += c.totalDirs; }
node.totalFiles = tf;
node.totalDirs = td;
let rf = node.files.length, rd = node.children.length;
for (const c of node.children) { finalizeZipNode(c); rf += c.runFiles; rd += c.runDirs; }
node.runFiles = rf;
node.runDirs = rd;
node.scanState = 'done';
node.pending = 0;
}

View file

@ -26,20 +26,45 @@
}
/**
* Count label for a folder row. While the folder is still being scanned
* its counts are unknown; once its own directory has been read we show the
* immediate subfolder/file counts (greyed until the whole subtree is done).
* Populate a folder row's count element with "direct+total" counts, e.g.
* "(2+10 folders, 15+300 files)" direct (immediate children) shows as
* 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;
if (st === 'pending') return '';
if (st === 'scanning') return 'scanning…';
const d = folder.subdirCount || 0;
const f = folder.fileCount || 0;
const parts = [];
if (d) parts.push(d + (d === 1 ? ' folder' : ' folders'));
parts.push(f + (f === 1 ? ' file' : ' files'));
return '(' + parts.join(', ') + ')';
if (st === 'pending') return;
if (st === 'scanning') { el.textContent = 'scanning…'; return; }
const done = st === 'done';
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
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.
const count = document.createElement('span');
count.className = 'folder-count';
count.textContent = folderCountLabel(folder);
populateCount(count, folder);
item.appendChild(count);
// Extract button for ZIP roots