Compare commits

..

2 commits

Author SHA1 Message Date
d874643af5 release: v0.0.14 lockstep
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 3s
Build + deploy releases / build-and-deploy (push) Successful in 8s
Build + deploy releases / notify-chart-prod (push) Successful in 1s
2026-05-03 20:40:02 -05:00
424bf8e769 feat(browse): Phase 2 — preview popup, ZIP expansion, ext filter, breadcrumbs
Bundles Phase 2 polish + the user-requested header/breadcrumb work:

- Breadcrumbs replacing the plain currentPath span. Server mode
  renders linkified ancestor segments (each <a> navigates to that
  directory; the browser fetches browse.html, the new instance
  auto-loads the listing). FS-API mode renders the rootHandle name
  as a non-link (no ancestor handles to navigate). Both prefix the
  path with a 🏠 root icon. Trailing slash + bold-current segment
  match common file-explorer conventions.

- Subdued 'Select Directory' button in server mode. Once browse is
  serving a real directory listing, the local-folder switcher is
  available but visually quiet (btn--subtle: transparent, muted
  color). FS-API mode keeps the primary styling (it's how the user
  got there). New btn--subtle CSS class added to browse's tree.css.
  A refresh button (⟳) appears next to it in both modes; clicking
  it re-fetches the current root listing.

- Header consistency: browse now matches archive's header layout
  (refresh + help buttons in addition to theme on the right). Help
  is a placeholder for future help dialog wiring.

- File preview popup. Click a file row → opens a popup window with
  the file rendered. Plain types (PDF, HTML, image) load in
  iframes; TIFF + ZIP listings via shared/preview-lib.js's
  renderTiff / renderZipListing helpers; text via <pre>; unknown
  types → 'click Download' placeholder. Modifier-click (ctrl/cmd/
  shift) and middle-click still open the file in a new tab via the
  underlying <a target=_blank>. Single popup window is reused
  across multiple file clicks (matches archive's UX).

- ZIP inline expansion. .zip files have a chevron and act like
  folders in the tree. First expand fetches the zip bytes
  (server URL or FS handle or parent-zip read), parses with JSZip
  (auto-loaded from CDN), and synthesizes the entry tree. Nested
  directories within the zip lazy-expand on demand by re-walking
  the cached entry list at the right path prefix. Click on a
  zip-entry file opens the preview popup with bytes read from
  JSZip. Recursive expand-all skips zip archives by design — they
  can be very large, and explicit click-to-expand is safer.

- Extension multi-select filter. Toolbar now has a <select
  multiple> populated with extensions present in the current
  view. Filter is OR-of-selected; combined with the name filter
  it's AND-of-both. Folders pass through (so expanding a folder
  whose name doesn't match the ext filter still shows its file
  children that do match).
2026-05-03 20:39:49 -05:00
18 changed files with 2035 additions and 141 deletions

View file

@ -30,9 +30,11 @@ concat_files \
concat_files \
"../shared/zddc.js" \
"../shared/theme.js" \
"../shared/preview-lib.js" \
"js/init.js" \
"js/loader.js" \
"js/tree.js" \
"js/preview.js" \
"js/events.js" \
"js/app.js" \
> "$js_raw"

View file

@ -25,15 +25,55 @@
flex-wrap: wrap;
}
.toolbar__path {
/* Breadcrumb path. The root node is a 🏠 link to "/" (online) or
the FS handle name (offline). Each segment is a clickable link in
server mode that re-navigates the browser; in FS-API mode they
render as plain spans because we don't keep ancestor handles. */
.breadcrumbs {
flex: 1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
font-family: Consolas, Monaco, monospace;
font-size: 0.9rem;
color: var(--text-muted);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0.1rem 0;
/* Hide the scrollbar but keep horizontal scroll for very deep paths */
scrollbar-width: thin;
}
.breadcrumbs .bc-link {
color: var(--primary);
text-decoration: none;
padding: 0.1rem 0.25rem;
border-radius: 3px;
}
.breadcrumbs .bc-link:hover {
background: var(--bg-hover, rgba(0,0,0,0.05));
text-decoration: underline;
}
.breadcrumbs .bc-link--current {
color: var(--text);
font-weight: 500;
cursor: default;
}
.breadcrumbs .bc-link--current:hover {
background: transparent;
text-decoration: none;
}
.breadcrumbs .bc-sep {
color: var(--text-muted);
margin: 0 0.05rem;
}
.breadcrumbs .bc-root {
font-size: 1rem; /* the 🏠 emoji renders a hair bigger */
line-height: 1;
}
.toolbar__filter {
@ -47,12 +87,45 @@
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 {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
/* Subtle button variant used for "Select Directory" when the page
is server-backed (the user usually doesn't need to switch to a
local folder; we keep the option visible but quiet). */
.btn.btn--subtle {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
box-shadow: none;
font-weight: normal;
}
.btn.btn--subtle:hover {
color: var(--text);
background: var(--bg-hover, rgba(0,0,0,0.04));
}
/* Table — folders + files in a tree */
.browse-table {

View file

@ -19,7 +19,6 @@
if (detected) {
tree.setRoot(detected.entries);
events.showBrowseRoot();
document.getElementById('currentPath').textContent = detected.path;
tree.render();
events.statusInfo('Loaded ' + detected.entries.length + ' item'
+ (detected.entries.length === 1 ? '' : 's')

View file

@ -6,6 +6,9 @@
var state = window.app.state;
var tree = window.app.modules.tree;
var loader = window.app.modules.loader;
// preview module is loaded later (concat order); look it up at
// call time, not at IIFE-eval time.
function previewMod() { return window.app.modules.preview; }
function status(msg, kind) {
var el = document.getElementById('statusBar');
@ -44,7 +47,6 @@
}
tree.setRoot(raw);
showBrowseRoot();
document.getElementById('currentPath').textContent = state.currentPath;
tree.render();
statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
}
@ -54,6 +56,62 @@
var root = document.getElementById('browseRoot');
if (empty) empty.classList.add('hidden');
if (root) root.classList.remove('hidden');
applySourceUI();
}
// Visual state of the "Select Directory" button + the refresh
// button depends on the source. In server mode the user is
// already viewing a server-backed listing — Select Directory
// becomes a quiet "switch to local" affordance (subtle styling),
// and the refresh button is shown. In FS mode the button is
// primary (it's how you got here) and refresh is hidden (the
// listing was already a fresh enumeration).
function applySourceUI() {
var add = document.getElementById('addDirectoryBtn');
var refresh = document.getElementById('refreshHeaderBtn');
if (add) {
if (state.source === 'server') {
add.classList.remove('btn-primary');
add.classList.add('btn--subtle');
} else {
add.classList.add('btn-primary');
add.classList.remove('btn--subtle');
}
}
if (refresh) {
if (state.source) {
refresh.classList.remove('hidden');
} else {
refresh.classList.add('hidden');
}
}
}
async function refreshListing() {
if (state.source === 'server') {
var raw;
try {
raw = await loader.fetchServerChildren(state.currentPath);
} catch (e) {
statusError('Refresh failed: ' + e.message);
return;
}
tree.setRoot(raw);
tree.render();
statusInfo('Refreshed (' + raw.length + ' item'
+ (raw.length === 1 ? '' : 's') + ')');
} else if (state.source === 'fs' && state.rootHandle) {
var raw2;
try {
raw2 = await loader.fetchFsChildren(state.rootHandle);
} catch (e) {
statusError('Refresh failed: ' + e.message);
return;
}
tree.setRoot(raw2);
tree.render();
statusInfo('Refreshed');
}
}
function init() {
@ -61,6 +119,9 @@
var btn = document.getElementById('addDirectoryBtn');
if (btn) btn.addEventListener('click', pickLocalDir);
var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing);
// Filter input
var filter = document.getElementById('filterInput');
if (filter) {
@ -69,6 +130,18 @@
});
}
// Extension multi-select
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);
});
}
// Sort headers
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
@ -96,20 +169,45 @@
tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr.tree-row');
if (!row) return;
var isDir = row.dataset.isdir === 'true';
if (!isDir) return;
e.preventDefault();
var id = parseInt(row.dataset.id, 10);
if (e.shiftKey || e.altKey) {
var node = state.nodes.get(id);
if (node && node.expanded) {
tree.collapseSubtree(id);
var node = state.nodes.get(id);
if (!node) return;
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
var clickedChevron = !!e.target.closest('.tree-name__chevron');
if (isExpandable) {
// For folders + zips: click anywhere on the row
// toggles. Modifier-click → recursive expand.
e.preventDefault();
if (e.shiftKey || e.altKey) {
if (node.expanded) tree.collapseSubtree(id);
else tree.expandSubtree(id);
} else {
tree.expandSubtree(id);
tree.toggleFolder(id);
}
} else {
tree.toggleFolder(id);
return;
}
// Plain file row.
// Modifier-click (ctrl/cmd) and middle-click → fall
// through to the <a> tag's natural target=_blank
// behavior (open in new tab). For server-backed
// files, that opens the real URL via zddc-server.
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
return;
}
// Plain click → preview popup. Intercept default nav.
e.preventDefault();
var p = previewMod();
if (p) p.showFilePreview(node);
});
// Middle-click (auxclick) — same fall-through logic.
tbody.addEventListener('auxclick', function (e) {
if (e.button !== 1) return; // middle only
// Browser handles target=_blank natively for middle
// click; don't preventDefault, just don't intercept.
});
}
}

