feat(classifier): left-tree markers, exclude, cross-tree find (phase 4)

- Each source file row shows a classification state dot (unassigned →
  has-tracking/transmittal → done), and each folder shows an aggregate dot
  over its subtree.
- Right-click a file or folder to Exclude/Include from the copy (folder applies
  to its whole subtree) or clear an axis; excluded files are struck through and
  never copied.
- Cross-tree find is bidirectional: click a placed file in the target pane to
  reveal+flash it in the source tree (expanding its folders); click a source
  file to switch the target pane to its placed axis and flash the node.
- Target pane now reverse-looks-up over ALL scanned files (the left tree), not
  the selection-scoped grid, with placements grouped in one pass per render.
- classify.getAssignment() read-only accessor; 5 new tests (18 total green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-09 12:32:42 -05:00
parent 47cf58b0e9
commit eb1e3ec948
5 changed files with 309 additions and 19 deletions

View file

@ -424,6 +424,31 @@
.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); }
/* placed-file row in the target pane is clickable (reveal in source) */
.tfile { cursor: pointer; }
.tfile:hover .tfile__name { text-decoration: underline; }
/* cross-tree reveal flash */
.reveal-flash, .match-highlight { animation: cl-flash 1.5s ease-out; }
@keyframes cl-flash {
0%, 40% { background: var(--primary-light); outline: 2px solid var(--primary); outline-offset: -2px; }
100% { background: transparent; outline-color: transparent; }
}
/* exclude/include context menu */
.cl-menu {
position: fixed; z-index: 9500;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: 0 6px 18px rgba(0,0,0,0.18);
padding: 0.25rem; min-width: 11rem;
}
.cl-menu__item {
display: block; width: 100%; text-align: left;
border: none; background: none; color: var(--text);
padding: 0.4rem 0.6rem; font-size: 0.83rem; cursor: pointer; border-radius: var(--radius);
}
.cl-menu__item:hover { background: var(--bg-hover); }
/* Spreadsheet Pane */
.spreadsheet-pane {
flex: 1;

View file

@ -108,6 +108,8 @@
}
return a;
}
// Read-only: returns the existing entry or null (no side effects).
function getAssignment(key) { return state.assignments[key] || null; }
function cleanAssignment(key) {
var a = state.assignments[key];
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
@ -413,7 +415,8 @@
// keys/title
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
// assignments
assignmentFor: assignmentFor, place: place, setExcluded: setExcluded,
assignmentFor: assignmentFor, getAssignment: getAssignment,
place: place, setExcluded: setExcluded,
setTitleOverride: setTitleOverride,
// trees
addTrackingNode: addTrackingNode, addParty: addParty,

View file

@ -60,9 +60,28 @@
}
function C() { return window.app.modules.classify; }
// Every scanned source file (classify mode reads the left tree, not the
// selection-scoped grid). Lazy folders contribute their files once scanned.
function allFiles() {
var s = window.app.modules.store;
return s && s.getAllFiles ? s.getAllFiles() : [];
var out = [];
(function walk(nodes) {
(nodes || []).forEach(function (n) {
(n.files || []).forEach(function (f) { out.push(f); });
walk(n.children);
});
})(window.app.folderTree || []);
return out;
}
// One pass: group files by the node they're placed in, per axis.
function buildPlaced(files) {
var c = C(), byT = {}, byX = {};
files.forEach(function (f) {
var a = c.getAssignment(c.srcKeyForFile(f));
if (!a) return;
if (a.trackingNodeId) (byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f);
});
return { tracking: byT, transmittal: byX };
}
function showTab(which) {
@ -77,8 +96,9 @@
function render() {
if (!initialized || !C().isEnabled()) return;
var files = allFiles();
renderTrackingInto(els.trackingTree, C().getTrackingTree(), files);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), files);
var placed = buildPlaced(files);
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
renderStats(files);
}
@ -112,7 +132,8 @@
files.forEach(function (f) {
var d = C().deriveTarget(f);
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
row.title = d.errors.length ? d.errors.join('; ') : '';
row.title = d.errors.length ? d.errors.join('; ') : 'Click to find this file in the source tree';
row.dataset.key = d.key; // for cross-tree reveal
row.appendChild(el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')));
row.appendChild(el('span', 'tfile__arrow', '→'));
row.appendChild(el('span', 'tfile__name', d.filename || '(incomplete)'));
@ -122,15 +143,15 @@
}
// Tracking tree (recursive)
function renderTrackingInto(container, nodes, files) {
function renderTrackingInto(container, nodes, placedMap) {
container.textContent = '';
if (!nodes.length) {
container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.'));
return;
}
nodes.forEach(function (n) { container.appendChild(trackingNode(n, files)); });
nodes.forEach(function (n) { container.appendChild(trackingNode(n, placedMap)); });
}
function trackingNode(n, files) {
function trackingNode(n, placedMap) {
var isLeaf = (n.children || []).length === 0;
var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : ''));
wrap.dataset.id = n.id;
@ -141,7 +162,7 @@
row.appendChild(toggle);
row.appendChild(el('span', 'tnode__name', n.name));
var placed = C().filesInNode(n.id, 'tracking', files);
var placed = placedMap[n.id] || [];
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([
@ -154,22 +175,22 @@
if (placed.length) wrap.appendChild(fileList(placed));
if (!isLeaf && !collapsed[n.id]) {
var kids = el('div', 'tnode__children');
(n.children || []).forEach(function (c) { kids.appendChild(trackingNode(c, files)); });
(n.children || []).forEach(function (c) { kids.appendChild(trackingNode(c, placedMap)); });
wrap.appendChild(kids);
}
return wrap;
}
// Transmittal tree
function renderTransmittalInto(container, parties, files) {
function renderTransmittalInto(container, parties, placedMap) {
container.textContent = '';
if (!parties.length) {
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
return;
}
parties.forEach(function (p) { container.appendChild(partyNode(p, files)); });
parties.forEach(function (p) { container.appendChild(partyNode(p, placedMap)); });
}
function partyNode(party, files) {
function partyNode(party, placedMap) {
var wrap = el('div', 'tnode tnode--party');
wrap.dataset.id = party.id;
var row = el('div', 'tnode__row');
@ -197,18 +218,18 @@
sw.appendChild(binForm(party.id, slot));
}
(slotNode ? slotNode.children : []).forEach(function (bin) {
sw.appendChild(binNode(bin, files));
sw.appendChild(binNode(bin, placedMap));
});
wrap.appendChild(sw);
});
return wrap;
}
function binNode(bin, files) {
function binNode(bin, placedMap) {
var wrap = el('div', 'tnode tnode--bin');
wrap.dataset.id = bin.id;
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
var placed = C().filesInNode(bin.id, 'transmittal', files);
var placed = placedMap[bin.id] || [];
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }]));
wrap.appendChild(row);
@ -240,7 +261,16 @@
var n = target.closest('.tnode');
return n ? n.dataset.id : null;
}
function revealInSource(e) {
var tf = e.target.closest('.tfile');
if (tf && tf.dataset.key && window.app.modules.tree.revealFile) {
window.app.modules.tree.revealFile(tf.dataset.key);
return true;
}
return false;
}
function onTrackingClick(e) {
if (revealInSource(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
@ -258,6 +288,7 @@
}
}
function onTransmittalClick(e) {
if (revealInSource(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
@ -334,9 +365,31 @@
});
}
// Reveal a source key's placement in the target pane (source → target).
function reveal(key) {
var a = C().getAssignment(key);
if (!a) return;
if (a.trackingNodeId) {
showTab('tracking'); collapsed = {}; render();
flashNode(els.trackingTree, a.trackingNodeId);
} else if (a.transmittalNodeId) {
showTab('transmittal'); render();
flashNode(els.transmittalTree, a.transmittalNodeId);
}
}
function flashNode(container, id) {
var node = container.querySelector('.tnode[data-id="' + id + '"]');
if (!node) return;
node.scrollIntoView({ block: 'center' });
var row = node.querySelector('.tnode__row') || node;
row.classList.add('reveal-flash');
setTimeout(function () { row.classList.remove('reveal-flash'); }, 1500);
}
window.app.modules.targetTree = {
init: init,
render: render,
showTab: showTab,
reveal: reveal,
};
})();

View file

@ -40,6 +40,7 @@
*/
function render() {
const container = window.app.dom.folderTree;
wireClassifyInteractions();
container.innerHTML = '';
if (window.app.folderTree.length === 0) {
@ -177,6 +178,12 @@
}
item.appendChild(icon);
// Classify mode: an aggregate state dot for the folder's subtree.
if (classifyOn()) {
const agg = aggregateState(subtreeFiles(folder));
if (agg) item.appendChild(stateDot(agg));
}
// Folder name
const name = document.createElement('span');
name.className = 'folder-name';
@ -262,8 +269,10 @@
item.draggable = true;
const key = c.srcKeyForFile(file);
item.dataset.key = key;
const st = c.fileState(file);
if (st === 'excluded') item.classList.add('excluded');
item.appendChild(stateDot(c.fileState(file)));
item.appendChild(stateDot(st));
const icon = document.createElement('span');
icon.className = 'file-icon';
@ -668,6 +677,142 @@
container.tabIndex = 0;
}
// ── Classify interactions (exclude menu, cross-tree reveal) ─────────────
var classifyWired = false;
function wireClassifyInteractions() {
if (classifyWired) return;
classifyWired = true;
var ft = window.app.dom.folderTree;
if (!ft) { classifyWired = false; return; }
ft.addEventListener('contextmenu', onContextMenu);
ft.addEventListener('click', function (e) {
if (!classifyOn()) return;
var fe = e.target.closest('.file-item');
if (fe && fe.dataset.key && window.app.modules.targetTree) {
window.app.modules.targetTree.reveal(fe.dataset.key);
}
});
}
// Aggregate classification state across a folder's loaded subtree files.
function aggregateState(files) {
if (!files.length) return null;
var c = window.app.modules.classify;
var ex = 0, done = 0, placed = 0;
files.forEach(function (f) {
var s = c.fileState(f);
if (s === 'excluded') ex++;
else if (s === 'done') done++;
else if (s !== 'none') placed++;
});
if (ex === files.length) return 'excluded';
var active = files.length - ex;
if (active > 0 && done === active) return 'done';
if (done > 0 || placed > 0) return 'partial';
return 'none';
}
function findFolderByPath(path) {
var hit = null;
(function walk(nodes) {
(nodes || []).forEach(function (n) {
if (hit) return;
if (n.path === path) { hit = n; return; }
walk(n.children);
});
})(window.app.folderTree);
return hit;
}
function findFileByKey(key) {
var c = window.app.modules.classify, hit = null;
(function walk(nodes) {
(nodes || []).forEach(function (n) {
if (hit) return;
(n.files || []).forEach(function (f) { if (!hit && c.srcKeyForFile(f) === key) hit = f; });
walk(n.children);
});
})(window.app.folderTree);
return hit;
}
function expandToPath(folderPath) {
(function walk(nodes) {
(nodes || []).forEach(function (n) {
if (n.path === folderPath || folderPath.indexOf(n.path + '/') === 0) {
n.expanded = true;
walk(n.children);
}
});
})(window.app.folderTree);
}
// Reveal a source file (target → source). Expands its folder chain, renders,
// scrolls + flashes the row.
function revealFile(key) {
var file = findFileByKey(key);
if (!file) return;
expandToPath(file.folderPath);
render();
var rows = window.app.dom.folderTree.querySelectorAll('.file-item');
var row = Array.prototype.filter.call(rows, function (r) { return r.dataset.key === key; })[0];
if (row) {
row.scrollIntoView({ block: 'center' });
row.classList.add('match-highlight');
setTimeout(function () { row.classList.remove('match-highlight'); }, 1500);
}
}
// ── context menu (exclude / include / clear) ───────────────────────────
var menuEl = null;
function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } }
function showMenu(x, y, items) {
hideMenu();
menuEl = document.createElement('div');
menuEl.className = 'cl-menu';
items.forEach(function (it) {
var b = document.createElement('button');
b.className = 'cl-menu__item';
b.textContent = it.label;
b.addEventListener('click', function () { hideMenu(); it.fn(); });
menuEl.appendChild(b);
});
menuEl.style.left = x + 'px';
menuEl.style.top = y + 'px';
document.body.appendChild(menuEl);
setTimeout(function () {
document.addEventListener('click', hideMenu, { once: true });
document.addEventListener('scroll', hideMenu, { once: true, capture: true });
}, 0);
}
function onContextMenu(e) {
if (!classifyOn()) return;
var c = window.app.modules.classify;
var fileEl = e.target.closest('.file-item');
var folderEl = e.target.closest('.folder-item');
if (!fileEl && !folderEl) return;
e.preventDefault();
var items = [];
if (fileEl) {
var key = fileEl.dataset.key;
var a = c.getAssignment(key);
var excluded = !!(a && a.excluded);
items.push({ label: excluded ? 'Include in copy' : 'Exclude from copy', fn: function () { c.setExcluded([key], !excluded); } });
if (a && (a.trackingNodeId || a.transmittalNodeId)) {
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'); } });
}
} else {
var folder = findFolderByPath(folderEl.dataset.path);
var keys = keysFor(subtreeFiles(folder || { files: [], children: [] }));
if (!keys.length) return;
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); },
});
}
showMenu(e.clientX, e.clientY, items);
}
// Export module
window.app.modules.tree = {
render,
@ -675,6 +820,7 @@
loadFilesFromSelectedFolders,
setupKeyboardShortcuts,
expandAll,
selectAll
selectAll,
revealFile
};
})();

