feat(browse): vendored JSZip, SVG home icon, auto-filter rows

- Vendor JSZip locally (shared/vendor/jszip.min.js) and bundle into
  the browse build instead of CDN-loading. Eliminates the failure
  mode where ZIP rows can't expand because the CDN script doesn't
  load (CSP, network, etc.). Tool now works fully offline.
- Replace the toolbar filter input + ext multi-select with two
  spreadsheet-style auto-filter rows in <thead>:
    - 📄 row: file-name filter + extension filter
    - 📁 row: folder-name filter
  Each input uses shared/zddc-filter syntax (substring/!negate/
  ^startsWith/$endsWith/regex/| or/space and).
- New visibility model with ancestor-of-match awareness:
    - file matches keep their ancestor folders visible (path-to-hit)
    - folder match keeps its descendants visible
    - filters compose (file ∧ folder ∧ ext) so combinations narrow
  Computed model-side; render walks only visible nodes.
- Replace 🏠 emoji breadcrumb-root with an inline outline-stroke SVG
  that tints with currentColor.
This commit is contained in:
ZDDC 2026-05-03 21:35:15 -05:00
parent 6e80e2bf12
commit 582db6d86d
8 changed files with 292 additions and 144 deletions

View file

@ -26,9 +26,13 @@ concat_files \
# JS files: shared canonical helpers, then browse modules. # JS files: shared canonical helpers, then browse modules.
# init.js must come first so window.app exists when later modules # init.js must come first so window.app exists when later modules
# attach to it. # attach to it. JSZip is vendored (rather than CDN-loaded) so ZIP
# expansion in the tree works under restrictive networks / CSPs and
# without an external HTTP dependency.
concat_files \ concat_files \
"../shared/vendor/jszip.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \
"../shared/theme.js" \ "../shared/theme.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"js/init.js" \ "js/init.js" \

View file

@ -72,36 +72,16 @@
} }
.breadcrumbs .bc-root { .breadcrumbs .bc-root {
font-size: 1rem; /* the 🏠 emoji renders a hair bigger */ display: inline-flex;
align-items: center;
line-height: 1; line-height: 1;
} }
.toolbar__filter { .bc-home-icon {
width: 22rem; width: 1rem;
max-width: 100%; height: 1rem;
padding: 0.3rem 0.6rem; display: block;
border: 1px solid var(--border); color: currentColor;
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 0.9rem;
}
.toolbar__ext {
/* Multi-select extension filter. Native <select multiple> is
intentionally compact most folders have a small set of
extensions, and we surface the list dynamically from the
loaded view. */
min-width: 8rem;
max-width: 14rem;
height: auto;
padding: 0.2rem 0.4rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 0.85rem;
font-family: Consolas, Monaco, monospace;
} }
.toolbar__count { .toolbar__count {
@ -110,6 +90,58 @@
white-space: nowrap; white-space: nowrap;
} }
/* Auto-filter rows in <thead>. Two rows one targets file rows
(📄 icon, with file-name + ext inputs), one targets folder rows
(📁 icon, with folder-name input). The icons make it visually
obvious which row controls which kind of filter. The rows are
non-sticky (only the sortable header row sticks) keeps the
stack-positioning math out of the picture and accepts that
filters scroll out of view on long lists. */
.browse-table thead .filter-row th {
position: static;
padding: 0.25rem 0.6rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
cursor: default;
font-weight: normal;
z-index: 0;
}
.browse-table thead .filter-row th:hover {
background: var(--bg-secondary);
}
.filter-row__icon {
display: inline-block;
width: 1.2rem;
text-align: center;
margin-right: 0.3rem;
vertical-align: middle;
font-size: 0.95rem;
color: var(--text-muted);
}
.column-filter {
width: calc(100% - 1.5rem);
padding: 0.2rem 0.4rem;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg);
color: var(--text);
font-size: 0.8rem;
font-family: Consolas, Monaco, monospace;
box-sizing: border-box;
}
.filter-row th.col-name .column-filter {
width: calc(100% - 1.7rem); /* leave space for the icon */
}
.column-filter:focus {
outline: 1px solid var(--primary);
outline-offset: -1px;
}
/* Subtle button variant used for "Select Directory" when the page /* Subtle button variant used for "Select Directory" when the page
is server-backed (the user usually doesn't need to switch to a is server-backed (the user usually doesn't need to switch to a
local folder; we keep the option visible but quiet). */ local folder; we keep the option visible but quiet). */
@ -266,5 +298,3 @@
padding: 0.5rem 1rem 0.5rem calc(0.75rem + 2.4rem); padding: 0.5rem 1rem 0.5rem calc(0.75rem + 2.4rem);
} }
/* When filter hides a row */
.tree-row--filtered { display: none; }

