feat(classifier): zips are single files by default, toggle to expand as a folder

Previously every .zip was auto-expanded into a folder of members (and was even
double-represented as both a file and a folder node). Now a .zip is one
classifiable file by default; right-click it → "Expand as folder" to pull its
members into the fileset, and right-click an expanded archive (or a member) →
"Collapse to single file" to go back. The toggle sits with Exclude in the
context menu.

- scanner: stop creating zip-root nodes during the scan; expandZipAsFolder /
  collapseZipToFile mutate the tree in place (re-reading members from the live
  handle or, for a restored workspace, lazily from the root) and recompute
  subtree totals. Mode is encoded by the tree shape, so it persists in the
  snapshot as-is.
- classify.dropAssignments clears the assignments that cease to exist when a zip
  flips mode (the single-file key on expand; the member keys on collapse).
- copy already handles both: a zip-as-file copies whole; members extract from
  the archive.

Also: a folder whose entire subtree is excluded now renders its name struck
through, mirroring the excluded-file style.

Tests: collapse restores the single .zip + drops member assignments; a
fully-excluded folder gets the struck-through class (48 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 14:34:25 -05:00
parent 8368cc81b8
commit 1f8b4e4aaa
5 changed files with 176 additions and 21 deletions

View file

@ -502,6 +502,7 @@
.cl-dot--done { background: var(--success); border-color: var(--success); } .cl-dot--done { background: var(--success); border-color: var(--success); }
.cl-dot--excluded { background: var(--text-muted); border-color: var(--text-muted); opacity: 0.6; } .cl-dot--excluded { background: var(--text-muted); border-color: var(--text-muted); opacity: 0.6; }
.file-item.excluded .file-name { text-decoration: line-through; color: var(--text-muted); } .file-item.excluded .file-name { text-decoration: line-through; color: var(--text-muted); }
.folder-item.excluded .folder-name { text-decoration: line-through; color: var(--text-muted); }
/* placed-file row in the target pane is clickable (reveal in source) */ /* placed-file row in the target pane is clickable (reveal in source) */
.tfile { cursor: pointer; } .tfile { cursor: pointer; }

View file

@ -129,6 +129,15 @@
}); });
notify(); notify();
} }
// Forget any assignment for these source keys (e.g. when a .zip flips
// between single-file and folder mode and the old keys cease to exist).
function dropAssignments(keys) {
var changed = false;
(keys || []).forEach(function (k) {
if (state.assignments[k]) { delete state.assignments[k]; changed = true; }
});
if (changed) notify();
}
function setTitleOverride(key, title) { function setTitleOverride(key, title) {
var a = assignmentFor(key); var a = assignmentFor(key);
a.titleOverride = title && title.trim() ? title.trim() : null; a.titleOverride = title && title.trim() ? title.trim() : null;
@ -550,7 +559,7 @@
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle, srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
// assignments // assignments
assignmentFor: assignmentFor, getAssignment: getAssignment, assignmentFor: assignmentFor, getAssignment: getAssignment,
place: place, setExcluded: setExcluded, place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
setTitleOverride: setTitleOverride, setTitleOverride: setTitleOverride,
// trees // trees
addTrackingNode: addTrackingNode, addParty: addParty, addTrackingNode: addTrackingNode, addParty: addParty,

View file

@ -323,18 +323,9 @@
fo.folderPath = node.path; fo.folderPath = node.path;
files.push(fo); files.push(fo);
if (scanStats) scanStats.files++; if (scanStats) scanStats.files++;
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') { // A .zip is a single file by default (one classifiable unit).
// Don't read the archive during the listing — make an // The user can later "Expand as folder" (expandZipAsFolder) to
// expandable, lazy zip node scanned on open (scanZipNode). // pull its members into the fileset.
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);
zipNode._zipFileObj = fo;
zipNode.scanState = 'zip-pending';
childDirs.push(zipNode);
if (scanStats) scanStats.folders++;
}
} else if (entry.kind === 'directory') { } else if (entry.kind === 'directory') {
const childPath = node.path + '/' + entry.name; const childPath = node.path + '/' + entry.name;
childDirs.push(makeNode(entry, childPath, node)); childDirs.push(makeNode(entry, childPath, node));
@ -869,6 +860,76 @@
if (!entry) throw new Error('zip member not found: ' + fileObj.zipEntryPath); if (!entry) throw new Error('zip member not found: ' + fileObj.zipEntryPath);
return await entry.async('blob'); return await entry.async('blob');
} }
// ── per-zip mode toggle (single file ⇄ expandable folder) ───────────────
function findNodeByPath(path) {
var hit = null;
(function walk(ns) { (ns || []).forEach(function (n) { if (hit) return; if (n.path === path) hit = n; else walk(n.children); }); })(window.app.folderTree || []);
return hit;
}
// Recompute subtree totals after a structural change (expand/collapse a zip).
function recomputeTotals() {
(function walk(ns) {
(ns || []).forEach(function (n) {
walk(n.children || []);
var rf = (n.files || []).length, rd = (n.children || []).length;
(n.children || []).forEach(function (c) { rf += c.runFiles || 0; rd += c.runDirs || 0; });
n.runFiles = rf; n.runDirs = rd;
n.fileCount = (n.files || []).length; n.subdirCount = (n.children || []).length;
});
})(window.app.folderTree || []);
}
// Turn a .zip FILE into an expandable archive folder in place: scan its
// members into the fileset and drop the now-meaningless single-file
// assignment. Members come from the live handle, or (snapshot-restored) are
// re-read from the workspace root via scanZipNode's fallback.
async function expandZipAsFolder(file) {
var parent = findNodeByPath(file.folderPath);
if (!parent) return null;
var zipName = zddc.joinExtension(file.originalFilename, file.extension);
var zipPath = parent.path + '/' + zipName;
var zipNode = makeNode({ name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath }, zipPath, parent);
zipNode._zipFileObj = file.handle ? file : null; // null → scanZipNode resolves from root
zipNode.scanState = 'zip-pending';
zipNode.expanded = true;
parent.files = (parent.files || []).filter(function (f) { return f !== file; });
parent.children = parent.children || [];
parent.children.push(zipNode);
var c = window.app.modules.classify;
if (c && c.dropAssignments) c.dropAssignments([c.srcKeyForFile(file)]);
await scanZipNode(zipNode);
recomputeTotals();
return zipNode;
}
// Collapse an expanded archive folder back to a single .zip file, dropping
// every member's assignment.
function collapseZipToFile(zipNode) {
if (!zipNode || !zipNode.isZipRoot) return null;
var parent = zipNode.parent || findNodeByPath(zipNode.path.slice(0, zipNode.path.lastIndexOf('/')));
if (!parent) return null;
var c = window.app.modules.classify;
if (c && c.dropAssignments) {
var keys = [];
(function walk(n) { (n.files || []).forEach(function (f) { keys.push(c.srcKeyForFile(f)); }); (n.children || []).forEach(walk); })(zipNode);
c.dropAssignments(keys);
}
var split = zddc.splitExtension(zipNode.name);
var file = {
handle: (zipNode._zipFileObj && zipNode._zipFileObj.handle) || null,
folderHandle: (zipNode._zipFileObj && zipNode._zipFileObj.folderHandle) || null,
originalFilename: split.name, extension: split.extension,
size: null, lastModified: null,
trackingNumber: '', revision: '', status: '', title: '',
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null,
folderPath: parent.path,
};
parent.children = (parent.children || []).filter(function (n) { return n !== zipNode; });
parent.files = parent.files || [];
parent.files.push(file);
zipCache.delete(zipNode.zipPath);
recomputeTotals();
return file;
}
async function resolveDirHandle(rootHandle, relPath) { async function resolveDirHandle(rootHandle, relPath) {
var cur = rootHandle; var cur = rootHandle;
var parts = (relPath || '').split('/').filter(Boolean); var parts = (relPath || '').split('/').filter(Boolean);
@ -938,6 +999,8 @@
resolveDirHandle, resolveDirHandle,
ensureZipLoaded, ensureZipLoaded,
extractZipMember, extractZipMember,
expandZipAsFolder,
collapseZipToFile,
resumeScan resumeScan
}; };
})(); })();