View file

@ -223,6 +223,69 @@ test('dropping onto a transmittal bin assigns; dropping on a party row does not'
expect(r.afterParty).toBe(null);
});
// ── Phase 4: left-tree markers, exclude, cross-tree find ───────────────────
// Inject a synthetic scanned tree (no FS Access needed) and render it.
async function withSourceTree(page) {
await page.click('#modeClassifyBtn');
await page.evaluate(() => {
window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
files: [{ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }],
children: [], fileCount: 1, subdirCount: 0, runFiles: 1, runDirs: 0,
}];
window.app.modules.tree.render();
});
}
test('source file rows render with a state dot in classify mode', async ({ page }) => {
await withSourceTree(page);
await expect(page.locator('#folderTree .file-item .file-name', { hasText: 'Foundation Plan.pdf' })).toBeVisible();
await expect(page.locator('#folderTree .file-item .cl-dot--none')).toBeVisible();
});
test('placing a file turns its dot (and the folder aggregate) done', async ({ page }) => {
await withSourceTree(page);
await page.evaluate(() => {
const c = window.app.modules.classify;
const realKey = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' });
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
c.place([realKey], leaf, 'tracking');
c.place([realKey], bin, 'transmittal');
window.app.modules.tree.render();
});
await expect(page.locator('#folderTree .file-item .cl-dot--done')).toBeVisible();
await expect(page.locator('#folderTree .folder-item .cl-dot--done')).toBeVisible();
});
test('context-menu exclude marks the file excluded', async ({ page }) => {
await withSourceTree(page);
await page.locator('#folderTree .file-item').click({ button: 'right' });
await expect(page.locator('.cl-menu')).toBeVisible();
await page.locator('.cl-menu__item', { hasText: 'Exclude from copy' }).click();
await expect(page.locator('#folderTree .file-item.excluded')).toBeVisible();
const excluded = await page.evaluate(() => {
const c = window.app.modules.classify;
const key = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' });
return c.getAssignment(key).excluded;
});
expect(excluded).toBe(true);
});
test('cross-tree reveal: source→target switches to the placed axis', async ({ page }) => {
await withSourceTree(page);
const ok = await page.evaluate(() => {
const c = window.app.modules.classify;
const key = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' });
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'issued', { date: '2026-03-14', type: 'SUB', seq: '0001' });
c.place([key], bin, 'transmittal');
window.app.modules.targetTree.reveal(key); // should switch to transmittal tab
return !document.getElementById('transmittalPanel').hidden;
});
expect(ok).toBe(true);
});
test('deleting a tracking node clears the files placed in it', async ({ page }) => {
const after = await page.evaluate((file) => {
const c = window.app.modules.classify;