View file

@ -28,13 +28,28 @@
// Current filter substring (lowercase).
filterText: '',
// Selected extensions (Set of lowercase strings, no leading
// dot). Empty set = no extension filtering.
extFilter: new Set(),
// The tree's in-memory representation. Each node:
// { id, name, isDir, size, modTime, ext, url, depth,
// parentId, expanded, loaded, childIds }
// parentId, expanded, loaded, childIds, isZip, zipFile,
// zipPath }
// - isZip: set when the node IS a .zip file we know how to
// expand inline (server file or FS handle).
// - zipFile: cached JSZip instance for this archive (set
// after first expand).
// - zipPath: relative path WITHIN a zip (set on virtual
// children of an expanded zip; null otherwise).
// Stored flat in a Map keyed by id; render order derived
// from a depth-first walk.
nodes: new Map(),
rootIds: [],
nextId: 1
nextId: 1,
// Single shared popup window for file preview (across
// multiple file clicks). Same pattern as archive's preview.
previewWindow: null
};
})();

View file

@ -120,11 +120,35 @@
}
}
// CDN library loader. Idempotent — multiple callers share the
// same in-flight Promise. Used by ZIP expansion + the file
// preview popup.
var libCache = new Map();
function loadScript(url) {
if (libCache.has(url)) return libCache.get(url);
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() {
if (window.JSZip) return Promise.resolve();
return loadScript('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js');
}
// Public API
window.app.modules.loader = {
fetchServerChildren: fetchServerChildren,
fetchFsChildren: fetchFsChildren,
autoDetectServerMode: autoDetectServerMode,
splitExt: splitExt
splitExt: splitExt,
ensureJSZip: ensureJSZip,
loadScript: loadScript
};
})();

239
browse/js/preview.js Normal file
View file

@ -0,0 +1,239 @@
// preview.js — file preview popup. Reuses shared/preview-lib.js for
// TIFF, ZIP listing, and image-rendering helpers; native iframe for
// PDF and HTML; <pre> for text; download button for everything else.
//
// Lifecycle: a single popup window is reused across multiple file
// clicks (state.previewWindow). Subsequent clicks rewrite its
// contents instead of spawning a new window — same UX as the archive
// tool.
(function () {
'use strict';
var state = window.app.state;
var loader = window.app.modules.loader;
var preview = window.zddc && window.zddc.preview;
if (!preview) {
// shared/preview-lib.js wasn't concatenated in. Bail loudly so
// the bug shows up in console rather than mysteriously failing.
console.error('[browse] zddc.preview not loaded — preview popup disabled.');
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var MIME = {
'pdf': 'application/pdf',
'html': 'text/html', 'htm': 'text/html',
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
'tif': 'image/tiff', 'tiff': 'image/tiff',
'zip': 'application/zip',
'txt': 'text/plain', 'md': 'text/markdown', 'json': 'application/json',
'xml': 'application/xml', 'csv': 'text/csv', 'log': 'text/plain',
'js': 'text/javascript', 'css': 'text/css'
};
// Pull bytes for a file node. Three sources:
// - server URL (zddc-server-backed file, including downloads
// of archived files served at real paths)
// - FS-API handle (local folder)
// - JSZip entry (file inside an expanded zip; reads from
// parent's cached JSZip instance)
async function getArrayBuffer(node) {
if (node.zipParentId != null) {
var owner = state.nodes.get(node.zipParentId);
if (!owner || !owner.zipFile) {
throw new Error('parent zip not loaded');
}
return await owner.zipFile.file(node.zipPath).async('arraybuffer');
}
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.arrayBuffer();
}
if (node.handle) {
var f = await node.handle.getFile();
return await f.arrayBuffer();
}
throw new Error('no source for file');
}
function getMime(ext) {
return MIME[ext] || 'application/octet-stream';
}
// Build a blob URL for the file's bytes. For server-mode regular
// files (not in a zip), prefer the live URL — relative links and
// server-side interception (e.g. .archive resolution) work then.
async function getBlobUrl(node) {
if (state.source === 'server' && node.url && node.zipParentId == null) {
return { url: node.url, fromServer: true };
}
var buf = await getArrayBuffer(node);
var blob = new Blob([buf], { type: getMime(node.ext) });
return { url: URL.createObjectURL(blob), fromServer: false };
}
function popupShell(node, primaryUrl) {
var safeName = escapeHtml(node.name);
var safeHref = escapeHtml(primaryUrl);
var ext = (node.ext || '').toLowerCase();
// Inline PDF and HTML previews load in iframes. HTML uses
// sandbox="allow-same-origin allow-popups
// allow-popups-to-escape-sandbox" — same posture as archive's
// preview: links navigate, scripts blocked, popups allowed.
var contentHtml;
if (ext === 'pdf') {
contentHtml = '<iframe src="' + safeHref + '"></iframe>';
} else if (ext === 'html' || ext === 'htm') {
contentHtml = '<iframe src="' + safeHref + '" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>';
} else if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
contentHtml = '<img class="preview-image" src="' + safeHref + '" alt="' + safeName + '">';
} else {
contentHtml = '<div id="previewContent"><div class="loading">Loading preview…</div></div>';
}
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
+ '<title>' + safeName + ' — preview</title><style>'
+ '*{margin:0;padding:0;box-sizing:border-box;}'
+ 'body{display:flex;flex-direction:column;height:100vh;'
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}'
+ '.toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;'
+ 'background:#f5f5f5;border-bottom:1px solid #ddd;}'
+ '.toolbar h1{flex:1;font-size:.95rem;font-weight:500;overflow:hidden;'
+ 'text-overflow:ellipsis;white-space:nowrap;}'
+ '.btn{padding:.4rem .8rem;font-size:.85rem;border:1px solid #ccc;'
+ 'border-radius:4px;background:white;cursor:pointer;}'
+ '.btn:hover{background:#e8e8e8;}'
+ 'iframe{flex:1;width:100%;border:none;}'
+ '#previewContent{flex:1;overflow:auto;display:flex;flex-direction:column;}'
+ '.loading{display:flex;align-items:center;justify-content:center;height:100%;'
+ 'color:#666;font-size:1.1rem;}'
+ 'img.preview-image{max-width:100%;max-height:100%;object-fit:contain;'
+ 'margin:auto;display:block;}'
+ 'pre.preview-text{padding:1rem;font-family:Consolas,Monaco,monospace;'
+ 'font-size:.85rem;white-space:pre-wrap;word-wrap:break-word;}'
+ '</style></head><body>'
+ '<div class="toolbar"><h1>' + safeName + '</h1>'
+ '<button class="btn" onclick="downloadFile()">Download</button></div>'
+ contentHtml
+ '<script>'
+ 'var blobUrl=' + JSON.stringify(primaryUrl) + ';'
+ 'var fileName=' + JSON.stringify(node.name) + ';'
+ 'function downloadFile(){var a=document.createElement("a");'
+ 'a.href=blobUrl;a.download=fileName;document.body.appendChild(a);'
+ 'a.click();document.body.removeChild(a);}'
+ '</' + 'script></body></html>';
}
async function renderTextInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c) return;
try {
var buf = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
var MAX = 200000;
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, '
+ (text.length - MAX) + ' more chars — Download for full file)';
}
var pre = win.document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
c.innerHTML = '';
c.appendChild(pre);
} catch (e) {
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
}
}
async function renderTiffInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c || !preview) return;
try {
var buf = await getArrayBuffer(node);
await preview.renderTiff(win.document, c, buf, { fileName: node.name });
} catch (e) {
c.innerHTML = '<div class="loading">Error rendering TIFF: '
+ escapeHtml(e.message || e) + '</div>';
}
}
async function renderZipInWindow(node, win) {
var c = win.document.getElementById('previewContent');
if (!c || !preview) return;
try {
var buf = await getArrayBuffer(node);
await preview.renderZipListing(win.document, c, buf, { fileName: node.name });
} catch (e) {
c.innerHTML = '<div class="loading">Error reading ZIP: '
+ escapeHtml(e.message || e) + '</div>';
}
}
async function showFilePreview(node) {
if (node.isDir) return;
var ext = (node.ext || '').toLowerCase();
var info;
try {
info = await getBlobUrl(node);
} catch (e) {
window.app.modules.events.statusError('Preview failed: ' + e.message);
return;
}
var html = popupShell(node, info.url);
var win = state.previewWindow;
if (win && !win.closed) {
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
} else {
var w = Math.round(screen.width * 0.6);
var h = Math.round(screen.height * 0.8);
var left = Math.round((screen.width - w) / 2);
var top = Math.round((screen.height - h) / 2);
win = window.open('', 'browseFilePreview',
'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top
+ ',resizable=yes,scrollbars=yes');
if (!win) {
// Popup blocked — fall back to opening the file directly.
window.open(info.url, '_blank', 'noopener');
return;
}
win.document.write(html);
win.document.close();
win.focus();
state.previewWindow = win;
}
// Async content rendering for the non-iframe types.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
return; // iframe wired in popupShell
}
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
return; // <img> wired in popupShell
}
if (preview && preview.isTiff(ext)) {
await renderTiffInWindow(node, win);
} else if (preview && preview.isZip(ext)) {
await renderZipInWindow(node, win);
} else if (preview && preview.isText(ext)) {
await renderTextInWindow(node, win);
} else {
// Unknown type — show a friendly "no preview, click
// download" placeholder.
var c = win.document.getElementById('previewContent');
if (c) {
c.innerHTML = '<div class="loading">No inline preview for .'
+ escapeHtml(ext) + ' — click Download.</div>';
}
}
}
window.app.modules.preview = { showFilePreview: showFilePreview };
})();

View file

@ -14,6 +14,10 @@
function newNode(raw, parentId, depth) {
var id = state.nextId++;
// ZIP files are treated as folders for tree purposes — the
// chevron lets the user expand them inline. The actual
// contents are loaded on first expand via JSZip.
var isZip = !raw.isDir && raw.ext === 'zip';
var node = {
id: id,
name: raw.name,
@ -27,7 +31,11 @@
parentId: parentId,
expanded: false,
loaded: false,
childIds: []
childIds: [],
isZip: isZip,
zipFile: null, // cached JSZip instance
zipPath: raw.zipPath || null, // path within zip (for virtual children)
zipParentId: raw.zipParentId || null // ancestor zip's node id (for nested entries)
};
state.nodes.set(id, node);
return node;
@ -101,14 +109,14 @@
for (var i = 0; i < ids.length; i++) {
out.push(ids[i]);
var n = state.nodes.get(ids[i]);
if (n.isDir && n.expanded) walk(n.childIds);
if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds);
}
}
// Re-sort everything at all levels so a sort change reorders
// already-loaded children consistently.
sortNodes(state.rootIds);
state.nodes.forEach(function (n) {
if (n.isDir && n.loaded) sortNodes(n.childIds);
if ((n.isDir || n.isZip) && n.loaded) sortNodes(n.childIds);
});
walk(state.rootIds);
return out;
@ -138,17 +146,20 @@
function rowHtml(node) {
var indent = node.depth * 1.2;
var iconChar = node.isDir ? '📁' : '📄';
var labelClass = node.isDir ? 'is-folder' : 'is-file';
var chevronClass = 'tree-name__chevron' + (node.isDir ? '' : ' tree-name__chevron--leaf');
var expandable = node.isDir || node.isZip;
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄');
var chevronClass = 'tree-name__chevron'
+ (expandable ? '' : ' tree-name__chevron--leaf');
var nameInner;
if (node.isDir) {
nameInner = '<span class="tree-name__label is-folder">'
+ escapeHtml(node.name) + '</span>';
} else {
// File: clickable link. In server mode, href is a real URL
// that opens the file. In FS mode, click handler reads the
// file via the handle and triggers a download (Phase 2).
// File / zip: clickable. Plain click → preview popup.
// Modifier-click (ctrl/cmd) and middle-click → open in
// new tab (browser default for the href). Server mode
// gets the real URL (so right-click → save-link-as also
// works); FS mode and zip-virtual children get '#'.
var href = node.url || '#';
nameInner = '<a class="tree-name__label is-file"'
+ ' href="' + escapeHtml(href) + '"'
@ -156,7 +167,9 @@
}
return ''
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
+ '" data-id="' + node.id + '" data-isdir="' + node.isDir + '">'
+ '" data-id="' + node.id
+ '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '">'
+ '<td class="col-name">'
+ '<span class="tree-name">'
+ '<span class="tree-name__indent" style="width:' + indent + 'rem;"></span>'
@ -183,19 +196,24 @@
applyFilter();
updateCount();
updateSortHeaders();
renderBreadcrumbs();
renderExtFilter();
}
// Filter is purely DOM-level: hide rows whose name doesn't match.
// Filter is purely DOM-level: hide rows whose name doesn't match
// and (if any extensions are selected) whose ext isn't in the set.
// Cheap, immediate, no model rebuild.
function applyFilter() {
var f = state.filterText;
var ef = state.extFilter;
var rows = document.querySelectorAll('#browseTbody tr.tree-row');
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var n = state.nodes.get(parseInt(row.dataset.id, 10));
if (!n) continue;
var match = !f || n.name.toLowerCase().indexOf(f) !== -1;
row.classList.toggle('tree-row--filtered', !match);
var nameMatch = !f || n.name.toLowerCase().indexOf(f) !== -1;
var extMatch = !ef.size || n.isDir || ef.has(n.ext);
row.classList.toggle('tree-row--filtered', !(nameMatch && extMatch));
}
}
@ -204,11 +222,81 @@
if (!el) return;
var rows = document.querySelectorAll('#browseTbody tr.tree-row:not(.tree-row--filtered)');
var total = document.querySelectorAll('#browseTbody tr.tree-row').length;
el.textContent = state.filterText
var anyFilter = state.filterText || state.extFilter.size;
el.textContent = anyFilter
? rows.length + ' of ' + total + ' shown'
: total + ' item' + (total === 1 ? '' : 's');
}
// ── Breadcrumbs ──────────────────────────────────────────────────────
function renderBreadcrumbs() {
var el = document.getElementById('breadcrumbs');
if (!el) return;
var html = '';
if (state.source === 'server') {
// Server mode: every segment links to its directory URL.
// The browser navigates → server returns embedded browse →
// the new instance auto-loads that directory's listing.
var path = state.currentPath || '/';
var parts = path.split('/').filter(Boolean);
html += '<a class="bc-link bc-root" href="/" title="Site root">🏠</a>';
var sofar = '';
for (var i = 0; i < parts.length; i++) {
sofar += '/' + parts[i];
var isLast = i === parts.length - 1;
html += '<span class="bc-sep">/</span>';
if (isLast) {
html += '<span class="bc-link bc-link--current">'
+ escapeHtml(parts[i]) + '</span>';
} else {
html += '<a class="bc-link" href="' + escapeHtml(sofar + '/') + '">'
+ escapeHtml(parts[i]) + '</a>';
}
}
html += '<span class="bc-sep">/</span>';
} else if (state.source === 'fs') {
// FS-API mode: ancestor handles weren't retained when the
// user picked the root, so we can't navigate up. Show the
// root as 🏠 + handle name without links.
var name = state.rootHandle ? state.rootHandle.name : '';
html += '<span class="bc-link bc-root" title="Local directory">🏠</span>';
if (name) {
html += '<span class="bc-sep">/</span>';
html += '<span class="bc-link bc-link--current">' + escapeHtml(name) + '</span>';
}
html += '<span class="bc-sep">/</span>';
}
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() {
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
@ -219,29 +307,163 @@
}
}
// Load a folder's children (lazy; idempotent re-loads).
// Load a folder's children (lazy; idempotent re-loads). Dispatches
// by node kind:
// - regular folder → server JSON listing OR FS-API enumeration
// - zip file → fetch+JSZip; entries become virtual children
// - zip child dir → already-listed entries from the parent zip
// (zips are enumerated whole, so child dirs
// are pre-populated when the zip expands)
async function loadChildren(node) {
if (node.loaded) return;
try {
var raw;
if (state.source === 'server') {
raw = await loader.fetchServerChildren(pathFor(node) + '/');
} else if (state.source === 'fs') {
raw = await loader.fetchFsChildren(node.handle);
} else {
return;
if (node.isZip) {
await loadZipChildren(node);
} else if (node._zipSyntheticDir) {
// Synthetic dir node materialized when a zip's entry
// list referenced "a/b/file" but had no "a/" entry.
// Re-walk the owning zip's flat entry list with the
// dir's full prefix.
var owner = state.nodes.get(node.zipParentId);
if (!owner || !owner.zipEntries) {
throw new Error('zip parent not loaded');
}
setZipDirChildren(node, owner, node.zipPath + '/');
} else if (node.isDir) {
var raw;
if (state.source === 'server') {
raw = await loader.fetchServerChildren(pathFor(node) + '/');
} else if (state.source === 'fs') {
raw = await loader.fetchFsChildren(node.handle);
} else {
return;
}
setChildren(node.id, raw);
}
setChildren(node.id, raw);
} catch (e) {
window.app.modules.events.statusError(
'Failed to load ' + node.name + ': ' + e.message);
}
}
// Fetch a zip's bytes, parse with JSZip, and materialize its
// entries as a tree of virtual nodes. JSZip's entry list is flat
// (full paths); we reconstruct the directory hierarchy on top.
async function loadZipChildren(zipNode) {
await loader.ensureJSZip();
var arrayBuffer;
if (state.source === 'server' && zipNode.url) {
var resp = await fetch(zipNode.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + zipNode.url);
arrayBuffer = await resp.arrayBuffer();
} else if (zipNode.handle) {
// FS-API: top-level zip in a local folder.
var f = await zipNode.handle.getFile();
arrayBuffer = await f.arrayBuffer();
} else if (zipNode.zipParentId != null) {
// Nested zip inside another zip — read from parent JSZip.
var parent = state.nodes.get(zipNode.zipParentId);
if (!parent || !parent.zipFile) {
throw new Error('parent zip not loaded');
}
arrayBuffer = await parent.zipFile.file(zipNode.zipPath).async('arraybuffer');
} else {
throw new Error('cannot fetch zip bytes (no source)');
}
var zip = await window.JSZip.loadAsync(arrayBuffer);
zipNode.zipFile = zip;
// Build a path → raw-entry map. Entry paths are
// "dir/sub/file.ext" or "dir/" for directories. We slice
// to immediate children of zipNode (i.e. zero slashes after
// a leading prefix). For nested directories, we synthesize
// folder nodes that lazy-expand to the next level via the
// same raw-entry list — keep it on the zipNode for replay.
zipNode.zipEntries = []; // for re-walk on expand of subdirs
zip.forEach(function (relPath, entry) {
zipNode.zipEntries.push({
path: relPath.replace(/\/$/, ''),
isDir: entry.dir,
size: (entry._data && entry._data.uncompressedSize) || 0,
modTime: entry.date instanceof Date ? entry.date : null,
rawPath: relPath
});
});
// Now seed top-level children of the zip itself.
setZipDirChildren(zipNode, zipNode, '');
}
// Populate node's childIds with the entries directly under
// pathPrefix (relative to the owning zip). Directory entries
// become folder nodes whose own children are seeded on first
// expand by this same function (recursively descending zipPath).
function setZipDirChildren(node, zipOwner, pathPrefix) {
var seen = new Map(); // immediate child name → raw entry
zipOwner.zipEntries.forEach(function (e) {
if (!e.path.startsWith(pathPrefix)) return;
var rest = e.path.substring(pathPrefix.length);
if (rest === '') return;
// Take the FIRST segment of the remaining path
var slash = rest.indexOf('/');
var firstSeg = slash === -1 ? rest : rest.substring(0, slash);
var isImmediateFile = !e.isDir && slash === -1;
var isImmediateDir = e.isDir && slash === -1;
// For deeply-nested entries (rest contains a slash), we
// surface only the first segment as a synthetic folder
// entry. For immediate entries, we emit the entry as-is.
if (isImmediateFile || isImmediateDir) {
// Immediate entry — use the real metadata.
seen.set(firstSeg, {
name: firstSeg,
isDir: e.isDir,
size: e.size,
modTime: e.modTime,
ext: e.isDir ? '' : loader.splitExt(firstSeg),
url: null,
handle: null,
zipPath: e.path,
zipParentId: zipOwner.id
});
} else if (slash !== -1 && !seen.has(firstSeg)) {
// Deeper entry, no explicit dir entry yet — synthesize.
seen.set(firstSeg, {
name: firstSeg,
isDir: true,
size: 0,
modTime: null,
ext: '',
url: null,
handle: null,
zipPath: pathPrefix + firstSeg,
zipParentId: zipOwner.id
});
}
});
// Drop existing children (re-load case)
node.childIds.forEach(function (id) { state.nodes.delete(id); });
node.childIds = [];
seen.forEach(function (raw) {
var n = newNode(raw, node.id, node.depth + 1);
// Synthetic dir nodes inside zip don't have a dedicated
// load path — they re-walk zipEntries on expand. Mark
// them so the dispatcher knows.
if (raw.isDir && !n.isZip) {
n._zipSyntheticDir = true;
}
node.childIds.push(n.id);
});
sortNodes(node.childIds);
node.loaded = true;
}
// Toggle a folder's expanded state. Loads children on first expand.
// Treats "expandable" as either a real directory OR a zip file
// (zip files act like folders for tree purposes — the chevron
// expands them and the contents come from JSZip).
async function toggleFolder(nodeId) {
var n = state.nodes.get(nodeId);
if (!n || !n.isDir) return;
if (!n || !(n.isDir || n.isZip)) return;
if (!n.expanded && !n.loaded) {
await loadChildren(n);
if (!n.loaded) return; // load failed
@ -253,16 +475,12 @@
// Recursive expand: load + expand all descendants of nodeId. Used
// for Shift-click on a folder. Walks breadth-first, fanning out
// through children, grand-children, etc. until every reachable
// folder is loaded and marked expanded. Status bar shows progress
// because deeply-nested trees can take a while.
//
// Parallelism: kept conservative (per-level fan-out) to avoid
// hammering zddc-server with hundreds of concurrent listing
// fetches. Browsers also throttle per-origin concurrency, but
// queuing politely is friendlier than fighting that.
// expandable node (folder OR zip) is loaded and marked expanded.
// Skips zip-EXPANSION recursion to avoid auto-loading every
// archive in the tree (those can be huge); plain folders only.
async function expandSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !root.isDir) return;
if (!root || !(root.isDir || root.isZip)) return;
var status = window.app.modules.events.statusInfo;
status('Expanding subtree…');
var processed = 0;
@ -270,7 +488,6 @@
while (queue.length) {
var batch = queue;
queue = [];
// Load this level's children in parallel (Promise.all).
await Promise.all(batch.map(function (n) { return loadChildren(n); }));
for (var i = 0; i < batch.length; i++) {
var n = batch[i];
@ -278,28 +495,26 @@
processed++;
for (var j = 0; j < n.childIds.length; j++) {
var c = state.nodes.get(n.childIds[j]);
if (c && c.isDir) queue.push(c);
// Recurse into plain folders only — don't auto-
// expand zip archives during a subtree expand
// (they can be very large).
if (c && c.isDir && !c.isZip) queue.push(c);
}
}
// Re-render after each level so the user sees progress
// rather than a long pause then a sudden full-tree dump.
render();
status('Expanding subtree… (' + processed + ' folders loaded)');
}
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
}
// Recursive collapse: mark this node and every descendant as
// collapsed. Doesn't unload — if the user re-expands later, the
// children are still in memory and re-render is instant.
function collapseSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !root.isDir) return;
if (!root || !(root.isDir || root.isZip)) return;
function walk(n) {
n.expanded = false;
for (var i = 0; i < n.childIds.length; i++) {
var c = state.nodes.get(n.childIds[i]);
if (c && c.isDir) walk(c);
if (c && (c.isDir || c.isZip)) walk(c);
}
}
walk(root);
@ -344,6 +559,13 @@
applyFilter();
updateCount();
},
setExtFilter: function (extArr) {
state.extFilter = new Set((extArr || []).map(function (e) {
return String(e).toLowerCase().replace(/^\./, '');
}));
applyFilter();
updateCount();
},
pathFor: pathFor
};
})();

