Adds a UI checkbox next to the existing Sort dropdown that surfaces
hidden entries when ACL would otherwise allow read. Default off
(matches today's filtered behavior). On toggle, browse re-fetches
the current directory with ?hidden=1 and re-renders.
┌─ browse toolbar ─────────────────────────────────────────────┐
│ Sort: [Name (A→Z) ▾] ☐ Show hidden │
└──────────────────────────────────────────────────────────────┘
Server-side surface:
- internal/fs/tree.go ListDirectory gains an `includeHidden bool`
parameter. The .-prefix filter (previously hard-coded) now also
drops _-prefix entries (matches dispatch's reserved-prefix guard)
and honors the new flag.
- internal/handler/directory.go reads `?hidden=1` from the request
and threads it through.
- cmd/zddc-server/main.go dispatcher relaxes its dot-prefix and
_-prefix guards for GET/HEAD when `?hidden=1` is set, so clicking
a hidden entry's link works. `_app/` (apps cache) stays
unconditionally reserved — those bytes must go through the apps
resolver. Writes to hidden paths stay blocked (the file API has
its own segment check that the flag does NOT relax).
- internal/listing/listing.go: signature parity (the lower-level
helper that's used by tests + non-cascade listing paths).
Security model unchanged: the ACL chain on the parent dir is the only
real gate. Whoever can read the dir can see its contents — toggling
"Show hidden" just stops the client-side filter from masking
.-prefixed and _-prefixed entries. Hidden paths today:
• <dir>/.zddc ACL YAML — already exposed via /.profile/zddc
• <dir>/.converted/<base> cached MD→DOCX/HTML/PDF, same sensitivity as source
• <root>/.zddc.d/tokens/ per-token metadata; filename = sha256(token)
so not bearer-usable. Default root ACL
restricts to admins; matches /.tokens UI.
• <root>/.zddc.d/logs/ access logs; same admins-only audience
• <root>/_app/ cached upstream tool HTML (public)
• <root>/_template/ install.zip scaffolding (public)
None of these contain bearer credentials or secret material that the
existing ACL doesn't already gate. The walls are still the cascade.
442 lines
19 KiB
JavaScript
442 lines
19 KiB
JavaScript
// events.js — wires up DOM listeners. Idempotent so app.js can call
|
|
// init() once on load.
|
|
(function () {
|
|
'use strict';
|
|
|
|
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');
|
|
if (!el) return;
|
|
el.textContent = msg || '';
|
|
el.classList.remove('status-bar--error', 'status-bar--info');
|
|
if (kind === 'error') el.classList.add('status-bar--error');
|
|
if (kind === 'info') el.classList.add('status-bar--info');
|
|
}
|
|
|
|
function statusError(msg) { status(msg, 'error'); }
|
|
function statusInfo(msg) { status(msg, 'info'); }
|
|
function statusClear() { status('', null); }
|
|
|
|
async function pickLocalDir() {
|
|
if (typeof window.showDirectoryPicker !== 'function') {
|
|
statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.');
|
|
return;
|
|
}
|
|
var handle;
|
|
try {
|
|
handle = await window.showDirectoryPicker({ mode: 'read' });
|
|
} catch (e) {
|
|
// User cancelled — silent
|
|
return;
|
|
}
|
|
state.source = 'fs';
|
|
state.rootHandle = handle;
|
|
state.currentPath = handle.name + '/';
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchFsChildren(handle);
|
|
} catch (e) {
|
|
statusError('Failed to read directory: ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
showBrowseRoot();
|
|
tree.render();
|
|
statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
|
|
}
|
|
|
|
function showBrowseRoot() {
|
|
var empty = document.getElementById('emptyState');
|
|
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');
|
|
var dlZip = document.getElementById('downloadZipBtn');
|
|
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');
|
|
}
|
|
}
|
|
// "Download (zip)" is meaningful once a directory is loaded
|
|
// (server or local); it zips the directory currently in view.
|
|
if (dlZip) {
|
|
if (state.source) {
|
|
dlZip.classList.remove('hidden');
|
|
} else {
|
|
dlZip.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() {
|
|
// Header buttons
|
|
var btn = document.getElementById('addDirectoryBtn');
|
|
if (btn) btn.addEventListener('click', pickLocalDir);
|
|
|
|
var refresh = document.getElementById('refreshHeaderBtn');
|
|
if (refresh) refresh.addEventListener('click', refreshListing);
|
|
|
|
var dlZip = document.getElementById('downloadZipBtn');
|
|
if (dlZip) dlZip.addEventListener('click', function () {
|
|
var d = window.app.modules.download;
|
|
if (d) d.downloadCurrentSubtree();
|
|
});
|
|
|
|
// Sort dropdown — change → tree re-renders with the new sort.
|
|
// Format of option value: "<key>:<asc|desc>". Defaults match
|
|
// state.sort initial values (name:asc).
|
|
var sortSel = document.getElementById('sortBy');
|
|
if (sortSel) {
|
|
sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc');
|
|
sortSel.addEventListener('change', function () {
|
|
var parts = sortSel.value.split(':');
|
|
var key = parts[0];
|
|
var dir = parts[1] === 'desc' ? -1 : 1;
|
|
tree.setSortExplicit(key, dir);
|
|
});
|
|
}
|
|
|
|
// "Show hidden" checkbox — toggles state.showHidden, which the
|
|
// loader reads to append ?hidden=1 to listing requests. Re-uses
|
|
// the existing refreshListing flow so the tree pulls a fresh
|
|
// listing. ACL is still server-side; this just relaxes the
|
|
// client-visible filter for entries the user is already
|
|
// allowed to read.
|
|
var hiddenCb = document.getElementById('showHidden');
|
|
if (hiddenCb) {
|
|
hiddenCb.checked = !!state.showHidden;
|
|
hiddenCb.addEventListener('change', function () {
|
|
state.showHidden = hiddenCb.checked;
|
|
refreshListing();
|
|
});
|
|
}
|
|
|
|
// No view-mode buttons; mode is derived from the URL on every
|
|
// scope change (resolveViewMode below). Pass-through for the
|
|
// initial path.
|
|
applyResolvedViewMode();
|
|
|
|
// Pop-out preview button — opens the current preview in a separate window.
|
|
var popout = document.getElementById('previewPopout');
|
|
if (popout) popout.addEventListener('click', function () {
|
|
var p = previewMod();
|
|
if (p && state.lastPreviewedNodeId != null) {
|
|
var n = state.nodes.get(state.lastPreviewedNodeId);
|
|
if (n) p.showFilePreview(n, { popup: true });
|
|
}
|
|
});
|
|
|
|
// Pane resizer (tree pane width). Drag horizontally; clamps to
|
|
// [180, 60% of viewport]. State stays in-memory only — refresh
|
|
// resets to the default 360px.
|
|
var resizer = document.querySelector('.pane-resizer[data-resizer-for="tree-pane"]');
|
|
var treePane = document.getElementById('treePane');
|
|
if (resizer && treePane) {
|
|
var dragging = false;
|
|
var startX = 0;
|
|
var startWidth = 0;
|
|
resizer.addEventListener('mousedown', function (e) {
|
|
dragging = true;
|
|
resizer.classList.add('is-dragging');
|
|
startX = e.clientX;
|
|
startWidth = treePane.getBoundingClientRect().width;
|
|
e.preventDefault();
|
|
});
|
|
document.addEventListener('mousemove', function (e) {
|
|
if (!dragging) return;
|
|
var dx = e.clientX - startX;
|
|
var w = Math.max(180, Math.min(window.innerWidth * 0.6, startWidth + dx));
|
|
treePane.style.width = w + 'px';
|
|
});
|
|
document.addEventListener('mouseup', function () {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
resizer.classList.remove('is-dragging');
|
|
});
|
|
}
|
|
|
|
// Tree-row clicks (event delegation on the tree body).
|
|
// Click semantics on a folder row:
|
|
// - plain click → toggle expand (deferred so dblclick wins)
|
|
// - shift-click → recursive expand/collapse of the subtree
|
|
// - alt-click → ALSO recursive
|
|
// - dblclick → navigate into the folder
|
|
// File rows: plain click → preview in right pane; modifier-click
|
|
// and middle-click open in new tab.
|
|
//
|
|
// The plain-click toggle for folders is intentionally deferred
|
|
// via setTimeout. Reason: toggling re-renders the tree, which
|
|
// replaces the clicked row element. The browser detects a
|
|
// double-click only when the second click lands on the same
|
|
// target element as the first; replacing the row breaks that
|
|
// continuity and the dblclick event never fires. The deferred
|
|
// toggle lets a pending dblclick cancel it.
|
|
var pendingFolderToggle = null;
|
|
var treeBody = document.getElementById('treeBody');
|
|
if (treeBody) {
|
|
treeBody.addEventListener('click', function (e) {
|
|
var row = e.target.closest('.tree-row');
|
|
if (!row) return;
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
|
|
var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true';
|
|
|
|
if (isExpandable) {
|
|
e.preventDefault();
|
|
if (e.shiftKey || e.altKey) {
|
|
// Modifier-click skips the dblclick race — it's
|
|
// an explicit recursive toggle, never followed
|
|
// by a dblclick.
|
|
if (node.expanded) tree.collapseSubtree(id);
|
|
else tree.expandSubtree(id);
|
|
return;
|
|
}
|
|
// ZIPs don't navigate-into; toggle immediately.
|
|
if (row.dataset.iszip === 'true') {
|
|
tree.toggleFolder(id);
|
|
return;
|
|
}
|
|
// Folder: defer the toggle so a pending dblclick
|
|
// can pre-empt it.
|
|
if (pendingFolderToggle) {
|
|
clearTimeout(pendingFolderToggle.timer);
|
|
}
|
|
pendingFolderToggle = {
|
|
id: id,
|
|
timer: setTimeout(function () {
|
|
pendingFolderToggle = null;
|
|
tree.toggleFolder(id);
|
|
}, 220)
|
|
};
|
|
return;
|
|
}
|
|
|
|
// File row: modifier-click → open URL in new tab if
|
|
// available (server mode preserves the original URL,
|
|
// useful for direct download / sharing).
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) {
|
|
if (node.url) window.open(node.url, '_blank', 'noopener');
|
|
return;
|
|
}
|
|
// Plain click → preview in the right pane.
|
|
e.preventDefault();
|
|
state.selectedId = id;
|
|
state.lastPreviewedNodeId = id;
|
|
tree.render(); // refresh selection highlight
|
|
var p = previewMod();
|
|
if (p) p.showFilePreview(node);
|
|
});
|
|
|
|
// Double-click on a folder → "navigate into" it. Distinct
|
|
// from single-click (which expands inline) so users keep
|
|
// both UX models. Server mode jumps to the folder URL —
|
|
// zddc-server returns a fresh browse instance scoped to
|
|
// that directory. FS-API mode swaps state.rootHandle to
|
|
// the folder's handle and re-loads, so the user sees
|
|
// only that subtree at the root level.
|
|
//
|
|
// Files: dblclick is left alone — the single-click preview
|
|
// is already a "look at this file" action; a separate
|
|
// navigate-into doesn't apply.
|
|
// ZIPs: skipped too — they're inspected via inline
|
|
// expansion (JSZip), not navigated into.
|
|
treeBody.addEventListener('dblclick', function (e) {
|
|
var row = e.target.closest('.tree-row');
|
|
if (!row) return;
|
|
if (row.dataset.isdir !== 'true') return;
|
|
var id = parseInt(row.dataset.id, 10);
|
|
var node = state.nodes.get(id);
|
|
if (!node) return;
|
|
e.preventDefault();
|
|
// Pre-empt the deferred single-click toggle so the user
|
|
// doesn't see a flicker of expand/collapse before nav.
|
|
if (pendingFolderToggle) {
|
|
clearTimeout(pendingFolderToggle.timer);
|
|
pendingFolderToggle = null;
|
|
}
|
|
navigateIntoFolder(node);
|
|
});
|
|
}
|
|
}
|
|
|
|
// View mode is URL-driven, not UI-driven.
|
|
//
|
|
// ?view=grid → grid mode (only honored where classifier is
|
|
// available; otherwise falls back to browse)
|
|
// ?view=browse → browse mode (always)
|
|
// default → path-based: grid when inside an incoming/
|
|
// subtree, browse everywhere else
|
|
//
|
|
// resolveViewMode reads the current location and returns the mode
|
|
// to render; applyResolvedViewMode toggles the panes accordingly.
|
|
// Called on initial load and on every client-side rescope.
|
|
function resolveViewMode() {
|
|
var qs = new URLSearchParams(window.location.search);
|
|
var explicit = (qs.get('view') || '').toLowerCase();
|
|
var grid = window.app.modules.grid;
|
|
var classifierHere = !!(grid && grid.availableHere && grid.availableHere());
|
|
if (explicit === 'grid') return classifierHere ? 'grid' : 'browse';
|
|
if (explicit === 'browse') return 'browse';
|
|
return classifierHere ? 'grid' : 'browse';
|
|
}
|
|
|
|
function applyResolvedViewMode() {
|
|
var mode = resolveViewMode();
|
|
state.viewMode = mode;
|
|
var browseView = document.getElementById('browseView');
|
|
var gridView = document.getElementById('gridView');
|
|
if (mode === 'grid') {
|
|
if (browseView) browseView.classList.add('hidden');
|
|
if (gridView) gridView.classList.remove('hidden');
|
|
var grid = window.app.modules.grid;
|
|
if (grid) {
|
|
if (grid.reset) grid.reset();
|
|
if (grid.activate) grid.activate();
|
|
}
|
|
} else {
|
|
if (browseView) browseView.classList.remove('hidden');
|
|
if (gridView) gridView.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
async function navigateIntoFolder(node) {
|
|
if (state.source === 'server') {
|
|
// Rescope client-side rather than hard-navigating. A hard
|
|
// nav would let zddc-server's auto-serve kick in and swap
|
|
// us out of browse for canonical folders (e.g. /archive/
|
|
// → archive tool, /staging/ → transmittal). Staying in
|
|
// browse is what the user asked for; pushState keeps the
|
|
// URL bar accurate so a reload would re-load browse at the
|
|
// new scope.
|
|
var url = window.app.modules.tree.pathFor(node);
|
|
if (!url.endsWith('/')) url += '/';
|
|
await rescopeServer(url, node.name);
|
|
return;
|
|
}
|
|
if (state.source === 'fs') {
|
|
if (!node.handle || node.handle.kind !== 'directory') return;
|
|
state.rootHandle = node.handle;
|
|
state.currentPath = node.handle.name + '/';
|
|
var raw;
|
|
try {
|
|
raw = await loader.fetchFsChildren(node.handle);
|
|
} catch (e) {
|
|
statusError('Failed to enter ' + node.name + ': ' + e.message);
|
|
return;
|
|
}
|
|
tree.setRoot(raw);
|
|
tree.render();
|
|
statusInfo('Entered ' + node.name);
|
|
}
|
|
}
|
|
|
|
// Client-side rescope for server mode. Updates the URL via
|
|
// history.pushState, fetches the new directory listing, and
|
|
// re-renders the tree from scratch. Page DOES NOT reload.
|
|
async function rescopeServer(url, displayName) {
|
|
var entries;
|
|
try {
|
|
entries = await loader.fetchServerChildren(url);
|
|
} catch (e) {
|
|
statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
|
|
return;
|
|
}
|
|
state.currentPath = url;
|
|
// Selection / preview belong to the old scope; clear them so
|
|
// the new root doesn't carry stale highlight state.
|
|
state.selectedId = null;
|
|
state.lastPreviewedNodeId = null;
|
|
// Virtual canonical folders are emitted by zddc-server itself
|
|
// (so .zddc display: overrides apply uniformly); no client-side
|
|
// merge needed.
|
|
tree.setRoot(entries);
|
|
tree.render();
|
|
// Reset the preview pane so the user sees an "empty selection"
|
|
// state at the new scope instead of the previous file.
|
|
var previewBody = document.getElementById('previewBody');
|
|
if (previewBody) previewBody.innerHTML = '';
|
|
var previewTitle = document.getElementById('previewTitle');
|
|
if (previewTitle) previewTitle.textContent = 'No file selected';
|
|
var previewMeta = document.getElementById('previewMeta');
|
|
if (previewMeta) previewMeta.textContent = '';
|
|
// pushState so the URL bar reflects the new scope. A real
|
|
// reload would re-load browse at this URL (trailing slash →
|
|
// ServeDirectory → embedded browse SPA).
|
|
try {
|
|
history.pushState({ zddcBrowse: true, path: url }, '', url);
|
|
} catch (_e) { /* private browsing edge cases */ }
|
|
statusInfo('Entered ' + displayName);
|
|
// The new scope may have a different default view (grid inside
|
|
// incoming/, browse elsewhere). Re-resolve from the URL now
|
|
// that pushState has updated it.
|
|
applyResolvedViewMode();
|
|
}
|
|
|
|
// Public API
|
|
window.app.modules.events = {
|
|
init: init,
|
|
statusError: statusError,
|
|
statusInfo: statusInfo,
|
|
statusClear: statusClear,
|
|
showBrowseRoot: showBrowseRoot,
|
|
applyResolvedViewMode: applyResolvedViewMode
|
|
};
|
|
})();
|