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>
251 lines
9.8 KiB
JavaScript
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
|
|
};
|
|
})();
|