View file

@ -122,24 +122,17 @@
var refresh = document.getElementById('refreshHeaderBtn'); var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing); if (refresh) refresh.addEventListener('click', refreshListing);
// Filter input // Auto-filter row inputs. There are three of them (file, folder,
var filter = document.getElementById('filterInput'); // ext) — wire each by its `data-filter` attribute. Idempotent
if (filter) { // re: re-init.
filter.addEventListener('input', function () { var filterInputs = document.querySelectorAll('input.column-filter[data-filter]');
tree.setFilter(filter.value); for (var fi = 0; fi < filterInputs.length; fi++) {
}); (function (input) {
} var which = input.dataset.filter;
input.addEventListener('input', function () {
// Extension multi-select tree.setFilter(which, input.value);
var extSel = document.getElementById('extFilter');
if (extSel) {
extSel.addEventListener('change', function () {
var picked = [];
for (var i = 0; i < extSel.options.length; i++) {
if (extSel.options[i].selected) picked.push(extSel.options[i].value);
}
tree.setExtFilter(picked);
}); });
})(filterInputs[fi]);
} }
// Sort headers // Sort headers

View file

@ -25,12 +25,14 @@
// Sort state. key: 'name' | 'size' | 'ext' | 'date'. dir: 1 or -1. // Sort state. key: 'name' | 'size' | 'ext' | 'date'. dir: 1 or -1.
sort: { key: 'name', dir: 1 }, sort: { key: 'name', dir: 1 },
// Current filter substring (lowercase). // Auto-filter row state. Each is a raw string from the input,
filterText: '', // plus a parsed AST (zddc.filter.parse) cached on every keystroke.
// Empty raw → AST empty → matches everything.
// Selected extensions (Set of lowercase strings, no leading filters: {
// dot). Empty set = no extension filtering. file: { raw: '', ast: null }, // matches against file basename
extFilter: new Set(), folder: { raw: '', ast: null }, // matches against folder basename
ext: { raw: '', ast: null } // matches against file extension
},
// The tree's in-memory representation. Each node: // The tree's in-memory representation. Each node:
// { id, name, isDir, size, modTime, ext, url, depth, // { id, name, isDir, size, modTime, ext, url, depth,

View file

@ -120,26 +120,16 @@
} }
} }
// CDN library loader. Idempotent — multiple callers share the // JSZip is vendored into the bundle (shared/vendor/jszip.min.js
// same in-flight Promise. Used by ZIP expansion + the file // is concatenated ahead of init.js by build.sh), so it's always
// preview popup. // already attached to window.JSZip by the time any tree code runs.
var libCache = new Map(); // We keep the helper because tree.js calls it before reaching for
function loadScript(url) { // window.JSZip; if the bundle is ever rebuilt without the vendor
if (libCache.has(url)) return libCache.get(url); // copy this will throw a clear error rather than silently failing.
var p = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = url;
s.onload = function () { resolve(); };
s.onerror = function () { reject(new Error('Failed to load: ' + url)); };
document.head.appendChild(s);
});
libCache.set(url, p);
return p;
}
function ensureJSZip() { function ensureJSZip() {
if (window.JSZip) return Promise.resolve(); if (window.JSZip) return Promise.resolve();
return loadScript('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js'); return Promise.reject(new Error(
'JSZip not bundled — rebuild browse with shared/vendor/jszip.min.js'));
} }
// Public API // Public API
@ -148,7 +138,6 @@
fetchFsChildren: fetchFsChildren, fetchFsChildren: fetchFsChildren,
autoDetectServerMode: autoDetectServerMode, autoDetectServerMode: autoDetectServerMode,
splitExt: splitExt, splitExt: splitExt,
ensureJSZip: ensureJSZip, ensureJSZip: ensureJSZip
loadScript: loadScript
}; };
})(); })();

