diff --git a/classifier/css/layout.css b/classifier/css/layout.css index f66fae7..c9f574e 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -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; } diff --git a/classifier/js/scanner.js b/classifier/js/scanner.js index 018816d..468ca59 100644 --- a/classifier/js/scanner.js +++ b/classifier/js/scanner.js @@ -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 = 'Couldn’t 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; } diff --git a/classifier/js/tree.js b/classifier/js/tree.js index d22d5df..683632b 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -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 "" and, when there's a subtree (or scanning is ongoing), + // "+" 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