fix(classifier): the Folder Tree autofilter no longer auto-expands

The name filter (and the search-reveal path logic) used to force-expand every
folder on the way to a match, reshaping the tree the moment you typed. If you'd
collapsed everything except the one subtree you cared about, filtering blew the
rest open.

Now the filter only shows/hides rows IN PLACE: expansion is driven solely by the
user's folder.expanded. A collapsed folder that contains matches is still shown
(so you can open it) but stays collapsed — the filter never changes expand state.
Removed the autoOpen() force-expand and the `open` path-map it read.

Tests updated to the new contract: filter hides non-matches in place within
expanded folders, shows match-containing folders collapsed without revealing
their files, and leaves expand state untouched. Classifier suites 68 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-15 09:29:45 -05:00
parent 60678e552d
commit 0d8125a331
2 changed files with 36 additions and 32 deletions

View file

@ -111,7 +111,7 @@
var visible = null; // { folders, files } while filtering, else null var visible = null; // { folders, files } while filtering, else null
function computeVisible() { function computeVisible() {
var c = window.app.modules.classify; var c = window.app.modules.classify;
var folders = Object.create(null), files = Object.create(null), open = Object.create(null); var folders = Object.create(null), files = Object.create(null);
var nf = filterActive(); var nf = filterActive();
function walk(folder, ancMatched) { function walk(folder, ancMatched) {
var selfMatch = nf && nameHit(folder.path || folder.name); var selfMatch = nf && nameHit(folder.path || folder.name);
@ -134,19 +134,11 @@
// "Show Empty" off → hide folders whose whole subtree holds no files. // "Show Empty" off → hide folders whose whole subtree holds no files.
if (!hasFile && !showEmpty && !matched) show = false; if (!hasFile && !showEmpty && !matched) show = false;
if (show) folders[folder.path] = true; if (show) folders[folder.path] = true;
// Auto-open ONLY the connector folders on the path down to a match —
// never the matched node itself. Terminal matches and everything
// off-path keep their real collapse state; the root's expand-all
// covers the rest. (Search reveals where hits are; it doesn't reshape
// the tree.)
if (nf && descMatch) open[folder.path] = true;
return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch }; return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch };
} }
(window.app.folderTree || []).forEach(function (root) { walk(root, false); }); (window.app.folderTree || []).forEach(function (root) { walk(root, false); });
return { folders: folders, files: files, open: open }; return { folders: folders, files: files };
} }
// True only for folders the search needs opened to expose a hit beneath them.
function autoOpen(folder) { return !!(visible && visible.open && visible.open[folder.path]); }
function folderShown(folder) { return !visible || !!visible.folders[folder.path]; } function folderShown(folder) { return !visible || !!visible.folders[folder.path]; }
function fileShown(file) { function fileShown(file) {
if (!classifyAllows(file)) return false; if (!classifyAllows(file)) return false;
@ -302,7 +294,7 @@
// expandable so its files can be revealed and dragged. // expandable so its files can be revealed and dragged.
|| (classifyOn() && folder.files && folder.files.length > 0); || (classifyOn() && folder.files && folder.files.length > 0);
if (mightHaveChildren) { if (mightHaveChildren) {
toggle.textContent = (folder.expanded || autoOpen(folder)) ? '▼' : '▶'; toggle.textContent = folder.expanded ? '▼' : '▶';
toggle.addEventListener('click', (e) => { toggle.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const recursive = e.ctrlKey || e.metaKey; const recursive = e.ctrlKey || e.metaKey;
@ -366,9 +358,12 @@
div.appendChild(item); div.appendChild(item);
// Children — when expanded, or opened on the path to a search hit below. // Children render ONLY when the user has expanded this folder. The
// The Show toggles never force-expand; search opens only connector folders. // autofilter and Show toggles never change expand/collapse state — they
if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) { // hide/show rows in place. A collapsed folder stays collapsed even if it
// contains matches (it's still shown, so the user can open it); this lets
// you filter within one subtree without the rest expanding.
if (folder.expanded && folder.children && folder.children.length > 0) {
const childrenDiv = document.createElement('div'); const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children'; childrenDiv.className = 'folder-children';
sortedFolders(folder.children).forEach(child => { sortedFolders(folder.children).forEach(child => {
@ -379,9 +374,9 @@
div.appendChild(childrenDiv); div.appendChild(childrenDiv);
} }
// Classify mode: list this folder's own files (draggable leaves) when // Classify mode: list this folder's own files (draggable leaves) only
// expanded (or opened to reveal a search hit), so they can be dropped. // when the user has expanded it (the filter never force-expands).
if (classifyOn() && (folder.expanded || autoOpen(folder)) && folder.files && folder.files.length > 0) { if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) {
const filesDiv = document.createElement('div'); const filesDiv = document.createElement('div');
filesDiv.className = 'folder-children folder-files'; filesDiv.className = 'folder-children folder-files';
sortedFiles(folder.files).forEach(function (file) { sortedFiles(folder.files).forEach(function (file) {

View file

@ -729,29 +729,35 @@ test('dataset (filename-based): import reconstruction rebuilds tracking + shared
expect(r.excluded).toBe(true); expect(r.excluded).toBe(true);
}); });
test('source-tree filter reveals matches with their folder hierarchy', async ({ page }) => { test('source-tree filter hides non-matches in place; never changes expand state', async ({ page }) => {
await page.click('#modeClassifyBtn'); await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => { const r = await page.evaluate(() => {
window.app.folderTree = [{ window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [ name: 'Project', path: 'Project', expanded: true, scanState: 'done', files: [], children: [
{ name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [ // EXPANDED: its match shows in place, the non-match is hidden.
{ name: 'Electrical', path: 'Project/Electrical', expanded: true, scanState: 'done', children: [], files: [
{ originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' }, { originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' },
{ originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' }, { originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' },
] }, ] },
// COLLAPSED but ALSO holds a match — it must stay collapsed (shown
// as a row, file NOT revealed): the filter never auto-expands.
{ name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [ { name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' }, { originalFilename: 'master deliverables draft', extension: 'pdf', folderPath: 'Project/Civil' },
] }, ] },
], ],
}]; }];
window.app.modules.tree.render(); window.app.modules.tree.render();
window.app.modules.tree.setNameFilter('master deliverables'); window.app.modules.tree.setNameFilter('master deliverables');
const civil = window.app.folderTree[0].children[1];
return { return {
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent), files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path), folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort(),
civilStillCollapsed: civil.expanded === false,
}; };
}); });
expect(r.files).toEqual(['Master Deliverables List.xlsx']); // only the match shown expect(r.files).toEqual(['Master Deliverables List.xlsx']); // expanded folder: match in place, Switchgear hidden
expect(r.folders).toEqual(['Project', 'Project/Electrical']); // path revealed; Civil hidden expect(r.folders).toEqual(['Project', 'Project/Civil', 'Project/Electrical']); // Civil shown (has a match) but collapsed
expect(r.civilStillCollapsed).toBe(true); // the filter did NOT expand it
}); });
test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => { test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => {
@ -803,18 +809,19 @@ test('toggling a Show filter preserves collapse state (no force-expand)', async
// A Show toggle must not expand the collapsed parent… // A Show toggle must not expand the collapsed parent…
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false }); tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
const afterToggle = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path); const afterToggle = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
// …whereas a name search still reveals the match by auto-expanding. // …and neither does the name filter — it hides/shows in place, never expands.
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: true }); tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: true });
tree.setNameFilter('a.pdf'); tree.setNameFilter('a.pdf');
const afterSearch = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path); const afterSearch = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
return { collapsed, afterToggle, afterSearch }; return { collapsed, afterToggle, afterSearch, parentCollapsed: window.app.folderTree[0].expanded === false };
}); });
expect(r.collapsed).toEqual(['Project']); // child hidden — parent collapsed expect(r.collapsed).toEqual(['Project']); // child hidden — parent collapsed
expect(r.afterToggle).toEqual(['Project']); // Show toggle leaves it collapsed expect(r.afterToggle).toEqual(['Project']); // Show toggle leaves it collapsed
expect(r.afterSearch).toEqual(['Project', 'Project/Sub']); // name search auto-expands to the match expect(r.afterSearch).toEqual(['Project']); // name filter leaves it collapsed (no force-expand)
expect(r.parentCollapsed).toBe(true); // expand state untouched
}); });
test('search opens only the branch with a hit, leaving siblings collapsed', async ({ page }) => { test('filter does not open collapsed branches; non-matching siblings hide', async ({ page }) => {
await page.click('#modeClassifyBtn'); await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => { const r = await page.evaluate(() => {
window.app.folderTree = [{ window.app.folderTree = [{
@ -829,15 +836,17 @@ test('search opens only the branch with a hit, leaving siblings collapsed', asyn
}]; }];
const tree = window.app.modules.tree; const tree = window.app.modules.tree;
tree.render(); tree.render();
tree.setNameFilter('switchgear'); // a file deep in the Electrical branch tree.setNameFilter('switchgear'); // a file deep in the (collapsed) Electrical branch
return { return {
folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path), folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path),
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent), files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
}; };
}); });
// Path to the hit opens; the unrelated Civil sibling is not force-opened (stays out). // Project contains a match so it's shown — but stays COLLAPSED, so Electrical
expect(r.folders).toEqual(['Project', 'Project/Electrical']); // isn't rendered and the hit isn't revealed (the user expands to reach it).
expect(r.files).toEqual(['Switchgear Spec.pdf']); // Civil has no match and is hidden.
expect(r.folders).toEqual(['Project']);
expect(r.files).toEqual([]);
}); });
test('snapshot: a scanned zip subtree round-trips with its virtual members', async ({ page }) => { test('snapshot: a scanned zip subtree round-trips with its virtual members', async ({ page }) => {