View file

@ -102,13 +102,18 @@
parent.loaded = true; parent.loaded = true;
} }
// Walk visible nodes in render order. // Walk visible nodes in render order. Excludes nodes whose
// node.visible is false (filter-hidden) and skips the children of
// a collapsed expandable. Filter visibility is computed by
// recomputeVisibility() before this is called from render().
function visibleIds() { function visibleIds() {
var out = []; var out = [];
function walk(ids) { function walk(ids) {
for (var i = 0; i < ids.length; i++) { for (var i = 0; i < ids.length; i++) {
out.push(ids[i]);
var n = state.nodes.get(ids[i]); var n = state.nodes.get(ids[i]);
if (!n) continue;
if (n.visible === false) continue;
out.push(ids[i]);
if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds); if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds);
} }
} }
@ -187,49 +192,162 @@
function render() { function render() {
var tbody = document.getElementById('browseTbody'); var tbody = document.getElementById('browseTbody');
if (!tbody) return; if (!tbody) return;
recomputeVisibility();
var ids = visibleIds(); var ids = visibleIds();
var html = ''; var html = '';
for (var i = 0; i < ids.length; i++) { for (var i = 0; i < ids.length; i++) {
html += rowHtml(state.nodes.get(ids[i])); html += rowHtml(state.nodes.get(ids[i]));
} }
tbody.innerHTML = html; tbody.innerHTML = html;
applyFilter();
updateCount(); updateCount();
updateSortHeaders(); updateSortHeaders();
renderBreadcrumbs(); renderBreadcrumbs();
renderExtFilter();
} }
// Filter is purely DOM-level: hide rows whose name doesn't match // Compute model-level visibility per node based on the three
// and (if any extensions are selected) whose ext isn't in the set. // filter ASTs:
// Cheap, immediate, no model rebuild. // - fileFilter → matches a file's basename
function applyFilter() { // - folderFilter→ matches a folder's basename
var f = state.filterText; // - extFilter → matches a file's extension (no leading dot)
var ef = state.extFilter; //
var rows = document.querySelectorAll('#browseTbody tr.tree-row'); // Visibility rules:
for (var i = 0; i < rows.length; i++) { // 1. A FILE is "self-matched" when it passes both file+ext filter.
var row = rows[i]; // 2. A FOLDER is "self-matched" when it passes the folder filter.
var n = state.nodes.get(parseInt(row.dataset.id, 10)); // 3. A file is "in-scope" when either no folder filter is active,
if (!n) continue; // OR at least one ancestor folder is folder-self-matched.
var nameMatch = !f || n.name.toLowerCase().indexOf(f) !== -1; // 4. A file is VISIBLE when self-matched AND in-scope.
var extMatch = !ef.size || n.isDir || ef.has(n.ext); // 5. A folder is VISIBLE when:
row.classList.toggle('tree-row--filtered', !(nameMatch && extMatch)); // - any descendant is visible (so the path to a hit is
// always shown), OR
// - the folder itself is folder-self-matched AND no file
// filter is active (when a file filter is set, we hide
// folders that have no matching files inside — keeps
// the result list focused).
//
// Pure model walk; the renderer just consumes node.visible. Hidden
// expandable nodes get their `expanded` flag respected even though
// they're not in the DOM, so toggling filters preserves the user's
// expand state.
function recomputeVisibility() {
var fileAst = state.filters.file.ast;
var folderAst = state.filters.folder.ast;
var extAst = state.filters.ext.ast;
var hasFile = !!(state.filters.file.raw);
var hasFolder = !!(state.filters.folder.raw);
var hasExt = !!(state.filters.ext.raw);
var anyActive = hasFile || hasFolder || hasExt;
// Fast path: nothing filtered → everything visible.
if (!anyActive) {
state.nodes.forEach(function (n) { n.visible = true; });
return;
} }
var f = window.zddc && window.zddc.filter;
// Walk top-down to propagate folder scope, then bottom-up to
// propagate descendant visibility. Done in one DFS recursion.
// ZIPs are hybrids — they match FILE filter (their name is a
// filename) AND can be matched by FOLDER filter (they're
// container-like — clicking expands them like a folder).
function visit(nodeId, ancestorMatchesFolder) {
var n = state.nodes.get(nodeId);
if (!n) return false;
if (!(n.isDir || n.isZip)) {
// Plain file. Visible iff its name+ext pass file/ext
// filters AND it's inside the folder-filter scope.
var nameOk = f.matches(n.name, fileAst);
var extOk = f.matches(n.ext || '', extAst);
n.visible = nameOk && extOk && ancestorMatchesFolder;
return n.visible;
}
// Folder or zip — has childIds and contributes to scope.
// Folder self-match: the folder/zip name passes folder
// filter. A folder match also opens the file-filter scope
// for descendants.
var asFolderMatch = f.matches(n.name, folderAst);
// A zip can also match the FILE filter (it's a file too).
// Typing a zip name into file filter surfaces the zip.
// Gate on hasFile||hasExt — when neither is active, the
// empty filter matches every name and would falsely
// surface every zip regardless of the active folder filter.
var asFileMatch = n.isZip
&& (hasFile || hasExt)
&& f.matches(n.name, fileAst)
&& f.matches(n.ext || '', extAst);
var nextAncestorScope = ancestorMatchesFolder
|| asFolderMatch || asFileMatch;
var anyChildVisible = false;
for (var i = 0; i < n.childIds.length; i++) {
if (visit(n.childIds[i], nextAncestorScope)) anyChildVisible = true;
}
// Visible if:
// - any descendant is visible (path-to-hit visibility), or
// - self-folder-match with no file/ext filter active
// (let the folder surface even if it's empty/unloaded), or
// - self-file-match (for zips, where the user is searching
// for the archive by name in the file filter).
n.visible = anyChildVisible
|| (asFolderMatch && !hasFile && !hasExt)
|| asFileMatch;
return n.visible;
}
// Initial ancestor scope = folder filter empty (so files don't
// require ancestor matches when there's no folder filter).
var initialScope = !hasFolder;
for (var i = 0; i < state.rootIds.length; i++) {
visit(state.rootIds[i], initialScope);
}
}
// Count nodes that would render if no filter were active
// (i.e. anything at the root, or under an expanded ancestor).
// Used to express "<visible> of <total> shown" while a filter is on.
function expandedSetSize() {
var n = 0;
function walk(ids) {
for (var i = 0; i < ids.length; i++) {
n++;
var node = state.nodes.get(ids[i]);
if (node && (node.isDir || node.isZip) && node.expanded) {
walk(node.childIds);
}
}
}
walk(state.rootIds);
return n;
} }
function updateCount() { function updateCount() {
var el = document.getElementById('entryCount'); var el = document.getElementById('entryCount');
if (!el) return; if (!el) return;
var rows = document.querySelectorAll('#browseTbody tr.tree-row:not(.tree-row--filtered)'); var visible = visibleIds().length;
var total = document.querySelectorAll('#browseTbody tr.tree-row').length; var total = expandedSetSize();
var anyFilter = state.filterText || state.extFilter.size; var anyFilter = state.filters.file.raw
|| state.filters.folder.raw
|| state.filters.ext.raw;
el.textContent = anyFilter el.textContent = anyFilter
? rows.length + ' of ' + total + ' shown' ? visible + ' of ' + total + ' shown'
: total + ' item' + (total === 1 ? '' : 's'); : total + ' item' + (total === 1 ? '' : 's');
} }
// ── Breadcrumbs ────────────────────────────────────────────────────── // ── Breadcrumbs ──────────────────────────────────────────────────────
// Inline outline home icon. Stroke-based so it tints with the
// current text color rather than depending on emoji rendering.
var HOME_SVG = '<svg class="bc-home-icon" xmlns="http://www.w3.org/2000/svg" '
+ 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M3 12l9-9 9 9"/>'
+ '<path d="M5 10v10h14V10"/>'
+ '<path d="M10 20v-6h4v6"/></svg>';
function renderBreadcrumbs() { function renderBreadcrumbs() {
var el = document.getElementById('breadcrumbs'); var el = document.getElementById('breadcrumbs');
if (!el) return; if (!el) return;
@ -240,7 +358,8 @@
// the new instance auto-loads that directory's listing. // the new instance auto-loads that directory's listing.
var path = state.currentPath || '/'; var path = state.currentPath || '/';
var parts = path.split('/').filter(Boolean); var parts = path.split('/').filter(Boolean);
html += '<a class="bc-link bc-root" href="/" title="Site root">🏠</a>'; html += '<a class="bc-link bc-root" href="/" title="Site root">'
+ HOME_SVG + '</a>';
var sofar = ''; var sofar = '';
for (var i = 0; i < parts.length; i++) { for (var i = 0; i < parts.length; i++) {
sofar += '/' + parts[i]; sofar += '/' + parts[i];
@ -258,9 +377,10 @@
} else if (state.source === 'fs') { } else if (state.source === 'fs') {
// FS-API mode: ancestor handles weren't retained when the // FS-API mode: ancestor handles weren't retained when the
// user picked the root, so we can't navigate up. Show the // user picked the root, so we can't navigate up. Show the
// root as 🏠 + handle name without links. // root icon + handle name without links.
var name = state.rootHandle ? state.rootHandle.name : ''; var name = state.rootHandle ? state.rootHandle.name : '';
html += '<span class="bc-link bc-root" title="Local directory">🏠</span>'; html += '<span class="bc-link bc-root" title="Local directory">'
+ HOME_SVG + '</span>';
if (name) { if (name) {
html += '<span class="bc-sep">/</span>'; html += '<span class="bc-sep">/</span>';
html += '<span class="bc-link bc-link--current">' + escapeHtml(name) + '</span>'; html += '<span class="bc-link bc-link--current">' + escapeHtml(name) + '</span>';
@ -270,33 +390,6 @@
el.innerHTML = html; el.innerHTML = html;
} }
// ── Extension filter ─────────────────────────────────────────────────
function renderExtFilter() {
var sel = document.getElementById('extFilter');
if (!sel) return;
// Collect unique extensions from currently-loaded nodes (any
// depth). Folders excluded. Empty-string ext omitted (no-ext
// files would be filtered out by selecting any other ext).
var exts = new Set();
state.nodes.forEach(function (n) {
if (!n.isDir && n.ext) exts.add(n.ext);
});
var sorted = Array.from(exts).sort();
// Preserve current selection when re-rendering after expand.
var selected = state.extFilter;
var html = '';
for (var i = 0; i < sorted.length; i++) {
var e = sorted[i];
var isSel = selected.has(e) ? ' selected' : '';
html += '<option value="' + escapeHtml(e) + '"' + isSel + '>'
+ escapeHtml(e) + '</option>';
}
sel.innerHTML = html;
// Size to fit content — multi-selects can be cramped otherwise.
sel.size = Math.min(Math.max(sorted.length, 2), 6);
}
function updateSortHeaders() { function updateSortHeaders() {
var ths = document.querySelectorAll('#browseTable thead th.sortable'); var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) { for (var i = 0; i < ths.length; i++) {
@ -466,7 +559,7 @@
if (!n || !(n.isDir || n.isZip)) return; if (!n || !(n.isDir || n.isZip)) return;
if (!n.expanded && !n.loaded) { if (!n.expanded && !n.loaded) {
await loadChildren(n); await loadChildren(n);
if (!n.loaded) return; // load failed if (!n.loaded) return; // load failed (statusError already set)
} }
n.expanded = !n.expanded; n.expanded = !n.expanded;
render(); render();
@ -554,17 +647,16 @@
} }
render(); render();
}, },
setFilter: function (s) { // Update one of the three column filters and re-render. `which`
state.filterText = (s || '').toLowerCase(); // is 'file' | 'folder' | 'ext'. Empty raw → AST cleared.
applyFilter(); setFilter: function (which, raw) {
updateCount(); var slot = state.filters[which];
}, if (!slot) return;
setExtFilter: function (extArr) { slot.raw = raw || '';
state.extFilter = new Set((extArr || []).map(function (e) { slot.ast = slot.raw && window.zddc && window.zddc.filter
return String(e).toLowerCase().replace(/^\./, ''); ? window.zddc.filter.parse(slot.raw)
})); : null;
applyFilter(); render();
updateCount();
}, },
pathFor: pathFor pathFor: pathFor
}; };

View file

@ -47,28 +47,53 @@
</ul> </ul>
<p>Once loaded: click a folder to expand it, <b>shift-click</b> <p>Once loaded: click a folder to expand it, <b>shift-click</b>
to expand its entire subtree (or collapse it again), to expand its entire subtree (or collapse it again),
click column headers to sort, type in the filter to narrow click column headers to sort. Use the 📄 row to filter files
by name. Click any file to open it.</p> (and the 📁 row to scope to matching folders) — file matches
stay visible together with their containing folders.
Click any file to open it.</p>
</div> </div>
</div> </div>
<div id="browseRoot" class="browse-root hidden"> <div id="browseRoot" class="browse-root hidden">
<div class="toolbar"> <div class="toolbar">
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav> <nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
<input type="search" id="filterInput" class="toolbar__filter"
placeholder="Filter by name (substring)..." />
<select id="extFilter" class="toolbar__ext" multiple aria-label="Filter by extension"></select>
<span class="toolbar__count" id="entryCount"></span> <span class="toolbar__count" id="entryCount"></span>
</div> </div>
<div class="browse-table-wrap"> <div class="browse-table-wrap">
<table class="browse-table" id="browseTable"> <table class="browse-table" id="browseTable">
<thead> <thead>
<tr> <tr class="header-row">
<th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th> <th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th>
<th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th> <th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th>
<th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th> <th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th>
<th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th> <th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th>
</tr> </tr>
<tr class="filter-row filter-row--file" title="Filter files">
<th class="col-name">
<span class="filter-row__icon" aria-hidden="true">📄</span>
<input type="text" class="column-filter" data-filter="file"
placeholder="filter files…" spellcheck="false"
aria-label="Filter by file name">
</th>
<th class="col-size"></th>
<th class="col-ext">
<input type="text" class="column-filter" data-filter="ext"
placeholder="ext…" spellcheck="false"
aria-label="Filter by extension">
</th>
<th class="col-date"></th>
</tr>
<tr class="filter-row filter-row--folder" title="Filter folders">
<th class="col-name">
<span class="filter-row__icon" aria-hidden="true">📁</span>
<input type="text" class="column-filter" data-filter="folder"
placeholder="filter folders…" spellcheck="false"
aria-label="Filter by folder name">
</th>
<th class="col-size"></th>
<th class="col-ext"></th>
<th class="col-date"></th>
</tr>
</thead> </thead>
<tbody id="browseTbody"></tbody> <tbody id="browseTbody"></tbody>
</table> </table>

13
shared/vendor/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long