From 424bf8e76997dadd4284857c493d833fe7ef634b Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sun, 3 May 2026 20:39:49 -0500 Subject: [PATCH] =?UTF-8?q?feat(browse):=20Phase=202=20=E2=80=94=20preview?= =?UTF-8?q?=20popup,=20ZIP=20expansion,=20ext=20filter,=20breadcrumbs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles Phase 2 polish + the user-requested header/breadcrumb work: - Breadcrumbs replacing the plain currentPath span. Server mode renders linkified ancestor segments (each 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
; unknown
  types β†’ 'click Download' placeholder. Modifier-click (ctrl/cmd/
  shift) and middle-click still open the file in a new tab via the
  underlying . 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  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 {
diff --git a/browse/js/app.js b/browse/js/app.js
index 8d76be2..0b10936 100644
--- a/browse/js/app.js
+++ b/browse/js/app.js
@@ -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')
diff --git a/browse/js/events.js b/browse/js/events.js
index 6a8a893..18b8121 100644
--- a/browse/js/events.js
+++ b/browse/js/events.js
@@ -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  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.
             });
         }
     }
diff --git a/browse/js/init.js b/browse/js/init.js
index 6555e4b..4639c16 100644
--- a/browse/js/init.js
+++ b/browse/js/init.js
@@ -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
     };
 })();
diff --git a/browse/js/loader.js b/browse/js/loader.js
index 48adbbc..67443fd 100644
--- a/browse/js/loader.js
+++ b/browse/js/loader.js
@@ -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
     };
 })();
diff --git a/browse/js/preview.js b/browse/js/preview.js
new file mode 100644
index 0000000..51585ef
--- /dev/null
+++ b/browse/js/preview.js
@@ -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; 
 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, '&').replace(//g, '>').replace(/"/g, '"');
+    }
+
+    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 = '';
+        } else if (ext === 'html' || ext === 'htm') {
+            contentHtml = '';
+        } else if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
+            contentHtml = '' + safeName + '';
+        } else {
+            contentHtml = '
Loading preview…
'; + } + return '' + + '' + safeName + ' β€” preview' + + '

' + safeName + '

' + + '
' + + contentHtml + + '