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:
parent
47cf58b0e9
commit
eb1e3ec948
5 changed files with 309 additions and 19 deletions
|
|
@ -424,6 +424,31 @@
|
||||||
.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); }
|
||||||
|
|
||||||
|
/* 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 */
|
||||||
.spreadsheet-pane {
|
.spreadsheet-pane {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@
|
||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
// Read-only: returns the existing entry or null (no side effects).
|
||||||
|
function getAssignment(key) { return state.assignments[key] || null; }
|
||||||
function cleanAssignment(key) {
|
function cleanAssignment(key) {
|
||||||
var a = state.assignments[key];
|
var a = state.assignments[key];
|
||||||
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
|
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
|
||||||
|
|
@ -413,7 +415,8 @@
|
||||||
// keys/title
|
// keys/title
|
||||||
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
|
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
|
||||||
// assignments
|
// assignments
|
||||||
assignmentFor: assignmentFor, place: place, setExcluded: setExcluded,
|
assignmentFor: assignmentFor, getAssignment: getAssignment,
|
||||||
|
place: place, setExcluded: setExcluded,
|
||||||
setTitleOverride: setTitleOverride,
|
setTitleOverride: setTitleOverride,
|
||||||
// trees
|
// trees
|
||||||
addTrackingNode: addTrackingNode, addParty: addParty,
|
addTrackingNode: addTrackingNode, addParty: addParty,
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,28 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function C() { return window.app.modules.classify; }
|
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() {
|
function allFiles() {
|
||||||
var s = window.app.modules.store;
|
var out = [];
|
||||||
return s && s.getAllFiles ? s.getAllFiles() : [];
|
(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) {
|
function showTab(which) {
|
||||||
|
|
@ -77,8 +96,9 @@
|
||||||
function render() {
|
function render() {
|
||||||
if (!initialized || !C().isEnabled()) return;
|
if (!initialized || !C().isEnabled()) return;
|
||||||
var files = allFiles();
|
var files = allFiles();
|
||||||
renderTrackingInto(els.trackingTree, C().getTrackingTree(), files);
|
var placed = buildPlaced(files);
|
||||||
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), files);
|
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
|
||||||
|
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
|
||||||
renderStats(files);
|
renderStats(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +132,8 @@
|
||||||
files.forEach(function (f) {
|
files.forEach(function (f) {
|
||||||
var d = C().deriveTarget(f);
|
var d = C().deriveTarget(f);
|
||||||
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
|
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__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')));
|
||||||
row.appendChild(el('span', 'tfile__arrow', '→'));
|
row.appendChild(el('span', 'tfile__arrow', '→'));
|
||||||
row.appendChild(el('span', 'tfile__name', d.filename || '(incomplete)'));
|
row.appendChild(el('span', 'tfile__name', d.filename || '(incomplete)'));
|
||||||
|
|
@ -122,15 +143,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tracking tree (recursive)
|
// Tracking tree (recursive)
|
||||||
function renderTrackingInto(container, nodes, files) {
|
function renderTrackingInto(container, nodes, placedMap) {
|
||||||
container.textContent = '';
|
container.textContent = '';
|
||||||
if (!nodes.length) {
|
if (!nodes.length) {
|
||||||
container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.'));
|
container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.'));
|
||||||
return;
|
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 isLeaf = (n.children || []).length === 0;
|
||||||
var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : ''));
|
var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : ''));
|
||||||
wrap.dataset.id = n.id;
|
wrap.dataset.id = n.id;
|
||||||
|
|
@ -141,7 +162,7 @@
|
||||||
row.appendChild(toggle);
|
row.appendChild(toggle);
|
||||||
row.appendChild(el('span', 'tnode__name', n.name));
|
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)));
|
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
|
||||||
|
|
||||||
row.appendChild(nodeActions([
|
row.appendChild(nodeActions([
|
||||||
|
|
@ -154,22 +175,22 @@
|
||||||
if (placed.length) wrap.appendChild(fileList(placed));
|
if (placed.length) wrap.appendChild(fileList(placed));
|
||||||
if (!isLeaf && !collapsed[n.id]) {
|
if (!isLeaf && !collapsed[n.id]) {
|
||||||
var kids = el('div', 'tnode__children');
|
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);
|
wrap.appendChild(kids);
|
||||||
}
|
}
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transmittal tree
|
// Transmittal tree
|
||||||
function renderTransmittalInto(container, parties, files) {
|
function renderTransmittalInto(container, parties, placedMap) {
|
||||||
container.textContent = '';
|
container.textContent = '';
|
||||||
if (!parties.length) {
|
if (!parties.length) {
|
||||||
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
|
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
|
||||||
return;
|
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');
|
var wrap = el('div', 'tnode tnode--party');
|
||||||
wrap.dataset.id = party.id;
|
wrap.dataset.id = party.id;
|
||||||
var row = el('div', 'tnode__row');
|
var row = el('div', 'tnode__row');
|
||||||
|
|
@ -197,18 +218,18 @@
|
||||||
sw.appendChild(binForm(party.id, slot));
|
sw.appendChild(binForm(party.id, slot));
|
||||||
}
|
}
|
||||||
(slotNode ? slotNode.children : []).forEach(function (bin) {
|
(slotNode ? slotNode.children : []).forEach(function (bin) {
|
||||||
sw.appendChild(binNode(bin, files));
|
sw.appendChild(binNode(bin, placedMap));
|
||||||
});
|
});
|
||||||
wrap.appendChild(sw);
|
wrap.appendChild(sw);
|
||||||
});
|
});
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
function binNode(bin, files) {
|
function binNode(bin, placedMap) {
|
||||||
var wrap = el('div', 'tnode tnode--bin');
|
var wrap = el('div', 'tnode tnode--bin');
|
||||||
wrap.dataset.id = bin.id;
|
wrap.dataset.id = bin.id;
|
||||||
var row = el('div', 'tnode__row');
|
var row = el('div', 'tnode__row');
|
||||||
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
|
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)));
|
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
|
||||||
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }]));
|
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }]));
|
||||||
wrap.appendChild(row);
|
wrap.appendChild(row);
|
||||||
|
|
@ -240,7 +261,16 @@
|
||||||
var n = target.closest('.tnode');
|
var n = target.closest('.tnode');
|
||||||
return n ? n.dataset.id : null;
|
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) {
|
function onTrackingClick(e) {
|
||||||
|
if (revealInSource(e)) return;
|
||||||
var btn = e.target.closest('[data-act]');
|
var btn = e.target.closest('[data-act]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
var act = btn.dataset.act;
|
var act = btn.dataset.act;
|
||||||
|
|
@ -258,6 +288,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onTransmittalClick(e) {
|
function onTransmittalClick(e) {
|
||||||
|
if (revealInSource(e)) return;
|
||||||
var btn = e.target.closest('[data-act]');
|
var btn = e.target.closest('[data-act]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
var act = btn.dataset.act;
|
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 = {
|
window.app.modules.targetTree = {
|
||||||
init: init,
|
init: init,
|
||||||
render: render,
|
render: render,
|
||||||
showTab: showTab,
|
showTab: showTab,
|
||||||
|
reveal: reveal,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
*/
|
*/
|
||||||
function render() {
|
function render() {
|
||||||
const container = window.app.dom.folderTree;
|
const container = window.app.dom.folderTree;
|
||||||
|
wireClassifyInteractions();
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
if (window.app.folderTree.length === 0) {
|
if (window.app.folderTree.length === 0) {
|
||||||
|
|
@ -177,6 +178,12 @@
|
||||||
}
|
}
|
||||||
item.appendChild(icon);
|
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
|
// Folder name
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.className = 'folder-name';
|
name.className = 'folder-name';
|
||||||
|
|
@ -262,8 +269,10 @@
|
||||||
item.draggable = true;
|
item.draggable = true;
|
||||||
const key = c.srcKeyForFile(file);
|
const key = c.srcKeyForFile(file);
|
||||||
item.dataset.key = key;
|
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');
|
const icon = document.createElement('span');
|
||||||
icon.className = 'file-icon';
|
icon.className = 'file-icon';
|
||||||
|
|
@ -668,6 +677,142 @@
|
||||||
container.tabIndex = 0;
|
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
|
// Export module
|
||||||
window.app.modules.tree = {
|
window.app.modules.tree = {
|
||||||
render,
|
render,
|
||||||
|
|
@ -675,6 +820,7 @@
|
||||||
loadFilesFromSelectedFolders,
|
loadFilesFromSelectedFolders,
|
||||||
setupKeyboardShortcuts,
|
setupKeyboardShortcuts,
|
||||||
expandAll,
|
expandAll,
|
||||||
selectAll
|
selectAll,
|
||||||
|
revealFile
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,69 @@ test('dropping onto a transmittal bin assigns; dropping on a party row does not'
|
||||||
expect(r.afterParty).toBe(null);
|
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 }) => {
|
test('deleting a tracking node clears the files placed in it', async ({ page }) => {
|
||||||
const after = await page.evaluate((file) => {
|
const after = await page.evaluate((file) => {
|
||||||
const c = window.app.modules.classify;
|
const c = window.app.modules.classify;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue