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--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; }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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('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;
|
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,15 +964,26 @@
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue