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:
parent
8368cc81b8
commit
1f8b4e4aaa
5 changed files with 176 additions and 21 deletions
|
|
@ -502,6 +502,7 @@
|
|||
.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; }
|
||||
.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) */
|
||||
.tfile { cursor: pointer; }
|
||||
|
|
|
|||
|
|
@ -129,6 +129,15 @@
|
|||
});
|
||||
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) {
|
||||
var a = assignmentFor(key);
|
||||
a.titleOverride = title && title.trim() ? title.trim() : null;
|
||||
|
|
@ -550,7 +559,7 @@
|
|||
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
|
||||
// assignments
|
||||
assignmentFor: assignmentFor, getAssignment: getAssignment,
|
||||
place: place, setExcluded: setExcluded,
|
||||
place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
|
||||
setTitleOverride: setTitleOverride,
|
||||
// trees
|
||||
addTrackingNode: addTrackingNode, addParty: addParty,
|
||||
|
|
|
|||
|
|
@ -323,18 +323,9 @@
|
|||
fo.folderPath = node.path;
|
||||
files.push(fo);
|
||||
if (scanStats) scanStats.files++;
|
||||
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') {
|
||||
// Don't read the archive during the listing — make an
|
||||
// expandable, lazy zip node scanned on open (scanZipNode).
|
||||
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++;
|
||||
}
|
||||
// A .zip is a single file by default (one classifiable unit).
|
||||
// The user can later "Expand as folder" (expandZipAsFolder) to
|
||||
// pull its members into the fileset.
|
||||
} else if (entry.kind === 'directory') {
|
||||
const childPath = node.path + '/' + entry.name;
|
||||
childDirs.push(makeNode(entry, childPath, node));
|
||||
|
|
@ -869,6 +860,76 @@
|
|||
if (!entry) throw new Error('zip member not found: ' + fileObj.zipEntryPath);
|
||||
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) {
|
||||
var cur = rootHandle;
|
||||
var parts = (relPath || '').split('/').filter(Boolean);
|
||||
|
|
@ -938,6 +999,8 @@
|
|||
resolveDirHandle,
|
||||
ensureZipLoaded,
|
||||
extractZipMember,
|
||||
expandZipAsFolder,
|
||||
collapseZipToFile,
|
||||
resumeScan
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -307,10 +307,12 @@
|
|||
}
|
||||
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()) {
|
||||
const agg = aggregateState(subtreeFiles(folder));
|
||||
if (agg) item.appendChild(stateDot(agg));
|
||||
if (agg === 'excluded') item.classList.add('excluded');
|
||||
}
|
||||
|
||||
// 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('Couldn’t 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;
|
||||
function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } }
|
||||
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.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 {
|
||||
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: [] }));
|
||||
if (!keys.length) return;
|
||||
if (keys.length) {
|
||||
var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; });
|
||||
items.push({
|
||||
label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')',
|
||||
fn: function () { c.setExcluded(keys, !allExcl); },
|
||||
});
|
||||
}
|
||||
if (!items.length) return;
|
||||
}
|
||||
showMenu(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue