ZDDC/browse/js/events.js
ZDDC 53eb58b90b refactor(browse): remove auto-filter rows from header
The two filter rows (📄 file/ext + 📁 folder) didn't really earn their
header real estate. Browse is for navigating directory structure;
ad-hoc filters across a tree of mixed file types and depths weren't
the right affordance, and the visual weight competed with the column
headers and the breadcrumb. Removed entirely:

  - template.html: dropped both <tr class="filter-row"> rows in <thead>,
    the related "Filter rows" help section, and the empty-state copy
    that mentioned the 📄/📁 rows.
  - init.js: dropped state.filters (file/folder/ext slots).
  - events.js: dropped the .column-filter[data-filter] input wiring.
  - tree.js: dropped recomputeVisibility() and the n.visible plumbing
    in visibleIds() and updateCount(). Render is now a straight depth-
    first walk over expanded subtrees; the count is just total rows.
    setFilter is removed from the public API.
  - css/tree.css: dropped .filter-row*, .filter-row__icon, and the
    browse-local .column-filter rules (.column-filter is also defined
    in shared/base.css for tools that still use it; that stays).

No test changes — tests/browse.spec.js never exercised filters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:50:58 -05:00

251 lines
9.8 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 headers
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
(function (th) {
th.addEventListener('click', function () {
tree.setSort(th.dataset.sort);
});
})(ths[i]);
}
// Tree-row clicks (event delegation on tbody).
// Click semantics on a folder row:
// - plain click → toggle just this folder
// - shift-click → recursive expand/collapse of the whole
// subtree (matches common file-explorer
// convention; e.g. Finder, VSCode tree,
// Windows Explorer)
// - alt-click → ALSO recursive (alt is sometimes the
// expand-all key on Linux DEs; bind both
// so muscle memory works either way)
// File rows: let the <a> tag's natural target=_blank do its
// job — don't intercept.
var tbody = document.getElementById('browseTbody');
if (tbody) {
tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr.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';
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.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.
});
// 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.
tbody.addEventListener('dblclick', function (e) {
var row = e.target.closest('tr.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();
navigateIntoFolder(node);
});
}
}
async function navigateIntoFolder(node) {
if (state.source === 'server') {
var url = window.app.modules.tree.pathFor(node);
if (!url.endsWith('/')) url += '/';
window.location.assign(url);
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);
}
}
// Public API
window.app.modules.events = {
init: init,
statusError: statusError,
statusInfo: statusInfo,
statusClear: statusClear,
showBrowseRoot: showBrowseRoot
};
})();