View file

@ -307,10 +307,12 @@
} }
item.appendChild(icon); item.appendChild(icon);
// Classify mode: an aggregate state dot for the folder's subtree. // Classify mode: an aggregate state dot for the folder's subtree, and a
// struck-through name when the WHOLE subtree is excluded (mirrors files).
if (classifyOn()) { if (classifyOn()) {
const agg = aggregateState(subtreeFiles(folder)); const agg = aggregateState(subtreeFiles(folder));
if (agg) item.appendChild(stateDot(agg)); if (agg) item.appendChild(stateDot(agg));
if (agg === 'excluded') item.classList.add('excluded');
} }
// Folder name // Folder name
@ -899,7 +901,31 @@
} }
} }
// ── context menu (exclude / include / clear) ─────────────────────────── // ── per-zip mode toggle (single file ⇄ expandable folder) ───────────────
function persistTreeChange() {
var ws = window.app.modules.workspace;
if (ws && ws.onRescanned) ws.onRescanned();
}
async function expandZip(file) {
if (!file.handle && !window.app.rootHandle) {
if (window.zddc) window.zddc.toast('Connect the source directory first to expand this archive.', 'warning');
return;
}
try {
var node = await window.app.modules.scanner.expandZipAsFolder(file);
if (node) { render(); persistTreeChange(); }
} catch (e) {
if (window.zddc) window.zddc.toast('Couldnt expand the archive — ' + (e.message || e), 'error');
}
}
function collapseZip(zipNode) {
if (!zipNode) return;
window.app.modules.scanner.collapseZipToFile(zipNode);
render();
persistTreeChange();
}
// ── context menu (exclude / include / clear / zip mode) ─────────────────
var menuEl = null; var menuEl = null;
function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } } function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } }
function showMenu(x, y, items) { function showMenu(x, y, items) {
@ -938,16 +964,27 @@
if (a.trackingNodeId) items.push({ label: 'Clear tracking', fn: function () { c.place([key], null, 'tracking'); } }); if (a.trackingNodeId) items.push({ label: 'Clear tracking', fn: function () { c.place([key], null, 'tracking'); } });
if (a.transmittalNodeId) items.push({ label: 'Clear transmittal', fn: function () { c.place([key], null, 'transmittal'); } }); if (a.transmittalNodeId) items.push({ label: 'Clear transmittal', fn: function () { c.place([key], null, 'transmittal'); } });
} }
var file = findFileByKey(key);
if (file && file.isVirtual) {
items.push({ label: 'Collapse archive to single file', fn: function () { collapseZip(findFolderByPath(file.zipPath)); } });
} else if (file && file.extension === 'zip') {
items.push({ label: 'Expand as folder', fn: function () { expandZip(file); } });
}
} else { } else {
var folder = findFolderByPath(folderEl.dataset.path); var folder = findFolderByPath(folderEl.dataset.path);
if (folder && folder.isZipRoot) {
items.push({ label: 'Collapse to single file', fn: function () { collapseZip(folder); } });
}
var keys = keysFor(subtreeFiles(folder || { files: [], children: [] })); var keys = keysFor(subtreeFiles(folder || { files: [], children: [] }));
if (!keys.length) return; if (keys.length) {
var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; }); var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; });
items.push({ items.push({
label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')', label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')',
fn: function () { c.setExcluded(keys, !allExcl); }, fn: function () { c.setExcluded(keys, !allExcl); },
}); });
} }
if (!items.length) return;
}
showMenu(e.clientX, e.clientY, items); showMenu(e.clientX, e.clientY, items);
} }

