feat(classifier): Show Empty toggle; tidy the folder-tree header

- Add a "Show Empty" checkbox (classify mode) — when off, folders whose whole
  subtree contains no files are hidden, decluttering messy scans.
- Move the Show Unassigned/Assigned/Excluded/Empty filters out of the cramped
  pane header into a dedicated "Show …" toolbar row beneath it (wraps cleanly).
- Drop the "X folders selected" text from the folder-tree header (selection
  still works; updateSelectedCount guards the now-absent element).

Test: Show Empty off hides file-less folders (classify.spec.js -> 41).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 12:52:03 -05:00
parent c61cac7c8f
commit aa34a4b3e7
5 changed files with 70 additions and 28 deletions

View file

@ -76,6 +76,8 @@
} }
.folder-tree-pane.collapsed .pane-header-controls, .folder-tree-pane.collapsed .pane-header-controls,
.folder-tree-pane.collapsed .classify-filters,
.folder-tree-pane.collapsed .tree-filter,
.folder-tree-pane.collapsed .folder-tree, .folder-tree-pane.collapsed .folder-tree,
.folder-tree-pane.collapsed .pane-header h3 { .folder-tree-pane.collapsed .pane-header h3 {
display: none; display: none;
@ -141,13 +143,19 @@
.pane-header-controls { .pane-header-controls {
display: flex; display: flex;
flex-direction: column; flex-wrap: wrap;
gap: 0.5rem; gap: 0.3rem 0.75rem;
align-items: flex-end; align-items: center;
justify-content: flex-end;
} }
/* Classify-mode source-tree filters (Show Unassigned/Assigned/Excluded). */ /* Classify-mode filter row, laid out as a toolbar under the pane header. */
.classify-filters { display: inline-flex; flex-wrap: wrap; gap: 0.3rem 0.75rem; justify-content: flex-end; } .tree-toolbar {
display: flex; flex-wrap: wrap; align-items: center;
gap: 0.2rem 0.7rem; padding: 0.3rem 1rem;
border-bottom: 1px solid var(--border);
}
.tree-toolbar__label { color: var(--text-muted); font-size: 0.8rem; font-weight: 600; }
.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; } .classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; }
/* Live filter box above a file tree. */ /* Live filter box above a file tree. */

View file

@ -147,6 +147,7 @@
showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'), showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'),
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'), showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'), showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
exportDatasetBtn: document.getElementById('exportDatasetBtn'), exportDatasetBtn: document.getElementById('exportDatasetBtn'),
importDatasetBtn: document.getElementById('importDatasetBtn'), importDatasetBtn: document.getElementById('importDatasetBtn'),
importDatasetInput: document.getElementById('importDatasetInput'), importDatasetInput: document.getElementById('importDatasetInput'),
@ -336,10 +337,11 @@
unassigned: app.dom.showUnassignedCheckbox.checked, unassigned: app.dom.showUnassignedCheckbox.checked,
assigned: app.dom.showAssignedCheckbox.checked, assigned: app.dom.showAssignedCheckbox.checked,
excluded: app.dom.showExcludedCheckbox.checked, excluded: app.dom.showExcludedCheckbox.checked,
empty: app.dom.showEmptyCheckbox.checked,
}); });
} }
} }
[app.dom.showUnassignedCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox] [app.dom.showUnassignedCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox]
.forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); }); .forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); });
// Collapse tree button // Collapse tree button

View file

