diff --git a/classifier/css/layout.css b/classifier/css/layout.css index e54984d..c9fd8b2 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -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; diff --git a/classifier/js/classify.js b/classifier/js/classify.js index fca8ee6..dc90808 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -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, diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 0410644..3391d1d 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -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, }; })(); diff --git a/classifier/js/tree.js b/classifier/js/tree.js index 95951e0..6fec115 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -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 }; })(); diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 58f73b7..53d7227 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -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;