View file

@ -25,9 +25,11 @@
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
</header>
@ -52,9 +54,10 @@
<div id="browseRoot" class="browse-root hidden">
<div class="toolbar">
<span class="toolbar__path" id="currentPath"></span>
<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>
</div>
<div class="browse-table-wrap">

View file

@ -1774,7 +1774,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp">v0.0.13</span>
<span class="build-timestamp">v0.0.14</span>
</div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
</div>

View file

@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.13</span>
<span class="build-timestamp">v0.0.14</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button>

File diff suppressed because it is too large Load diff

View file

@ -1376,7 +1376,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">v0.0.13</span>
<span class="build-timestamp">v0.0.14</span>
</div>
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>

View file

@ -866,7 +866,7 @@ body {
</g>
</svg>
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.13</span>
<span class="build-timestamp">v0.0.14</span>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>

View file

@ -1774,7 +1774,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp">v0.0.13</span>
<span class="build-timestamp">v0.0.14</span>
</div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
</div>

View file

@ -2210,7 +2210,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">v0.0.13</span>
<span class="build-timestamp">v0.0.14</span>
</div>
<div class="app-header__spacer"></div>
<div class="app-header__icons">

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.13
transmittal=v0.0.13
classifier=v0.0.13
mdedit=v0.0.13
landing=v0.0.13
form=v0.0.13
browse=v0.0.13
archive=v0.0.14
transmittal=v0.0.14
classifier=v0.0.14
mdedit=v0.0.14
landing=v0.0.14
form=v0.0.14
browse=v0.0.14

View file

@ -722,7 +722,7 @@ body.help-open .app-header {
</g>
</svg>
<span class="app-header__title" id="form-title">ZDDC Form</span>
<span class="build-timestamp">v0.0.13</span>
<span class="build-timestamp">v0.0.14</span>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>