View file

@ -912,3 +912,48 @@ test('workspace: import recreates a transferable record (snapshot + map, no hand
expect(r.excluded).toBe(true); // classifications came across expect(r.excluded).toBe(true); // classifications came across
expect(r.noHandle).toBe(true); // source handle intentionally absent (re-attach on this browser) expect(r.noHandle).toBe(true); // source handle intentionally absent (re-attach on this browser)
}); });
test('zip mode: collapse turns an expanded archive back into one .zip file', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, sc = window.app.modules.scanner;
const member = { originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip', isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf' };
const zipNode = { name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, zipPath: 'Root/docs.zip', files: [member], children: [] };
const root = { name: 'Root', path: 'Root', files: [], children: [zipNode] };
zipNode.parent = root;
window.app.folderTree = [root];
c.setExcluded([c.srcKeyForFile(member)], true);
const hadAssign = !!(c.getAssignment('docs.zip/spec.pdf') || {}).excluded;
sc.collapseZipToFile(zipNode);
const file = root.files[0];
return {
hadAssign,
noChild: root.children.length === 0,
fileName: file && (file.originalFilename + '.' + file.extension),
droppedMemberAssign: !c.getAssignment('docs.zip/spec.pdf'),
};
});
expect(r.hadAssign).toBe(true); // member had an assignment
expect(r.noChild).toBe(true); // archive folder removed
expect(r.fileName).toBe('docs.zip'); // single .zip file restored
expect(r.droppedMemberAssign).toBe(true); // member assignment cleared
});
test('a fully-excluded folder is struck through like its files', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tree = window.app.modules.tree;
const f1 = { originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' };
const f2 = { originalFilename: 'b', extension: 'pdf', folderPath: 'Docs' };
window.app.folderTree = [{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [], files: [f1, f2] }];
tree.render();
const sel = '#folderTree .folder-item[data-path="Docs"]';
const before = document.querySelector(sel).classList.contains('excluded');
c.setExcluded([c.srcKeyForFile(f1), c.srcKeyForFile(f2)], true);
tree.render();
const after = document.querySelector(sel).classList.contains('excluded');
return { before, after };
});
expect(r.before).toBe(false); // not struck through while active
expect(r.after).toBe(true); // struck through once the whole subtree is excluded
});