Compare commits
5 commits
362f5bd036
...
7d93171900
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d93171900 | |||
| 237c353845 | |||
| 3d02084397 | |||
| ecb0a270cc | |||
| e0ae0772da |
15 changed files with 997 additions and 153 deletions
|
|
@ -175,6 +175,36 @@
|
|||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 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-count .ct-total.pending {
|
||||
color: var(--text-muted, #9aa0a6);
|
||||
font-style: italic;
|
||||
animation: scan-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes scan-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Live scan status line under the tree-pane header. */
|
||||
.scan-status {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a8a8a);
|
||||
border-bottom: 1px solid var(--border, #e2e2e2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 1.1em;
|
||||
}
|
||||
.scan-status:empty { display: none; }
|
||||
.scan-status.scanning { color: var(--primary, #2868c8); }
|
||||
|
||||
.folder-item.selected {
|
||||
background-color: var(--bg-selected);
|
||||
font-weight: 500;
|
||||
|
|
|
|||
|
|
@ -8,52 +8,191 @@
|
|||
// Store ZIP data for later access
|
||||
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle }
|
||||
|
||||
// ── Incremental-scan state ───────────────────────────────────────────────
|
||||
// The scan no longer reads the whole tree before rendering. It walks
|
||||
// breadth-first behind a small worker pool, renders progressively (top
|
||||
// levels appear first), and shows live status — so a huge network drive
|
||||
// never looks stalled. Each folder tracks its own scan state + counts.
|
||||
let scanGen = 0; // bumped per scan; stale workers bail
|
||||
let scanStats = null; // { folders, files, current, done, startedAt }
|
||||
let renderTimer = null; // throttle for progressive re-render
|
||||
|
||||
function scheduleRender() {
|
||||
if (renderTimer) return;
|
||||
renderTimer = setTimeout(function () {
|
||||
renderTimer = null;
|
||||
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
|
||||
updateScanStatus();
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function flushRender() {
|
||||
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
|
||||
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
|
||||
updateScanStatus();
|
||||
}
|
||||
|
||||
// Render the running scan status into the tree-pane header.
|
||||
function updateScanStatus() {
|
||||
const el = document.getElementById('scanStatus');
|
||||
if (!el || !scanStats) return;
|
||||
if (scanStats.done) {
|
||||
const secs = ((Date.now() - scanStats.startedAt) / 1000).toFixed(1);
|
||||
el.textContent = 'Scanned ' + scanStats.folders + ' folders · '
|
||||
+ scanStats.files + ' files in ' + secs + 's';
|
||||
el.classList.remove('scanning');
|
||||
} else {
|
||||
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
|
||||
+ scanStats.files + ' files'
|
||||
+ (scanStats.current ? ' — ' + scanStats.current : '');
|
||||
el.classList.add('scanning');
|
||||
}
|
||||
}
|
||||
|
||||
// Make a tree node. scanState: 'pending' (children not read) →
|
||||
// 'scanning' → 'children' (immediate children read, subtree still going) →
|
||||
// 'done' (entire subtree enumerated). The UI greys a node until 'done'.
|
||||
function makeNode(handle, path, parent) {
|
||||
const node = {
|
||||
name: handle.name,
|
||||
path: path,
|
||||
handle: handle,
|
||||
parent: parent || null,
|
||||
files: [],
|
||||
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',
|
||||
pending: 0, // child dirs not yet 'done'
|
||||
};
|
||||
if (handle.isZipRoot) { node.isZipRoot = true; node.zipPath = handle.zipPath; }
|
||||
if (handle.isVirtualDir) { node.isVirtualDir = true; node.zipPath = handle.zipPath; }
|
||||
return node;
|
||||
}
|
||||
|
||||
// Mark a node's subtree fully scanned: roll up recursive totals and
|
||||
// propagate completion to the parent (which flips to 'done' once all its
|
||||
// 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';
|
||||
const p = node.parent;
|
||||
if (p && p.scanState !== 'done') {
|
||||
p.pending -= 1;
|
||||
if (p.pending <= 0 && (p.scanState === 'children' || p.scanState === 'scanning')) {
|
||||
markDone(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
async function scanDirectory(dirHandle, preserveState = false) {
|
||||
|
||||
|
||||
// Save current state if preserving
|
||||
// Preserve which folders were expanded across a rescan (e.g. after a
|
||||
// ZIP extract) so the user doesn't lose their place.
|
||||
let savedExpanded = new Set();
|
||||
let savedSelected = new Set();
|
||||
if (preserveState) {
|
||||
savedExpanded = getExpandedPaths(window.app.folderTree);
|
||||
savedSelected = new Set(window.app.selectedFolders);
|
||||
}
|
||||
|
||||
// Clear ZIP cache
|
||||
zipCache.clear();
|
||||
|
||||
// Map to store files by folder handle (or ZIP path for virtual folders)
|
||||
const foldersMap = new Map();
|
||||
|
||||
// Recursively scan
|
||||
await scanFolder(dirHandle, foldersMap, dirHandle.name);
|
||||
|
||||
// Build tree structure
|
||||
window.app.folderTree = window.app.modules.tree.buildTree(dirHandle, foldersMap);
|
||||
|
||||
// Set in store
|
||||
window.app.modules.store.setFolderTree(window.app.folderTree);
|
||||
|
||||
if (preserveState) {
|
||||
// Restore expanded state
|
||||
restoreExpandedPaths(window.app.folderTree, savedExpanded);
|
||||
// Restore selection
|
||||
window.app.selectedFolders = savedSelected;
|
||||
// Render without changing selection
|
||||
window.app.modules.tree.render();
|
||||
window.app.modules.store.setSelectedFolders(savedSelected);
|
||||
} else {
|
||||
// Render tree
|
||||
window.app.modules.tree.render();
|
||||
// Auto-expand and select all folders
|
||||
window.app.modules.tree.expandAll();
|
||||
window.app.modules.tree.selectAll();
|
||||
}
|
||||
|
||||
|
||||
const myGen = ++scanGen;
|
||||
zipCache.clear();
|
||||
scanStats = { folders: 0, files: 0, current: dirHandle.name, done: false, startedAt: Date.now() };
|
||||
|
||||
// Root node — render immediately so the pane never sits blank.
|
||||
const root = makeNode(dirHandle, dirHandle.name, null);
|
||||
root.expanded = true;
|
||||
window.app.folderTree = [root];
|
||||
window.app.modules.store.setFolderTree(window.app.folderTree);
|
||||
if (!preserveState) {
|
||||
// Select the root so the grid shows its immediate files at once,
|
||||
// instead of auto-loading the ENTIRE drive (the old behaviour,
|
||||
// which is exactly what stalled on a large share).
|
||||
window.app.selectedFolders = new Set([root.path]);
|
||||
window.app.lastSelectedFolderPath = root.path;
|
||||
window.app.modules.store.setSelectedFolders(window.app.selectedFolders);
|
||||
} else {
|
||||
window.app.selectedFolders = savedSelected;
|
||||
window.app.modules.store.setSelectedFolders(savedSelected);
|
||||
}
|
||||
flushRender();
|
||||
|
||||
// Breadth-first by level behind a bounded worker pool: level 1, then
|
||||
// level 2, … each rendered as it lands (top levels appear first).
|
||||
// Deeper levels keep filling in; workers await between directories so
|
||||
// the UI stays responsive on a slow/large network drive.
|
||||
let level = [root];
|
||||
while (level.length && myGen === scanGen) {
|
||||
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
|
||||
const next = [];
|
||||
for (const n of level) {
|
||||
for (const c of n.children) {
|
||||
if (preserveState && savedExpanded.has(c.path)) c.expanded = true;
|
||||
if (c.scanState === 'pending') next.push(c);
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
if (myGen !== scanGen) return; // superseded by a newer scan
|
||||
|
||||
scanStats.done = true;
|
||||
scanStats.current = '';
|
||||
flushRender();
|
||||
}
|
||||
|
||||
// Run fn over items with at most `limit` concurrent calls; resolves when
|
||||
// all have settled. Termination is clean (no transient-empty-queue race).
|
||||
async function runWithConcurrency(items, limit, fn) {
|
||||
let i = 0;
|
||||
async function runner() {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
await fn(items[idx]);
|
||||
}
|
||||
}
|
||||
const runners = [];
|
||||
for (let k = 0; k < Math.min(limit, items.length); k++) runners.push(runner());
|
||||
await Promise.all(runners);
|
||||
}
|
||||
|
||||
// Force a folder's subtree to scan NOW (jumped ahead of the background
|
||||
// walk). Called when the user opens a folder, so an opened folder always
|
||||
// shows complete contents. Idempotent + shares the live scan generation.
|
||||
async function ensureScanned(node) {
|
||||
if (!node || !node.handle || node.scanState === 'done') return;
|
||||
const myGen = scanGen;
|
||||
let level = [node];
|
||||
while (level.length && myGen === scanGen) {
|
||||
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
|
||||
const next = [];
|
||||
for (const n of level) {
|
||||
for (const c of n.children) if (c.scanState === 'pending') next.push(c);
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
flushRender();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -83,43 +222,129 @@
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a folder
|
||||
*/
|
||||
async function scanFolder(dirHandle, foldersMap, currentPath) {
|
||||
const items = [];
|
||||
|
||||
// Read ONE directory's immediate entries: files into node.files, child
|
||||
// directories into node.children (left 'pending' for the BFS to descend).
|
||||
// A .zip becomes an expandable zip-root child, scanned inline (its
|
||||
// contents are already in memory once the entry is read). Idempotent:
|
||||
// only a 'pending' node is scanned, so concurrent callers (background +
|
||||
// open-prioritised) don't double-scan.
|
||||
async function scanNodeChildren(node, myGen) {
|
||||
if (node.scanState !== 'pending') return;
|
||||
node.scanState = 'scanning';
|
||||
if (scanStats) scanStats.current = node.path;
|
||||
const files = [];
|
||||
const childDirs = [];
|
||||
try {
|
||||
for await (const entry of dirHandle.values()) {
|
||||
for await (const entry of node.handle.values()) {
|
||||
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
|
||||
if (entry.kind === 'file') {
|
||||
// Create file object
|
||||
const file = await createFileObject(entry, dirHandle);
|
||||
if (file) {
|
||||
items.push(file);
|
||||
|
||||
// Check if it's a ZIP file - scan its contents
|
||||
if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
|
||||
await scanZipFile(file, foldersMap, currentPath, items);
|
||||
}
|
||||
const fo = await createFileObject(entry, node.handle);
|
||||
if (!fo) continue;
|
||||
fo.folderPath = node.path;
|
||||
files.push(fo);
|
||||
if (scanStats) scanStats.files++;
|
||||
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') {
|
||||
const zipName = zddc.joinExtension(fo.originalFilename, fo.extension);
|
||||
const zipPath = node.path + '/' + zipName;
|
||||
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
|
||||
const zipNode = makeNode(zh, zipPath, node);
|
||||
try { await scanZipIntoNode(zipNode, fo); }
|
||||
catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; }
|
||||
childDirs.push(zipNode);
|
||||
if (scanStats) scanStats.folders++;
|
||||
}
|
||||
} else if (entry.kind === 'directory') {
|
||||
// Add directory reference
|
||||
items.push({
|
||||
handle: entry,
|
||||
isDirectory: true
|
||||
});
|
||||
|
||||
// Recursively scan subdirectory
|
||||
const childPath = currentPath + '/' + entry.name;
|
||||
await scanFolder(entry, foldersMap, childPath);
|
||||
const childPath = node.path + '/' + entry.name;
|
||||
childDirs.push(makeNode(entry, childPath, node));
|
||||
if (scanStats) scanStats.folders++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error scanning folder:', dirHandle.name, err);
|
||||
node.scanError = true;
|
||||
reportScanError(node.path, err);
|
||||
}
|
||||
|
||||
// Store files for this folder
|
||||
foldersMap.set(dirHandle, items);
|
||||
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) {
|
||||
markDone(node);
|
||||
} else {
|
||||
node.scanState = 'children';
|
||||
}
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
// Build a zip-root node's children from its archive contents (in memory),
|
||||
// marking the whole zip subtree 'done' immediately. Mirrors the on-disk
|
||||
// node shape so the rest of the app treats zip folders like real ones.
|
||||
async function scanZipIntoNode(zipNode, fileObj) {
|
||||
const f = await fileObj.handle.getFile();
|
||||
const zip = await JSZip.loadAsync(await f.arrayBuffer());
|
||||
const zipPath = zipNode.path;
|
||||
zipCache.set(zipPath, { zip: zip, fileHandle: fileObj.handle, folderHandle: fileObj.folderHandle });
|
||||
const dirNodes = new Map();
|
||||
dirNodes.set(zipPath, zipNode);
|
||||
function ensureDir(dirPath) {
|
||||
if (dirNodes.has(dirPath)) return dirNodes.get(dirPath);
|
||||
const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/'));
|
||||
const parent = ensureDir(parentPath);
|
||||
const name = dirPath.substring(dirPath.lastIndexOf('/') + 1);
|
||||
const vh = { name: name, kind: 'directory', isVirtualDir: true, zipPath: zipPath, virtualPath: dirPath };
|
||||
const child = makeNode(vh, dirPath, parent);
|
||||
parent.children.push(child);
|
||||
dirNodes.set(dirPath, child);
|
||||
return child;
|
||||
}
|
||||
zip.forEach(function (relativePath, entry) {
|
||||
if (entry.dir) {
|
||||
ensureDir(zipPath + '/' + relativePath.replace(/\/$/, ''));
|
||||
} else {
|
||||
const fileName = relativePath.split('/').pop();
|
||||
const fileDir = relativePath.includes('/')
|
||||
? zipPath + '/' + relativePath.substring(0, relativePath.lastIndexOf('/'))
|
||||
: zipPath;
|
||||
const dirNode = ensureDir(fileDir);
|
||||
const split = zddc.splitExtension(fileName);
|
||||
dirNode.files.push({
|
||||
originalFilename: split.name,
|
||||
extension: split.extension,
|
||||
size: entry._data ? entry._data.uncompressedSize : 0,
|
||||
lastModified: entry.date ? entry.date.getTime() : Date.now(),
|
||||
isVirtual: true,
|
||||
zipPath: zipPath,
|
||||
zipEntryPath: relativePath,
|
||||
folderPath: dirNode.path,
|
||||
trackingNumber: '', revision: '', status: '', title: '',
|
||||
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null
|
||||
});
|
||||
}
|
||||
});
|
||||
finalizeZipNode(zipNode);
|
||||
}
|
||||
|
||||
// Roll up a zip node's counts + mark its whole subtree 'done'.
|
||||
function finalizeZipNode(node) {
|
||||
node.fileCount = node.files.length;
|
||||
node.subdirCount = node.children.length;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -429,6 +654,7 @@
|
|||
// Export module
|
||||
window.app.modules.scanner = {
|
||||
scanDirectory,
|
||||
ensureScanned,
|
||||
getZipCache,
|
||||
extractZip
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,48 @@
|
|||
updateSelectedCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 populateCount(el, folder) {
|
||||
el.textContent = '';
|
||||
const st = folder.scanState;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a folder element
|
||||
*/
|
||||
|
|
@ -33,6 +75,11 @@
|
|||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'folder-item';
|
||||
// Grey the row until its subtree is fully scanned (scanState 'done');
|
||||
// 'scanning' rows also get a subtle pulse via CSS.
|
||||
if (folder.scanState && folder.scanState !== 'done') {
|
||||
item.classList.add('scanning');
|
||||
}
|
||||
item.dataset.path = folder.path;
|
||||
item.style.paddingLeft = `${level * 1.5}rem`;
|
||||
|
||||
|
|
@ -41,10 +88,13 @@
|
|||
item.classList.add('selected');
|
||||
}
|
||||
|
||||
// Toggle button (if has children)
|
||||
// Toggle button: shown when the folder has children OR hasn't been
|
||||
// scanned yet (it might have children — expanding triggers its scan).
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'folder-toggle';
|
||||
if (folder.children && folder.children.length > 0) {
|
||||
const mightHaveChildren = (folder.children && folder.children.length > 0)
|
||||
|| folder.scanState === 'pending';
|
||||
if (mightHaveChildren) {
|
||||
toggle.textContent = folder.expanded ? '▼' : '▶';
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -74,10 +124,11 @@
|
|||
name.textContent = folder.name;
|
||||
item.appendChild(name);
|
||||
|
||||
// File count
|
||||
// Subfolder / file counts (immediate). Greyed via the row's .scanning
|
||||
// class until the subtree is fully scanned.
|
||||
const count = document.createElement('span');
|
||||
count.className = 'folder-count';
|
||||
count.textContent = `(${folder.fileCount || 0})`;
|
||||
populateCount(count, folder);
|
||||
item.appendChild(count);
|
||||
|
||||
// Extract button for ZIP roots
|
||||
|
|
@ -287,7 +338,7 @@
|
|||
*/
|
||||
function toggleFolder(folder, recursive = false) {
|
||||
folder.expanded = !folder.expanded;
|
||||
|
||||
|
||||
if (recursive && folder.children) {
|
||||
// Recursively expand/collapse all children
|
||||
const newState = folder.expanded;
|
||||
|
|
@ -299,8 +350,16 @@
|
|||
}
|
||||
folder.children.forEach(setAllExpanded);
|
||||
}
|
||||
|
||||
|
||||
render();
|
||||
|
||||
// Opening a not-yet-complete folder jumps its subtree to the front of
|
||||
// the scan so its contents are complete on open (re-renders as it
|
||||
// fills in). Background scanning continues for everything else.
|
||||
if (folder.expanded && folder.scanState !== 'done'
|
||||
&& window.app.modules.scanner && window.app.modules.scanner.ensureScanned) {
|
||||
window.app.modules.scanner.ensureScanned(folder).then(render).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="scanStatus" class="scan-status" aria-live="polite"></div>
|
||||
<div id="folderTree" class="folder-tree">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -807,6 +807,19 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// /_apps/ — virtual, public directory of the standalone tool HTMLs, so
|
||||
// people can download one and run it against their own local filesystem.
|
||||
// Tool UI only, no data, no auth. Before the reserved-prefix ('_'/'.')
|
||||
// guard so it isn't 404'd.
|
||||
if urlPath == "/_apps" {
|
||||
http.Redirect(w, r, handler.AppsVirtualPrefix, http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(urlPath, handler.AppsVirtualPrefix) {
|
||||
handler.ServeApps(appsSrv, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Auth check endpoints — machine-only forward_auth targets used by
|
||||
// upstream proxies (e.g. the dev-shell pod's Caddy in front of
|
||||
// code-server) to gate routes on root-admin status. Handled before
|
||||
|
|
|
|||
|
|
@ -2665,7 +2665,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
|
|
|
|||
|
|
@ -2772,7 +2772,7 @@ li.CodeMirror-hint-active {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Browse</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:14 · 237c353</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||
|
|
|
|||
|
|
@ -1293,6 +1293,36 @@ body.is-elevated::after {
|
|||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 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-count .ct-total.pending {
|
||||
color: var(--text-muted, #9aa0a6);
|
||||
font-style: italic;
|
||||
animation: scan-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes scan-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Live scan status line under the tree-pane header. */
|
||||
.scan-status {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a8a8a);
|
||||
border-bottom: 1px solid var(--border, #e2e2e2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 1.1em;
|
||||
}
|
||||
.scan-status:empty { display: none; }
|
||||
.scan-status.scanning { color: var(--primary, #2868c8); }
|
||||
|
||||
.folder-item.selected {
|
||||
background-color: var(--bg-selected);
|
||||
font-weight: 500;
|
||||
|
|
@ -1876,7 +1906,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
|
|
@ -1908,6 +1938,7 @@ body.is-elevated::after {
|
|||
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="scanStatus" class="scan-status" aria-live="polite"></div>
|
||||
<div id="folderTree" class="folder-tree">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
|
|
@ -6383,52 +6414,191 @@ X.B(E,Y);return E}return J}())
|
|||
// Store ZIP data for later access
|
||||
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle }
|
||||
|
||||
// ── Incremental-scan state ───────────────────────────────────────────────
|
||||
// The scan no longer reads the whole tree before rendering. It walks
|
||||
// breadth-first behind a small worker pool, renders progressively (top
|
||||
// levels appear first), and shows live status — so a huge network drive
|
||||
// never looks stalled. Each folder tracks its own scan state + counts.
|
||||
let scanGen = 0; // bumped per scan; stale workers bail
|
||||
let scanStats = null; // { folders, files, current, done, startedAt }
|
||||
let renderTimer = null; // throttle for progressive re-render
|
||||
|
||||
function scheduleRender() {
|
||||
if (renderTimer) return;
|
||||
renderTimer = setTimeout(function () {
|
||||
renderTimer = null;
|
||||
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
|
||||
updateScanStatus();
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function flushRender() {
|
||||
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
|
||||
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
|
||||
updateScanStatus();
|
||||
}
|
||||
|
||||
// Render the running scan status into the tree-pane header.
|
||||
function updateScanStatus() {
|
||||
const el = document.getElementById('scanStatus');
|
||||
if (!el || !scanStats) return;
|
||||
if (scanStats.done) {
|
||||
const secs = ((Date.now() - scanStats.startedAt) / 1000).toFixed(1);
|
||||
el.textContent = 'Scanned ' + scanStats.folders + ' folders · '
|
||||
+ scanStats.files + ' files in ' + secs + 's';
|
||||
el.classList.remove('scanning');
|
||||
} else {
|
||||
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
|
||||
+ scanStats.files + ' files'
|
||||
+ (scanStats.current ? ' — ' + scanStats.current : '');
|
||||
el.classList.add('scanning');
|
||||
}
|
||||
}
|
||||
|
||||
// Make a tree node. scanState: 'pending' (children not read) →
|
||||
// 'scanning' → 'children' (immediate children read, subtree still going) →
|
||||
// 'done' (entire subtree enumerated). The UI greys a node until 'done'.
|
||||
function makeNode(handle, path, parent) {
|
||||
const node = {
|
||||
name: handle.name,
|
||||
path: path,
|
||||
handle: handle,
|
||||
parent: parent || null,
|
||||
files: [],
|
||||
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',
|
||||
pending: 0, // child dirs not yet 'done'
|
||||
};
|
||||
if (handle.isZipRoot) { node.isZipRoot = true; node.zipPath = handle.zipPath; }
|
||||
if (handle.isVirtualDir) { node.isVirtualDir = true; node.zipPath = handle.zipPath; }
|
||||
return node;
|
||||
}
|
||||
|
||||
// Mark a node's subtree fully scanned: roll up recursive totals and
|
||||
// propagate completion to the parent (which flips to 'done' once all its
|
||||
// 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';
|
||||
const p = node.parent;
|
||||
if (p && p.scanState !== 'done') {
|
||||
p.pending -= 1;
|
||||
if (p.pending <= 0 && (p.scanState === 'children' || p.scanState === 'scanning')) {
|
||||
markDone(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
async function scanDirectory(dirHandle, preserveState = false) {
|
||||
|
||||
|
||||
// Save current state if preserving
|
||||
// Preserve which folders were expanded across a rescan (e.g. after a
|
||||
// ZIP extract) so the user doesn't lose their place.
|
||||
let savedExpanded = new Set();
|
||||
let savedSelected = new Set();
|
||||
if (preserveState) {
|
||||
savedExpanded = getExpandedPaths(window.app.folderTree);
|
||||
savedSelected = new Set(window.app.selectedFolders);
|
||||
}
|
||||
|
||||
// Clear ZIP cache
|
||||
zipCache.clear();
|
||||
|
||||
// Map to store files by folder handle (or ZIP path for virtual folders)
|
||||
const foldersMap = new Map();
|
||||
|
||||
// Recursively scan
|
||||
await scanFolder(dirHandle, foldersMap, dirHandle.name);
|
||||
|
||||
// Build tree structure
|
||||
window.app.folderTree = window.app.modules.tree.buildTree(dirHandle, foldersMap);
|
||||
|
||||
// Set in store
|
||||
window.app.modules.store.setFolderTree(window.app.folderTree);
|
||||
|
||||
if (preserveState) {
|
||||
// Restore expanded state
|
||||
restoreExpandedPaths(window.app.folderTree, savedExpanded);
|
||||
// Restore selection
|
||||
window.app.selectedFolders = savedSelected;
|
||||
// Render without changing selection
|
||||
window.app.modules.tree.render();
|
||||
window.app.modules.store.setSelectedFolders(savedSelected);
|
||||
} else {
|
||||
// Render tree
|
||||
window.app.modules.tree.render();
|
||||
// Auto-expand and select all folders
|
||||
window.app.modules.tree.expandAll();
|
||||
window.app.modules.tree.selectAll();
|
||||
}
|
||||
|
||||
|
||||
const myGen = ++scanGen;
|
||||
zipCache.clear();
|
||||
scanStats = { folders: 0, files: 0, current: dirHandle.name, done: false, startedAt: Date.now() };
|
||||
|
||||
// Root node — render immediately so the pane never sits blank.
|
||||
const root = makeNode(dirHandle, dirHandle.name, null);
|
||||
root.expanded = true;
|
||||
window.app.folderTree = [root];
|
||||
window.app.modules.store.setFolderTree(window.app.folderTree);
|
||||
if (!preserveState) {
|
||||
// Select the root so the grid shows its immediate files at once,
|
||||
// instead of auto-loading the ENTIRE drive (the old behaviour,
|
||||
// which is exactly what stalled on a large share).
|
||||
window.app.selectedFolders = new Set([root.path]);
|
||||
window.app.lastSelectedFolderPath = root.path;
|
||||
window.app.modules.store.setSelectedFolders(window.app.selectedFolders);
|
||||
} else {
|
||||
window.app.selectedFolders = savedSelected;
|
||||
window.app.modules.store.setSelectedFolders(savedSelected);
|
||||
}
|
||||
flushRender();
|
||||
|
||||
// Breadth-first by level behind a bounded worker pool: level 1, then
|
||||
// level 2, … each rendered as it lands (top levels appear first).
|
||||
// Deeper levels keep filling in; workers await between directories so
|
||||
// the UI stays responsive on a slow/large network drive.
|
||||
let level = [root];
|
||||
while (level.length && myGen === scanGen) {
|
||||
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
|
||||
const next = [];
|
||||
for (const n of level) {
|
||||
for (const c of n.children) {
|
||||
if (preserveState && savedExpanded.has(c.path)) c.expanded = true;
|
||||
if (c.scanState === 'pending') next.push(c);
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
if (myGen !== scanGen) return; // superseded by a newer scan
|
||||
|
||||
scanStats.done = true;
|
||||
scanStats.current = '';
|
||||
flushRender();
|
||||
}
|
||||
|
||||
// Run fn over items with at most `limit` concurrent calls; resolves when
|
||||
// all have settled. Termination is clean (no transient-empty-queue race).
|
||||
async function runWithConcurrency(items, limit, fn) {
|
||||
let i = 0;
|
||||
async function runner() {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
await fn(items[idx]);
|
||||
}
|
||||
}
|
||||
const runners = [];
|
||||
for (let k = 0; k < Math.min(limit, items.length); k++) runners.push(runner());
|
||||
await Promise.all(runners);
|
||||
}
|
||||
|
||||
// Force a folder's subtree to scan NOW (jumped ahead of the background
|
||||
// walk). Called when the user opens a folder, so an opened folder always
|
||||
// shows complete contents. Idempotent + shares the live scan generation.
|
||||
async function ensureScanned(node) {
|
||||
if (!node || !node.handle || node.scanState === 'done') return;
|
||||
const myGen = scanGen;
|
||||
let level = [node];
|
||||
while (level.length && myGen === scanGen) {
|
||||
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
|
||||
const next = [];
|
||||
for (const n of level) {
|
||||
for (const c of n.children) if (c.scanState === 'pending') next.push(c);
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
flushRender();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -6458,43 +6628,129 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan a folder
|
||||
*/
|
||||
async function scanFolder(dirHandle, foldersMap, currentPath) {
|
||||
const items = [];
|
||||
|
||||
// Read ONE directory's immediate entries: files into node.files, child
|
||||
// directories into node.children (left 'pending' for the BFS to descend).
|
||||
// A .zip becomes an expandable zip-root child, scanned inline (its
|
||||
// contents are already in memory once the entry is read). Idempotent:
|
||||
// only a 'pending' node is scanned, so concurrent callers (background +
|
||||
// open-prioritised) don't double-scan.
|
||||
async function scanNodeChildren(node, myGen) {
|
||||
if (node.scanState !== 'pending') return;
|
||||
node.scanState = 'scanning';
|
||||
if (scanStats) scanStats.current = node.path;
|
||||
const files = [];
|
||||
const childDirs = [];
|
||||
try {
|
||||
for await (const entry of dirHandle.values()) {
|
||||
for await (const entry of node.handle.values()) {
|
||||
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
|
||||
if (entry.kind === 'file') {
|
||||
// Create file object
|
||||
const file = await createFileObject(entry, dirHandle);
|
||||
if (file) {
|
||||
items.push(file);
|
||||
|
||||
// Check if it's a ZIP file - scan its contents
|
||||
if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
|
||||
await scanZipFile(file, foldersMap, currentPath, items);
|
||||
}
|
||||
const fo = await createFileObject(entry, node.handle);
|
||||
if (!fo) continue;
|
||||
fo.folderPath = node.path;
|
||||
files.push(fo);
|
||||
if (scanStats) scanStats.files++;
|
||||
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') {
|
||||
const zipName = zddc.joinExtension(fo.originalFilename, fo.extension);
|
||||
const zipPath = node.path + '/' + zipName;
|
||||
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
|
||||
const zipNode = makeNode(zh, zipPath, node);
|
||||
try { await scanZipIntoNode(zipNode, fo); }
|
||||
catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; }
|
||||
childDirs.push(zipNode);
|
||||
if (scanStats) scanStats.folders++;
|
||||
}
|
||||
} else if (entry.kind === 'directory') {
|
||||
// Add directory reference
|
||||
items.push({
|
||||
handle: entry,
|
||||
isDirectory: true
|
||||
});
|
||||
|
||||
// Recursively scan subdirectory
|
||||
const childPath = currentPath + '/' + entry.name;
|
||||
await scanFolder(entry, foldersMap, childPath);
|
||||
const childPath = node.path + '/' + entry.name;
|
||||
childDirs.push(makeNode(entry, childPath, node));
|
||||
if (scanStats) scanStats.folders++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error scanning folder:', dirHandle.name, err);
|
||||
node.scanError = true;
|
||||
reportScanError(node.path, err);
|
||||
}
|
||||
|
||||
// Store files for this folder
|
||||
foldersMap.set(dirHandle, items);
|
||||
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) {
|
||||
markDone(node);
|
||||
} else {
|
||||
node.scanState = 'children';
|
||||
}
|
||||
scheduleRender();
|
||||
}
|
||||
|
||||
// Build a zip-root node's children from its archive contents (in memory),
|
||||
// marking the whole zip subtree 'done' immediately. Mirrors the on-disk
|
||||
// node shape so the rest of the app treats zip folders like real ones.
|
||||
async function scanZipIntoNode(zipNode, fileObj) {
|
||||
const f = await fileObj.handle.getFile();
|
||||
const zip = await JSZip.loadAsync(await f.arrayBuffer());
|
||||
const zipPath = zipNode.path;
|
||||
zipCache.set(zipPath, { zip: zip, fileHandle: fileObj.handle, folderHandle: fileObj.folderHandle });
|
||||
const dirNodes = new Map();
|
||||
dirNodes.set(zipPath, zipNode);
|
||||
function ensureDir(dirPath) {
|
||||
if (dirNodes.has(dirPath)) return dirNodes.get(dirPath);
|
||||
const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/'));
|
||||
const parent = ensureDir(parentPath);
|
||||
const name = dirPath.substring(dirPath.lastIndexOf('/') + 1);
|
||||
const vh = { name: name, kind: 'directory', isVirtualDir: true, zipPath: zipPath, virtualPath: dirPath };
|
||||
const child = makeNode(vh, dirPath, parent);
|
||||
parent.children.push(child);
|
||||
dirNodes.set(dirPath, child);
|
||||
return child;
|
||||
}
|
||||
zip.forEach(function (relativePath, entry) {
|
||||
if (entry.dir) {
|
||||
ensureDir(zipPath + '/' + relativePath.replace(/\/$/, ''));
|
||||
} else {
|
||||
const fileName = relativePath.split('/').pop();
|
||||
const fileDir = relativePath.includes('/')
|
||||
? zipPath + '/' + relativePath.substring(0, relativePath.lastIndexOf('/'))
|
||||
: zipPath;
|
||||
const dirNode = ensureDir(fileDir);
|
||||
const split = zddc.splitExtension(fileName);
|
||||
dirNode.files.push({
|
||||
originalFilename: split.name,
|
||||
extension: split.extension,
|
||||
size: entry._data ? entry._data.uncompressedSize : 0,
|
||||
lastModified: entry.date ? entry.date.getTime() : Date.now(),
|
||||
isVirtual: true,
|
||||
zipPath: zipPath,
|
||||
zipEntryPath: relativePath,
|
||||
folderPath: dirNode.path,
|
||||
trackingNumber: '', revision: '', status: '', title: '',
|
||||
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null
|
||||
});
|
||||
}
|
||||
});
|
||||
finalizeZipNode(zipNode);
|
||||
}
|
||||
|
||||
// Roll up a zip node's counts + mark its whole subtree 'done'.
|
||||
function finalizeZipNode(node) {
|
||||
node.fileCount = node.files.length;
|
||||
node.subdirCount = node.children.length;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -6804,6 +7060,7 @@ X.B(E,Y);return E}return J}())
|
|||
// Export module
|
||||
window.app.modules.scanner = {
|
||||
scanDirectory,
|
||||
ensureScanned,
|
||||
getZipCache,
|
||||
extractZip
|
||||
};
|
||||
|
|
@ -6837,6 +7094,48 @@ X.B(E,Y);return E}return J}())
|
|||
updateSelectedCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 populateCount(el, folder) {
|
||||
el.textContent = '';
|
||||
const st = folder.scanState;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a folder element
|
||||
*/
|
||||
|
|
@ -6845,6 +7144,11 @@ X.B(E,Y);return E}return J}())
|
|||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'folder-item';
|
||||
// Grey the row until its subtree is fully scanned (scanState 'done');
|
||||
// 'scanning' rows also get a subtle pulse via CSS.
|
||||
if (folder.scanState && folder.scanState !== 'done') {
|
||||
item.classList.add('scanning');
|
||||
}
|
||||
item.dataset.path = folder.path;
|
||||
item.style.paddingLeft = `${level * 1.5}rem`;
|
||||
|
||||
|
|
@ -6853,10 +7157,13 @@ X.B(E,Y);return E}return J}())
|
|||
item.classList.add('selected');
|
||||
}
|
||||
|
||||
// Toggle button (if has children)
|
||||
// Toggle button: shown when the folder has children OR hasn't been
|
||||
// scanned yet (it might have children — expanding triggers its scan).
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'folder-toggle';
|
||||
if (folder.children && folder.children.length > 0) {
|
||||
const mightHaveChildren = (folder.children && folder.children.length > 0)
|
||||
|| folder.scanState === 'pending';
|
||||
if (mightHaveChildren) {
|
||||
toggle.textContent = folder.expanded ? '▼' : '▶';
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -6886,10 +7193,11 @@ X.B(E,Y);return E}return J}())
|
|||
name.textContent = folder.name;
|
||||
item.appendChild(name);
|
||||
|
||||
// File count
|
||||
// Subfolder / file counts (immediate). Greyed via the row's .scanning
|
||||
// class until the subtree is fully scanned.
|
||||
const count = document.createElement('span');
|
||||
count.className = 'folder-count';
|
||||
count.textContent = `(${folder.fileCount || 0})`;
|
||||
populateCount(count, folder);
|
||||
item.appendChild(count);
|
||||
|
||||
// Extract button for ZIP roots
|
||||
|
|
@ -7099,7 +7407,7 @@ X.B(E,Y);return E}return J}())
|
|||
*/
|
||||
function toggleFolder(folder, recursive = false) {
|
||||
folder.expanded = !folder.expanded;
|
||||
|
||||
|
||||
if (recursive && folder.children) {
|
||||
// Recursively expand/collapse all children
|
||||
const newState = folder.expanded;
|
||||
|
|
@ -7111,8 +7419,16 @@ X.B(E,Y);return E}return J}())
|
|||
}
|
||||
folder.children.forEach(setAllExpanded);
|
||||
}
|
||||
|
||||
|
||||
render();
|
||||
|
||||
// Opening a not-yet-complete folder jumps its subtree to the front of
|
||||
// the scan so its contents are complete on open (re-renders as it
|
||||
// fills in). Background scanning continues for everything else.
|
||||
if (folder.expanded && folder.scanState !== 'done'
|
||||
&& window.app.modules.scanner && window.app.modules.scanner.ensureScanned) {
|
||||
window.app.modules.scanner.ensureScanned(folder).then(render).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1619,7 +1619,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
|
|
@ -2718,7 +2718,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
|
||||
transmittal=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
|
||||
classifier=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
||||
landing=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
||||
form=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
||||
tables=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
||||
browse=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
|
||||
archive=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||
transmittal=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||
classifier=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||
landing=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||
form=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||
tables=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
|
||||
browse=v0.0.27-beta · 2026-06-09 15:30:14 · 237c353
|
||||
|
|
|
|||
126
zddc/internal/handler/appsvirtual.go
Normal file
126
zddc/internal/handler/appsvirtual.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||
)
|
||||
|
||||
// AppsVirtualPrefix is a virtual, public directory that serves the standalone
|
||||
// tool HTMLs so anyone can grab one and run it against their OWN local
|
||||
// filesystem — download it, open it from disk, and it runs offline via the
|
||||
// browser's File System Access picker. It carries no project data, so it's
|
||||
// served with no auth/ACL. `_`-prefixed names are system-reserved (mkdir
|
||||
// rejects them), so /_apps/ never collides with content, and it's virtual —
|
||||
// nothing exists on disk.
|
||||
const AppsVirtualPrefix = "/_apps/"
|
||||
|
||||
type standaloneApp struct {
|
||||
File string // URL + filename, e.g. "classifier.html"
|
||||
Name string
|
||||
Desc string
|
||||
}
|
||||
|
||||
// The order here is the order shown on the index; "local-first" tools lead.
|
||||
var standaloneApps = []standaloneApp{
|
||||
{"classifier.html", "Classifier", "Rename a local folder of files to ZDDC naming conventions."},
|
||||
{"browse.html", "Browse", "Browse + edit a local directory tree — markdown, YAML, CSV, and more."},
|
||||
{"transmittal.html", "Transmittal", "Assemble a transmittal package from local files."},
|
||||
{"tables.html", "Tables", "View and edit a directory of YAML rows as a sortable table."},
|
||||
{"form.html", "Form", "Edit a single YAML record with a schema-driven form."},
|
||||
{"archive.html", "Archive", "Review an archive of ZDDC-named files."},
|
||||
}
|
||||
|
||||
// appBytesForFile returns the HTML for a /_apps/<file> request, or nil for an
|
||||
// unknown file. It prefers the site .zddc.zip bundle member (operator
|
||||
// override / the freshest dev build) and falls back to the binary's embedded
|
||||
// copy. tables + form share the one embedded tables/form bundle.
|
||||
func appBytesForFile(appsSrv *apps.Server, file string) []byte {
|
||||
if appsSrv != nil && appsSrv.Bundle != nil {
|
||||
if b, ok := appsSrv.Bundle.Member(file); ok && len(b) > 0 {
|
||||
return b
|
||||
}
|
||||
}
|
||||
switch file {
|
||||
case "classifier.html":
|
||||
return apps.EmbeddedBytes("classifier")
|
||||
case "browse.html":
|
||||
return apps.EmbeddedBytes("browse")
|
||||
case "transmittal.html":
|
||||
return apps.EmbeddedBytes("transmittal")
|
||||
case "archive.html":
|
||||
return apps.EmbeddedBytes("archive")
|
||||
case "tables.html", "form.html":
|
||||
return EmbeddedTablesHTML()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeApps handles GET/HEAD under /_apps/. "/_apps/" → an index of the
|
||||
// standalone tools (Download / Open links); "/_apps/<tool>.html" → that tool's
|
||||
// embedded HTML. Append ?download to force a save dialog. No auth — tool UI
|
||||
// only, no data.
|
||||
func ServeApps(appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
rest := strings.TrimPrefix(r.URL.Path, AppsVirtualPrefix)
|
||||
if rest == "" || rest == "index" {
|
||||
serveAppsIndex(w, r)
|
||||
return
|
||||
}
|
||||
if strings.Contains(rest, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body := appBytesForFile(appsSrv, rest)
|
||||
if body == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Conditional-GET-friendly: revalidate each load; bytes change only on a
|
||||
// binary redeploy. (Index is no-store; it's tiny + generated.)
|
||||
w.Header().Set("Cache-Control", "max-age=0, must-revalidate")
|
||||
if r.URL.Query().Get("download") != "" {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+rest+`"`)
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func serveAppsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">`)
|
||||
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1">`)
|
||||
b.WriteString(`<title>ZDDC standalone apps</title><style>`)
|
||||
b.WriteString(`body{font-family:system-ui,-apple-system,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem;line-height:1.5;color:#222}`)
|
||||
b.WriteString(`h1{font-size:1.4rem;margin:0 0 .3rem}.lead{color:#555;font-size:.92rem;margin:0 0 1.2rem}`)
|
||||
b.WriteString(`.app{border:1px solid #e2e2e2;border-radius:8px;padding:.7rem 1rem;margin:.55rem 0}`)
|
||||
b.WriteString(`.app h2{font-size:1.05rem;margin:0 0 .15rem}.app p{margin:.15rem 0 .55rem;color:#555;font-size:.88rem}`)
|
||||
b.WriteString(`a.btn{display:inline-block;padding:.28rem .7rem;border:1px solid #2868c8;border-radius:5px;color:#2868c8;text-decoration:none;margin-right:.4rem;font-size:.85rem}`)
|
||||
b.WriteString(`a.btn:hover{background:#2868c8;color:#fff}`)
|
||||
b.WriteString(`</style></head><body>`)
|
||||
b.WriteString(`<h1>ZDDC standalone apps</h1>`)
|
||||
b.WriteString(`<p class="lead">Each tool is a single, self-contained HTML file. <strong>Download</strong> one and open it from your disk to run it offline against a folder on your own machine (use a Chromium-family browser — it needs the File System Access API). <strong>Open</strong> runs it here, against the server.</p>`)
|
||||
for _, a := range standaloneApps {
|
||||
b.WriteString(`<div class="app"><h2>` + html.EscapeString(a.Name) + `</h2>`)
|
||||
b.WriteString(`<p>` + html.EscapeString(a.Desc) + `</p>`)
|
||||
b.WriteString(`<a class="btn" href="` + AppsVirtualPrefix + a.File + `?download=1" download="` + a.File + `">Download</a>`)
|
||||
b.WriteString(`<a class="btn" href="` + AppsVirtualPrefix + a.File + `" target="_blank" rel="noopener">Open</a>`)
|
||||
b.WriteString(`</div>`)
|
||||
}
|
||||
b.WriteString(`</body></html>`)
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}
|
||||
42
zddc/internal/handler/appsvirtual_test.go
Normal file
42
zddc/internal/handler/appsvirtual_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServeApps(t *testing.T) {
|
||||
// Index lists the tools.
|
||||
rec := httptest.NewRecorder()
|
||||
ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("index: want 200, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Classifier") {
|
||||
t.Errorf("index should list Classifier")
|
||||
}
|
||||
|
||||
// A known tool resolves to HTML (embedded bytes may be empty in a fresh
|
||||
// checkout, so accept 200 with a body OR 404 only when the slot is empty).
|
||||
rec = httptest.NewRecorder()
|
||||
ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix+"classifier.html", nil))
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusNotFound {
|
||||
t.Errorf("classifier.html: unexpected %d", rec.Code)
|
||||
}
|
||||
|
||||
// Unknown name → 404.
|
||||
rec = httptest.NewRecorder()
|
||||
ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix+"nope.html", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown: want 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Path traversal / subpath → 404.
|
||||
rec = httptest.NewRecorder()
|
||||
ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix+"a/b.html", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("subpath: want 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
|
@ -918,3 +918,34 @@ func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
|
|||
t.Errorf("history should NOT follow a cross-dir move; err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileAPI_PreservesCase — mkdir + PUT must keep the basename's case
|
||||
// verbatim (tracking numbers are uppercase; the server must not normalize).
|
||||
func TestFileAPI_PreservesCase(t *testing.T) {
|
||||
// working/<party>/… is the realistic create surface; the harness
|
||||
// auto-registers Acme so the party gate passes. Subfolder + file names
|
||||
// under it are arbitrary — isolates the basename-case question.
|
||||
_, do, root := fileAPITestSetup(t, []string{"Proj/working/Acme"}, nil)
|
||||
base := filepath.Join(root, "Proj", "working", "Acme")
|
||||
|
||||
rec := do(http.MethodPost, "/Proj/working/Acme/MixedCaseDir", "alice@example.com", nil, map[string]string{"X-ZDDC-Op": "mkdir"})
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("mkdir: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(http.MethodPut, "/Proj/working/Acme/UPPER-Name.MD", "alice@example.com", []byte("# hi\n"), map[string]string{"Content-Type": "text/markdown"})
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("put: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
ents, _ := os.ReadDir(base)
|
||||
var names []string
|
||||
for _, e := range ents {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Logf("on-disk under working/Acme: %v", names)
|
||||
if _, err := os.Stat(filepath.Join(base, "MixedCaseDir")); err != nil {
|
||||
t.Errorf("mkdir case NOT preserved (%v)", names)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "UPPER-Name.MD")); err != nil {
|
||||
t.Errorf("PUT case NOT preserved (%v)", names)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1670,7 +1670,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
Loading…
Reference in a new issue