fix(classifier): search opens only the path to a hit, not the whole tree

Searching no longer force-expands every folder. computeVisible now returns an
"open" set holding just the connector folders on the path down to each match;
those open to expose the hit, while off-path branches and terminal nodes keep
their real collapse state (and honest ▶/▼ arrows). Reshaping the tree is the
user's call — the root's expand-all is one click away.

Test: a deep file hit opens its branch and leaves the sibling collapsed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 13:10:27 -05:00
parent 15ce7098a0
commit e1c479dba5
2 changed files with 50 additions and 12 deletions

View file

@ -93,30 +93,42 @@
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); var folders = Object.create(null), files = Object.create(null), open = Object.create(null);
var nf = filterActive(); var nf = filterActive();
function walk(folder, ancMatched) { function walk(folder, ancMatched) {
var matched = ancMatched || (nf && nameHit(folder.path || folder.name)); var selfMatch = nf && nameHit(folder.path || folder.name);
var show = false, hasFile = false; var matched = ancMatched || selfMatch;
var show = false, hasFile = false, descMatch = false;
(folder.children || []).forEach(function (ch) { (folder.children || []).forEach(function (ch) {
var r = walk(ch, matched); var r = walk(ch, matched);
if (r.show) show = true; if (r.show) show = true;
if (r.hasFile) hasFile = true; if (r.hasFile) hasFile = true;
if (r.subtreeMatch) descMatch = true; // a child leads to a match
}); });
(folder.files || []).forEach(function (f) { (folder.files || []).forEach(function (f) {
hasFile = true; hasFile = true;
if (!classifyAllows(f)) return; if (!classifyAllows(f)) return;
if (!nf || matched || nameHit(c.srcKeyForFile(f))) { files[c.srcKeyForFile(f)] = true; show = true; } var fileMatch = nf && nameHit(c.srcKeyForFile(f));
if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; }
if (fileMatch) descMatch = true; // a match sits directly in this folder
}); });
if (matched) show = true; if (matched) show = true;
// "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;
return { show: show, hasFile: hasFile }; // 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 };
} }
(window.app.folderTree || []).forEach(function (root) { walk(root, false); }); (window.app.folderTree || []).forEach(function (root) { walk(root, false); });
return { folders: folders, files: files }; return { folders: folders, files: files, open: open };
} }
// 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;
@ -272,7 +284,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 || filterActive()) ? '▼' : '▶'; toggle.textContent = (folder.expanded || autoOpen(folder)) ? '▼' : '▶';
toggle.addEventListener('click', (e) => { toggle.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
const recursive = e.ctrlKey || e.metaKey; const recursive = e.ctrlKey || e.metaKey;
@ -350,9 +362,9 @@
div.appendChild(item); div.appendChild(item);
// Children — when expanded, or auto-expanded to reveal a NAME-search // Children — when expanded, or opened on the path to a search hit below.
// match. The Show toggles only hide/show; they never force-expand. // The Show toggles never force-expand; search opens only connector folders.
if ((folder.expanded || filterActive()) && folder.children && folder.children.length > 0) { if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) {
const childrenDiv = document.createElement('div'); const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children'; childrenDiv.className = 'folder-children';
folder.children.forEach(child => { folder.children.forEach(child => {
@ -364,8 +376,8 @@
} }
// Classify mode: list this folder's own files (draggable leaves) when // Classify mode: list this folder's own files (draggable leaves) when
// expanded (or auto-expanded by a name search), so they can be dropped. // expanded (or opened to reveal a search hit), so they can be dropped.
if (classifyOn() && (folder.expanded || filterActive()) && folder.files && folder.files.length > 0) { if (classifyOn() && (folder.expanded || autoOpen(folder)) && 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';
folder.files.forEach(function (file) { folder.files.forEach(function (file) {

View file

@ -786,3 +786,29 @@ test('toggling a Show filter preserves collapse state (no force-expand)', async
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', 'Project/Sub']); // name search auto-expands to the match
}); });
test('search opens only the branch with a hit, leaving siblings collapsed', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
{ name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' },
] },
{ name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' },
] },
],
}];
const tree = window.app.modules.tree;
tree.render();
tree.setNameFilter('switchgear'); // a file deep in the Electrical branch
return {
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),
};
});
// Path to the hit opens; the unrelated Civil sibling is not force-opened (stays out).
expect(r.folders).toEqual(['Project', 'Project/Electrical']);
expect(r.files).toEqual(['Switchgear Spec.pdf']);
});