ZDDC/browse/js/events.js
ZDDC dbb7f6c9b5 fix(browse): dblclick rescopes client-side instead of hard nav
Hard navigation to /<path>/ lets zddc-server's auto-serve kick in for
canonical folders — /archive/ swaps in the archive tool, /staging/ the
transmittal tool, etc. — so double-clicking a canonical folder from
inside browse silently swapped the user out of browse, contrary to
what they expected.

Fix: client-side rescope. navigateIntoFolder() now fetches the new
directory listing via the loader, calls tree.setRoot() with virtual
canonicals re-applied, and pushes the new URL via history.pushState.
The page never reloads. A subsequent reload still works (browse loads
itself at the new URL since trailing-slash → ServeDirectory → embedded
browse SPA).

Side effects:
  - augmentRoot (the canonical-folder injection helper) exposed via
    window.app.modules so events.js can re-apply it on rescope.
  - popstate handler: back/forward in the browser triggers the same
    rescope path with the historic URL.
  - Selection + preview reset on rescope; the previous file's preview
    isn't carried into the new scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:45:14 -05:00

392 lines
17 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');
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() {
// Header buttons
var btn = document.getElementById('addDirectoryBtn');
if (btn) btn.addEventListener('click', pickLocalDir);
var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing);
// 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);
});
}
// View-mode toggle (Browse vs Grid)
var btnBrowse = document.getElementById('viewModeBrowse');
var btnGrid = document.getElementById('viewModeGrid');
if (btnBrowse) btnBrowse.addEventListener('click', function () { setViewMode('browse'); });
if (btnGrid) btnGrid.addEventListener('click', function () { setViewMode('grid'); });
// 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);
});
}
}
function setViewMode(mode) {
state.viewMode = mode;
var browseView = document.getElementById('browseView');
var gridView = document.getElementById('gridView');
var btnBrowse = document.getElementById('viewModeBrowse');
var btnGrid = document.getElementById('viewModeGrid');
if (mode === 'grid') {
if (browseView) browseView.classList.add('hidden');
if (gridView) gridView.classList.remove('hidden');
if (btnBrowse) btnBrowse.setAttribute('aria-selected', 'false');
if (btnGrid) btnGrid.setAttribute('aria-selected', 'true');
// Lazily mount classifier on first activation.
var grid = window.app.modules.grid;
if (grid && grid.activate) grid.activate();
} else {
if (browseView) browseView.classList.remove('hidden');
if (gridView) gridView.classList.add('hidden');
if (btnBrowse) btnBrowse.setAttribute('aria-selected', 'true');
if (btnGrid) btnGrid.setAttribute('aria-selected', 'false');
}
}
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;
// Inject virtual canonical folders at the new scope if it's a
// project root. (app.js owns this helper; expose via window.app.)
var augment = window.app.modules.augmentRoot;
var rootEntries = (typeof augment === 'function')
? augment(entries, url)
: entries;
tree.setRoot(rootEntries);
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);
}
// Public API
window.app.modules.events = {
init: init,
statusError: statusError,
statusInfo: statusInfo,
statusClear: statusClear,
showBrowseRoot: showBrowseRoot
};
})();