diff --git a/classifier/js/tree.js b/classifier/js/tree.js index 561174d..3141075 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -93,30 +93,42 @@ var visible = null; // { folders, files } while filtering, else null function computeVisible() { 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(); function walk(folder, ancMatched) { - var matched = ancMatched || (nf && nameHit(folder.path || folder.name)); - var show = false, hasFile = false; + var selfMatch = nf && nameHit(folder.path || folder.name); + var matched = ancMatched || selfMatch; + var show = false, hasFile = false, descMatch = false; (folder.children || []).forEach(function (ch) { var r = walk(ch, matched); if (r.show) show = true; if (r.hasFile) hasFile = true; + if (r.subtreeMatch) descMatch = true; // a child leads to a match }); (folder.files || []).forEach(function (f) { hasFile = true; 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; // "Show Empty" off → hide folders whose whole subtree holds no files. if (!hasFile && !showEmpty && !matched) show = false; 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); }); - 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 fileShown(file) { if (!classifyAllows(file)) return false; @@ -272,7 +284,7 @@ // expandable so its files can be revealed and dragged. || (classifyOn() && folder.files && folder.files.length > 0); if (mightHaveChildren) { - toggle.textContent = (folder.expanded || filterActive()) ? '▼' : '▶'; + toggle.textContent = (folder.expanded || autoOpen(folder)) ? '▼' : '▶'; toggle.addEventListener('click', (e) => { e.stopPropagation(); const recursive = e.ctrlKey || e.metaKey; @@ -350,9 +362,9 @@ div.appendChild(item); - // Children — when expanded, or auto-expanded to reveal a NAME-search - // match. The Show toggles only hide/show; they never force-expand. - if ((folder.expanded || filterActive()) && folder.children && folder.children.length > 0) { + // Children — when expanded, or opened on the path to a search hit below. + // The Show toggles never force-expand; search opens only connector folders. + if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) { const childrenDiv = document.createElement('div'); childrenDiv.className = 'folder-children'; folder.children.forEach(child => { @@ -364,8 +376,8 @@ } // Classify mode: list this folder's own files (draggable leaves) when - // expanded (or auto-expanded by a name search), so they can be dropped. - if (classifyOn() && (folder.expanded || filterActive()) && folder.files && folder.files.length > 0) { + // expanded (or opened to reveal a search hit), so they can be dropped. + if (classifyOn() && (folder.expanded || autoOpen(folder)) && folder.files && folder.files.length > 0) { const filesDiv = document.createElement('div'); filesDiv.className = 'folder-children folder-files'; folder.files.forEach(function (file) { diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 0a91e39..cec583c 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -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.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']); +});