@ -42,12 +42,14 @@
// (so unchecking Assigned+Excluded leaves only what's left to do). A folder // (so unchecking Assigned+Excluded leaves only what's left to do). A folder
// whose whole scanned subtree is filtered away is itself hidden. // whose whole scanned subtree is filtered away is itself hidden.
var showFilters = { unassigned: true, assigned: true, excluded: true }; var showFilters = { unassigned: true, assigned: true, excluded: true };
var showEmpty = true; // show folders that contain no files
function setShowFilters(f) { function setShowFilters(f) {
showFilters = { showFilters = {
unassigned: f.unassigned !== false, unassigned: f.unassigned !== false,
assigned: f.assigned !== false, assigned: f.assigned !== false,
excluded: f.excluded !== false, excluded: f.excluded !== false,
}; };
showEmpty = f.empty !== false;
render(); render();
} }
function allFiltersOn() { return showFilters.unassigned && showFilters.assigned && showFilters.excluded; } function allFiltersOn() { return showFilters.unassigned && showFilters.assigned && showFilters.excluded; }
@ -81,8 +83,8 @@
for (var i = 0; i < filterTerms.length; i++) { if (t.indexOf(filterTerms[i]) === -1) return false; } for (var i = 0; i < filterTerms.length; i++) { if (t.indexOf(filterTerms[i]) === -1) return false; }
return true; return true;
} }
// Anything narrowing the tree (a name search, or a show-filter turned off). // Anything narrowing the tree (a name search, a show-filter off, or hiding empties).
function anyFilter() { return filterActive() || (classifyOn() && !allFiltersOn()); } function anyFilter() { return filterActive() || (classifyOn() && (!allFiltersOn() || !showEmpty)); }
// One pass → the set of folder paths + file keys to render. A file shows when // One pass → the set of folder paths + file keys to render. A file shows when
// it passes the show-filters AND (no name search, OR an ancestor folder // it passes the show-filters AND (no name search, OR an ancestor folder
// matched, OR its own path/name matches). A folder shows when it (or an // matched, OR its own path/name matches). A folder shows when it (or an
@ -95,15 +97,22 @@
var nf = filterActive(); var nf = filterActive();
function walk(folder, ancMatched) { function walk(folder, ancMatched) {
var matched = ancMatched || (nf && nameHit(folder.path || folder.name)); var matched = ancMatched || (nf && nameHit(folder.path || folder.name));
var show = false; var show = false, hasFile = false;
(folder.children || []).forEach(function (ch) { if (walk(ch, matched)) show = true; }); (folder.children || []).forEach(function (ch) {
var r = walk(ch, matched);
if (r.show) show = true;
if (r.hasFile) hasFile = true;
});
(folder.files || []).forEach(function (f) { (folder.files || []).forEach(function (f) {
hasFile = true;
if (!classifyAllows(f)) return; if (!classifyAllows(f)) return;
if (!nf || matched || nameHit(c.srcKeyForFile(f))) { files[c.srcKeyForFile(f)] = true; show = true; } if (!nf || matched || nameHit(c.srcKeyForFile(f))) { files[c.srcKeyForFile(f)] = true; show = true; }
}); });
if (matched) show = true; 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; if (show) folders[folder.path] = true;
return show; return { show: show, hasFile: hasFile };
} }
(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 };
@ -610,9 +619,10 @@
* Update selected folders count * Update selected folders count
*/ */
function updateSelectedCount() { function updateSelectedCount() {
const el = window.app.dom.selectedFoldersCount;
if (!el) return; // count no longer shown in the folder-tree header
const count = window.app.selectedFolders.size; const count = window.app.selectedFolders.size;
window.app.dom.selectedFoldersCount.textContent = el.textContent = `${count} folder${count !== 1 ? 's' : ''} selected`;
`${count} folder${count !== 1 ? 's' : ''} selected`;
} }
/** /**

View file

@ -61,23 +61,27 @@
<input type="checkbox" id="hideCompliantCheckbox"> <input type="checkbox" id="hideCompliantCheckbox">
Hide Compliant Hide Compliant
</label> </label>
<span id="classifyFilters" class="classify-filters" hidden>
<label class="checkbox-label" title="Show files not yet assigned in the active tab">
<input type="checkbox" id="showUnassignedCheckbox" checked>
Show Unassigned <span class="filter-count" id="showUnassignedCount"></span>
</label>
<label class="checkbox-label" title="Show files already assigned in the active tab">
<input type="checkbox" id="showAssignedCheckbox" checked>
Show Assigned <span class="filter-count" id="showAssignedCount"></span>
</label>
<label class="checkbox-label" title="Show excluded files">
<input type="checkbox" id="showExcludedCheckbox" checked>
Show Excluded <span class="filter-count" id="showExcludedCount"></span>
</label>
</span>
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div> </div>
</div> </div>
<div id="classifyFilters" class="classify-filters tree-toolbar" hidden>
<span class="tree-toolbar__label">Show</span>
<label class="checkbox-label" title="Files not yet assigned in the active tab">
<input type="checkbox" id="showUnassignedCheckbox" checked>
Unassigned <span class="filter-count" id="showUnassignedCount"></span>
</label>
<label class="checkbox-label" title="Files already assigned in the active tab">
<input type="checkbox" id="showAssignedCheckbox" checked>
Assigned <span class="filter-count" id="showAssignedCount"></span>
</label>
<label class="checkbox-label" title="Excluded files">
<input type="checkbox" id="showExcludedCheckbox" checked>
Excluded <span class="filter-count" id="showExcludedCount"></span>
</label>
<label class="checkbox-label" title="Folders that contain no files">
<input type="checkbox" id="showEmptyCheckbox" checked>
Empty
</label>
</div>
<input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false" <input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false"
placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files"> placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files">
<div id="folderTree" class="folder-tree"> <div id="folderTree" class="folder-tree">

View file

@ -742,3 +742,21 @@ test('tracking-tree filter reveals matching nodes and hides the rest', async ({
expect(names).toContain('0001'); expect(names).toContain('0001');
expect(names).not.toContain('XYZ'); expect(names).not.toContain('XYZ');
}); });
test('Show Empty off hides folders that contain no files', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [
{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [],
files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' }] },
{ name: 'EmptyDir', path: 'EmptyDir', expanded: true, scanState: 'done', children: [], files: [] },
];
window.app.modules.tree.render();
const before = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort();
window.app.modules.tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
const after = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort();
return { before, after };
});
expect(r.before).toEqual(['Docs', 'EmptyDir']); // both shown by default
expect(r.after).toEqual(['Docs']); // empty folder hidden when Show Empty off
});