ZDDC/browse/js/events.js
ZDDC f94defc8c1 feat(browse,tables): flat-peer clients + dual-mode cross-party aggregate
browse: the party picker reads the ssr/ registry (the authoritative party
list) and creates at physical peer paths <project>/<peer>/<party>/…;
"register new party" writes ssr/<party>.yaml first (party_source: ssr).
stage.js + accept-transmittal.js repointed to the top-level workspace peers
(working/staging/incoming) — received/issued + plan-review stay under the
WORM archive.

tables: mdl/ and rsk/ render the cross-party aggregate by recursing ONE
level into the party subdirs CLIENT-side (works online AND offline), with
$party from the server-injected row content (or derived from the subdir
offline). Rows carry the <party>/ prefix so reads/edits hit the real
per-party path. The server just lists the peer root normally (party subdirs
+ synthetic table.yaml/form.yaml) — the fs/tree flattening + ListRollupRows
are dropped in favour of this dual-mode client recursion.

Full Go suite + all 256 Playwright tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:35:31 -05:00

1444 lines
65 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; }
// Notifications route through the shared toast helper (shared/
// toast.js) — there's no persistent footer strip in browse. Same
// signatures as before so the 70+ existing call sites work
// unchanged; statusClear is a no-op (toasts fade on their own and
// single-toast policy guarantees only the latest is visible).
function status(msg, kind) {
if (!msg) return;
if (!window.zddc || typeof window.zddc.toast !== 'function') return;
var level = kind === 'error' ? 'error' : 'info';
window.zddc.toast(msg, level);
}
function statusError(msg) { status(msg, 'error'); }
function statusInfo(msg) { status(msg, 'info'); }
function statusClear() { /* no-op — toasts fade on their own */ }
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');
}
}
}
// syncURLToSelection reflects the current scope + selected node +
// show-hidden flag into the URL bar via history.replaceState, so:
// - bookmarks / copy-paste of the URL re-open the same view
// - reload (e.g. after toggling admin mode, which forces a hard
// reload to pick up the elevated cookie) lands the user back
// on the same selection
//
// Uses replaceState (not pushState) so a long click sequence doesn't
// pollute browser history. Scope changes (rescopeServer) still
// pushState — that's the only "intentional" navigation step in the
// SPA, and back/forward should walk between scopes, not selections.
//
// FS-API mode has no shareable URL, so this is a no-op there.
function syncURLToSelection() {
if (state.source !== 'server') return;
var scope = state.currentPath || '/';
if (!scope.endsWith('/')) scope += '/';
var params = new URLSearchParams();
var node = state.selectedId != null ? state.nodes.get(state.selectedId) : null;
if (node) {
var abs = tree.pathFor(node);
var prefix = scope.replace(/\/$/, '');
var rel = abs;
if (prefix && abs.indexOf(prefix + '/') === 0) {
rel = abs.slice(prefix.length + 1);
}
// Directory selections get a trailing slash so the URL
// round-trips as a navigable folder reference.
if (node.isDir && rel && !rel.endsWith('/')) rel += '/';
if (rel) params.set('file', rel);
}
if (state.showHidden) params.set('hidden', '1');
// URLSearchParams percent-encodes '/' to %2F; the server doesn't
// care, but the URL bar reads better with raw slashes.
var qs = params.toString().replace(/%2F/g, '/');
var url = scope + (qs ? '?' + qs : '');
try {
history.replaceState({ zddcBrowse: true, path: url }, '', url);
} catch (_e) { /* private browsing edge cases */ }
}
async function refreshListing() {
// Snapshot expanded paths + selection BEFORE setRoot clears the
// tree, then re-apply after the new root is in place. Keeps
// the user's layout (which folders were open, which row was
// highlighted, what the preview was pinned to) stable across
// a refresh — including the auto-refresh triggered by the
// "Show hidden files" toggle.
var snap = tree.snapshotState();
if (state.source === 'server') {
var raw;
try {
raw = await loader.fetchServerChildren(state.currentPath);
} catch (e) {
statusError('Refresh failed: ' + e.message);
return;
}
tree.setRoot(raw);
await tree.restoreState(snap);
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);
await tree.restoreState(snap);
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);
// Tree autofilter — parses input through zddc.filter.parse so
// the same query grammar that the archive app uses (terms,
// quotes, !negation, multi-word AND) works here. The AST is
// cached on state.filterAST; tree.render reads it and skips
// non-matching rows. Escape clears.
var filterInput = document.getElementById('treeFilter');
if (filterInput) {
var filterDebounce = null;
var applyFilter = function () {
var raw = filterInput.value || '';
state.filterText = raw;
state.filterAST = raw ? window.zddc.filter.parse(raw) : null;
filterInput.classList.toggle('filter-active', !!raw);
tree.render();
};
filterInput.addEventListener('input', function () {
if (filterDebounce) clearTimeout(filterDebounce);
filterDebounce = setTimeout(applyFilter, 80);
});
filterInput.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && filterInput.value) {
e.preventDefault();
filterInput.value = '';
applyFilter();
}
});
}
// 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
syncURLToSelection();
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);
});
// Keyboard navigation in the tree. Document-level listener so
// the user doesn't have to click into the tree first; bails
// out cleanly when focus is in an editable field or when a
// modal / context-menu owns the keys. Roving-tabindex-style
// semantics, matching the W3C tree-view pattern:
//
// ↓ / ↑ — move selection (auto-previews files)
// → — expand if collapsed; jump to first child
// if already expanded; no-op otherwise
// ← — collapse if expanded; jump to parent
// if collapsed/leaf
// Enter / Space — preview file / toggle folder
// Home / End — first / last visible row
document.addEventListener('keydown', function (e) {
// Skip editable contexts.
var tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.target && e.target.isContentEditable) return;
// Skip when a modal or context menu is open.
if (document.querySelector('.modal-overlay, .zddc-menu')) return;
// Skip if any modifier is pressed — lets Ctrl-F, Cmd-T,
// Alt-arrow back/forward etc. fall through unchanged.
if (e.ctrlKey || e.metaKey || e.altKey) return;
var key = e.key;
var navKey = key === 'ArrowDown' || key === 'ArrowUp'
|| key === 'ArrowLeft' || key === 'ArrowRight'
|| key === 'Home' || key === 'End'
|| key === 'Enter' || key === ' ';
if (!navKey) return;
var visible = tree.visibleIds();
if (!visible.length) return;
// Commit to handling this key — preventDefault so the
// browser doesn't also scroll on arrows / page-down on
// Space. Selection / expand actions happen below.
e.preventDefault();
var curIdx = visible.indexOf(state.selectedId);
var node = state.selectedId != null
? state.nodes.get(state.selectedId) : null;
var expandable = !!(node && (node.isDir || node.isZip));
var nextId = null;
var previewModule = previewMod();
if (key === 'ArrowDown') {
nextId = curIdx < 0
? visible[0]
: visible[Math.min(curIdx + 1, visible.length - 1)];
} else if (key === 'ArrowUp') {
nextId = curIdx < 0
? visible[visible.length - 1]
: visible[Math.max(curIdx - 1, 0)];
} else if (key === 'Home') {
nextId = visible[0];
} else if (key === 'End') {
nextId = visible[visible.length - 1];
} else if (key === 'ArrowRight' && node) {
if (expandable && !node.expanded) {
tree.toggleFolder(node.id);
return;
}
if (expandable && node.expanded
&& node.childIds && node.childIds.length) {
nextId = node.childIds[0];
}
} else if (key === 'ArrowLeft' && node) {
if (expandable && node.expanded) {
tree.toggleFolder(node.id);
return;
}
if (node.parentId != null) {
nextId = node.parentId;
}
} else if ((key === 'Enter' || key === ' ') && node) {
if (expandable) {
tree.toggleFolder(node.id);
} else if (previewModule) {
previewModule.showFilePreview(node);
state.lastPreviewedNodeId = node.id;
}
return;
}
if (nextId == null) return;
state.selectedId = nextId;
var nextNode = state.nodes.get(nextId);
tree.render();
syncURLToSelection();
// Auto-preview files as the keyboard cursor lands on them
// so the right pane keeps up with selection. Folders are
// selection-only; their preview is "expand to see inside".
if (nextNode && !nextNode.isDir && !nextNode.isZip
&& previewModule) {
previewModule.showFilePreview(nextNode);
state.lastPreviewedNodeId = nextId;
}
// Scroll the now-selected row into view.
var newRow = treeBody.querySelector(
'.tree-row[data-id="' + nextId + '"]');
if (newRow) newRow.scrollIntoView({ block: 'nearest' });
});
// Right-click → context menu. Two surfaces:
// - on a tree row: per-row menu (Open, Rename, Delete, …)
// - on empty space in the pane: directory-scope menu
// (New folder, Refresh, Sort by, …)
treeBody.addEventListener('contextmenu', function (e) {
e.preventDefault();
var row = e.target.closest('.tree-row');
if (row) {
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
state.selectedId = id;
tree.render();
syncURLToSelection();
window.zddc.menu.open({
x: e.clientX,
y: e.clientY,
context: { node: node, row: row },
items: buildTreeRowMenu
});
} else {
window.zddc.menu.open({
x: e.clientX,
y: e.clientY,
context: { dir: state.currentPath || '/' },
items: buildPaneMenu
});
}
});
// Per-row drag-drop. Any row is a drop target — folders
// upload into themselves; files upload into their parent
// folder. Highlighting is purely visual; server-side ACL
// is the source of truth (a 403 surfaces as an error toast).
wirePerRowDrop(treeBody);
}
}
// ── Per-row drag/drop targets ─────────────────────────────────────────
// Translate a node into the directory that should receive uploads
// dropped onto its row. Folders → themselves; files → their parent.
// Returns a server path with a trailing slash, or null when there's
// no usable destination (offline mode, virtual node, etc.).
function targetDirForNode(node) {
if (!node || node.virtual) return null;
if (state.source !== 'server') return null;
if (node.isZip) return null; // can't upload INTO a zip via PUT
var dirNode = node;
if (!node.isDir) {
if (node.parentId == null) {
// Top-level file → upload to current scope.
return state.currentPath || '/';
}
dirNode = state.nodes.get(node.parentId);
if (!dirNode) return null;
}
var p = tree.pathFor(dirNode);
if (!p.endsWith('/')) p += '/';
return p;
}
// True when this node is a file viewed through the synthetic
// <workflow>/received/ window — the URL has a `received/` segment
// that's NOT preceded by `archive/<party>/` (the canonical record
// form). A drop here is a review-comment intent: server rewrites to
// <workflow>/<base>+C<n><suffix>.
function isVirtualReceivedFile(node) {
if (!node || node.isDir || state.source !== 'server') return false;
var url = tree.pathFor(node);
var parts = url.replace(/^\/+/, '').split('/');
var idx = parts.indexOf('received');
if (idx < 2) return false;
// Canonical form: parts[idx - 2] === 'archive'. Virtual form: anything else.
return parts[idx - 2].toLowerCase() !== 'archive';
}
function dragHasFiles(e) {
if (!e.dataTransfer || !e.dataTransfer.types) return false;
var types = e.dataTransfer.types;
for (var i = 0; i < types.length; i++) {
if (types[i] === 'Files') return true;
}
return false;
}
function wirePerRowDrop(treeBody) {
var lastOver = null;
function clearHighlight() {
if (lastOver) {
lastOver.classList.remove('is-droptarget');
lastOver = null;
}
}
treeBody.addEventListener('dragover', function (e) {
if (!dragHasFiles(e)) return;
var row = e.target.closest('.tree-row');
if (!row) { clearHighlight(); return; }
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
var dest = targetDirForNode(node);
if (!dest) {
if (e.dataTransfer) e.dataTransfer.dropEffect = 'none';
clearHighlight();
return;
}
e.preventDefault(); // signals "this is a drop target"
e.stopPropagation(); // suppress doc-level overlay
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
if (lastOver !== row) {
clearHighlight();
row.classList.add('is-droptarget');
lastOver = row;
}
});
treeBody.addEventListener('dragleave', function (e) {
// dragleave fires on row crossings too — only clear when the
// pointer actually leaves the tree body.
if (!e.relatedTarget || !treeBody.contains(e.relatedTarget)) {
clearHighlight();
}
});
treeBody.addEventListener('drop', async function (e) {
if (!dragHasFiles(e)) return;
var row = e.target.closest('.tree-row');
clearHighlight();
if (!row) return;
var id = parseInt(row.dataset.id, 10);
var node = state.nodes.get(id);
if (!node) return;
// Comment-upload short-circuit: drop on a file that lives
// under the virtual <workflow>/received/ window is a "comment
// on this file" intent. PUT to the target's URL — the server
// rewrites to <workflow>/<base>+C<n><suffix> and the canonical
// record (WORM) stays untouched. Confirm first so the user
// sees what's about to happen.
if (!node.isDir && isVirtualReceivedFile(node)) {
e.preventDefault();
e.stopPropagation();
if (!window.confirm("Drop bytes here as a review comment on '" + node.name + "'? The server will save it in the workflow folder with a +C<n> revision modifier.")) {
return;
}
var upMod = window.app.modules.upload;
if (!upMod) return;
var targetURL = tree.pathFor(node);
try {
await upMod.uploadCommentToTarget(targetURL, e.dataTransfer);
} catch (err) {
statusError('Comment upload failed: ' + (err.message || err));
}
return;
}
var dest = targetDirForNode(node);
if (!dest) return;
e.preventDefault();
e.stopPropagation(); // pre-empt doc-level handler
var up = window.app.modules.upload;
if (!up) return;
try {
await up.uploadToDir(dest, e.dataTransfer);
} catch (err) {
statusError('Upload failed: ' + (err.message || err));
}
});
}
// ── Create new folder / file (server mode) ────────────────────────────
// Reject names with path separators, leading dots, or empty input —
// mirrors the server-side hidden-segment / no-traversal guards so
// the user sees the rejection without a round-trip.
function validateName(name) {
name = (name || '').trim();
if (!name) return { ok: false, msg: 'Name required.' };
if (name.indexOf('/') !== -1) return { ok: false, msg: 'No slashes allowed.' };
if (name === '.' || name === '..') return { ok: false, msg: 'Invalid name.' };
if (name.charAt(0) === '.' || name.charAt(0) === '_') {
return { ok: false, msg: 'Names beginning with "." or "_" are reserved.' };
}
return { ok: true, name: name };
}
// Resolve "the directory new items go into" for a given row.
// Folders/zips: create inside them. Files: create alongside (in
// their parent). Used by the row-context New menu items.
function parentDirFor(node) {
var parentDir;
if (!node) {
parentDir = state.currentPath || '/';
} else if (node.isDir || node.isZip) {
parentDir = tree.pathFor(node);
} else if (node.parentId != null) {
var parent = state.nodes.get(node.parentId);
parentDir = parent ? tree.pathFor(parent) : (state.currentPath || '/');
} else {
parentDir = state.currentPath || '/';
}
if (!parentDir.endsWith('/')) parentDir += '/';
return parentDir;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
// Valid party folder name — mirrors zddc.ValidPartyName server-side
// (^[A-Za-z0-9][A-Za-z0-9.-]*$).
function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); }
// The party-partitioned workspace peers. Each is a physical top-level
// directory <project>/<peer>/ whose children are <party>/ folders.
// Creating something at a peer root means choosing a party — see
// createInAggregator. (mdl/rsk rows are created via the tables tool;
// archive is the WORM record; ssr is the flat registry — none of those
// use this picker.)
var PARTY_PEERS = { incoming: 1, working: 1, staging: 1, reviewing: 1 };
// aggregatorRoot returns { project, slot } when parentDir is a party-
// partitioned peer root (server mode only), else null. parentDir is a
// "/<project>/<peer>/" URL.
function aggregatorRoot(parentDir) {
if (state.source !== 'server') return null;
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
if (segs.length !== 2 || !segs[0]) return null;
var peer = segs[1].toLowerCase();
return PARTY_PEERS[peer] ? { project: segs[0], slot: peer } : null;
}
// List the registered parties for a project — one ssr/<party>.yaml per
// party (the authoritative registry). A party "exists" iff its ssr row
// exists, so this is the canonical source for the picker. Returns []
// on error.
async function fetchParties(project) {
try {
var entries = await loader.fetchServerChildren('/' + project + '/ssr/');
return entries
.filter(function (e) { return !e.isDir && /\.yaml$/i.test(e.name); })
.map(function (e) { return e.name.replace(/\.yaml$/i, ''); })
.filter(function (n) { return n !== 'table' && n !== 'form'; })
.sort(function (a, b) { return a.localeCompare(b); });
} catch (_e) { return []; }
}
// openPartyPicker resolves to { party, name } once the user picks a
// party (existing or new) and a name, or null on cancel. Mirrors the
// stage.js modal styling. New-party creation is offered but the server
// gates it to the document_controller (a 403 surfaces a clear message).
function openPartyPicker(opts) {
return new Promise(function (resolve) {
var kindWord = opts.kind === 'folder' ? 'folder' : 'file';
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
var partyList = opts.parties.map(function (name) {
return '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;">' +
'<input type="radio" name="pp-party" value="' + escapeHtml(name) + '">' +
'<span style="font-family:var(--code,monospace);">' + escapeHtml(name) + '</span></label>';
}).join('');
box.innerHTML =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
escapeHtml(opts.slot) + '/ is partitioned by party. ' +
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' +
'</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
'<input type="radio" name="pp-party" value="__new__">' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
'</div>' +
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
'<label for="pp-newparty">New party name</label><br>' +
'<input id="pp-newparty" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" placeholder="Acme">' +
'</div>' +
'<label for="pp-name" style="font-size:0.9rem;">' + (opts.kind === 'folder' ? 'Folder' : 'File') + ' name</label>' +
'<input id="pp-name" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" value="' + (opts.kind === 'folder' ? 'new-folder' : 'new.md') + '">' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="pp-cancel">Cancel</button>' +
'<button type="button" id="pp-submit" class="btn-primary">Create</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
var newRow = box.querySelector('#pp-newparty-row');
var newInput = box.querySelector('#pp-newparty');
box.querySelectorAll('input[name="pp-party"]').forEach(function (r) {
r.addEventListener('change', function () {
var isNew = (r.value === '__new__' && r.checked);
newRow.style.display = isNew ? '' : 'none';
if (isNew) newInput.focus();
});
});
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
function cancel() { close(); resolve(null); }
box.querySelector('#pp-cancel').addEventListener('click', cancel);
overlay.addEventListener('click', function (e) { if (e.target === overlay) cancel(); });
box.querySelector('#pp-submit').addEventListener('click', function () {
var sel = box.querySelector('input[name="pp-party"]:checked');
if (!sel) { statusError('Pick a party.'); return; }
var party;
if (sel.value === '__new__') {
party = newInput.value.trim();
if (!validPartyName(party)) {
statusError('Party name: a letter or digit, then letters/digits/dot/hyphen.');
return;
}
} else {
party = sel.value;
}
var nv = validateName(box.querySelector('#pp-name').value);
if (!nv.ok) { statusError(nv.msg); return; }
close();
resolve({ party: party, name: nv.name, isNew: sel.value === '__new__' });
});
});
}
// createInAggregator routes a New folder/file at a party-peer root to
// the physical <project>/<peer>/<party>/<name> after prompting for the
// party. A brand-new party is registered first by creating its
// ssr/<party>.yaml row (the authoritative registry; party_source: ssr).
async function createInAggregator(agg, kind) {
var up = window.app.modules.upload;
if (!up) return;
var parties = await fetchParties(agg.project);
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
if (!choice) return;
// Party names are validated to a URL-safe charset, so no encoding
// needed for the party segment; makeDir/makeFile encode the leaf.
var targetDir = '/' + agg.project + '/' + agg.slot + '/' + choice.party + '/';
try {
if (choice.isNew) {
// Register the party: its existence is ssr/<party>.yaml.
await up.makeFile('/' + agg.project + '/ssr/', choice.party + '.yaml',
'kind: SSR\n', 'application/yaml; charset=utf-8');
}
if (kind === 'folder') {
await up.makeDir(targetDir, choice.name);
statusInfo('Created ' + choice.party + '/' + choice.name + ' in ' + agg.slot + '/');
} else {
var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md';
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8');
statusInfo('Created ' + choice.party + '/' + name + ' in ' + agg.slot + '/');
}
} catch (e) {
var msg = (e && e.message) || String(e);
if (/\b403\b/.test(msg)) {
statusError('Not allowed — registering a new party requires the document-controller role.');
} else if (/\b409\b/.test(msg)) {
statusError('Unknown party — register it first (document controller).');
} else {
statusError('Create failed: ' + msg);
}
return;
}
await reloadDir('/' + agg.project + '/' + agg.slot + '/');
}
async function createInDir(parentDir, kind) {
var up = window.app.modules.upload;
if (!up) return;
// At a party-peer root (incoming/working/staging/reviewing) the
// create needs a party — route through the picker. Deeper paths
// (a party already chosen, e.g. working/<party>/…) are physical and
// created directly.
var agg = aggregatorRoot(parentDir);
if (agg) return createInAggregator(agg, kind);
var promptMsg = kind === 'folder'
? 'New folder name (under ' + parentDir + '):'
: 'New markdown filename (under ' + parentDir + '):';
var defaultName = kind === 'folder' ? 'new-folder' : 'new.md';
var raw = window.prompt(promptMsg, defaultName);
if (raw == null) return;
var v = validateName(raw);
if (!v.ok) {
statusError(v.msg);
return;
}
try {
if (kind === 'folder') {
await up.makeDir(parentDir, v.name);
statusInfo('Created folder ' + v.name);
} else {
var name = /\.(md|markdown)$/i.test(v.name) ? v.name : v.name + '.md';
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
await up.makeFile(parentDir, name, template, 'text/markdown; charset=utf-8');
statusInfo('Created ' + name);
}
await reloadDir(parentDir);
} catch (e) {
statusError('Create failed: ' + (e.message || e));
}
}
function createInside(node, kind) { return createInDir(parentDirFor(node), kind); }
// Reload a directory's children in the tree so a create/delete/
// rename is reflected. Works for both the current scope (root)
// and any expanded subdirectory.
async function reloadDir(dirPath) {
var loader = window.app.modules.loader;
if (!loader) return;
if (!dirPath.endsWith('/')) dirPath += '/';
// Root-scope reload — refresh the visible top-level listing.
if (dirPath === state.currentPath) {
try {
var es = state.source === 'server'
? await loader.fetchServerChildren(dirPath)
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
tree.setRoot(es);
} catch (_e) { /* swallow */ }
tree.render();
return;
}
// Otherwise find the node whose path matches and reload it.
var noSlash = dirPath.replace(/\/$/, '');
var hit = null;
state.nodes.forEach(function (n) {
if (hit || !n.isDir) return;
if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
});
if (hit) {
try {
var raw = state.source === 'server'
? await loader.fetchServerChildren(dirPath)
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
tree.setChildren(hit.id, raw);
hit.expanded = true;
} catch (_e) { /* swallow */ }
tree.render();
}
}
// ── Rename / Delete ───────────────────────────────────────────────────
async function renameNode(node) {
var up = window.app.modules.upload;
if (!up || !up.canMutate(node)) return;
var raw = window.prompt('Rename "' + node.name + '" to:', node.name);
if (raw == null) return;
var v = validateName(raw);
if (!v.ok) { statusError(v.msg); return; }
if (v.name === node.name) return;
try {
await up.renameNode(node, v.name);
statusInfo('Renamed to ' + v.name);
var parentPath = node.parentId != null
? tree.pathFor(state.nodes.get(node.parentId))
: (state.currentPath || '/');
await reloadDir(parentPath);
} catch (e) {
statusError('Rename failed: ' + (e.message || e));
}
}
async function deleteNode(node) {
var up = window.app.modules.upload;
if (!up || !up.canMutate(node)) return;
var what = node.isDir ? 'folder' : 'file';
// Native confirm() is intentional — destructive actions
// benefit from the browser's blocking, OS-styled dialog
// (signals "this is serious"). A custom modal would look
// friendlier; we want it to NOT look friendly.
var msg = 'Permanently delete this ' + what + '?\n\n' + node.name;
if (node.isDir) {
msg += '\n\nThis will remove every file inside it.';
}
if (!window.confirm(msg)) return;
try {
await up.removeNode(node);
statusInfo('Deleted ' + node.name);
// Clear selection / preview when they pointed at the
// now-gone node, so the right pane doesn't keep a ghost.
if (state.selectedId === node.id) {
state.selectedId = null;
syncURLToSelection();
}
if (state.lastPreviewedNodeId === node.id) {
state.lastPreviewedNodeId = null;
var pb = document.getElementById('previewBody');
if (pb) pb.innerHTML =
'<div class="preview-empty">Click a file in the tree to preview it.</div>';
var pt = document.getElementById('previewTitle');
if (pt) pt.textContent = 'No file selected';
var pm = document.getElementById('previewMeta');
if (pm) pm.textContent = '';
}
var parentPath = node.parentId != null
? tree.pathFor(state.nodes.get(node.parentId))
: (state.currentPath || '/');
await reloadDir(parentPath);
} catch (e) {
statusError('Delete failed: ' + (e.message || e));
}
}
// Shared submenu (used by both the row menu and the pane menu).
// Toggle items so the active sort is checked in both surfaces.
var SORT_BY_ITEMS = [
{ label: 'Name',
checked: function () { return state.sort.key === 'name'; },
action: function () { tree.setSortExplicit('name', 1); } },
{ label: 'Modified',
checked: function () { return state.sort.key === 'date'; },
action: function () { tree.setSortExplicit('date', -1); } },
{ label: 'Size',
checked: function () { return state.sort.key === 'size'; },
action: function () { tree.setSortExplicit('size', -1); } },
{ label: 'Type',
checked: function () { return state.sort.key === 'ext'; },
action: function () { tree.setSortExplicit('ext', 1); } }
];
// Row context menu — traditional file-manager layout:
// Open / Open in new tab / Pop out preview
// ─
// Download (label flips on type)
// ─
// New folder / New markdown file
// ─
// Rename / Delete (permission-gated, disabled
// when the row can't be mutated)
// ─
// Copy path / Copy name
// ─
// Expand / Collapse / Navigate into
// ─
// Sort by … / Show hidden files
//
// Items are kept VISIBLE but DISABLED when they don't apply, so
// every menu has the same shape regardless of what the user
// right-clicked. Predictable position = muscle memory.
// canCreateHere — whether New folder/file has a writable target: the
// server (ACL decides the rest) or a picked local folder (the
// filesystem permission decides, escalated on first write).
function canCreateHere() {
return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle);
}
function buildTreeRowMenu(ctx) {
var serverMode = state.source === 'server';
var canMutate = function (c) {
var up = window.app.modules.upload;
return !!(up && up.canMutate(c.node));
};
return [
// ── Open / preview cluster ──
{
label: function (c) {
if (c.node.isDir) return 'Open';
if (c.node.isZip) return 'Open archive';
return 'Preview';
},
disabled: function (c) { return !!c.node.virtual; },
action: function (c) {
if (c.node.isDir || c.node.isZip) {
tree.toggleFolder(c.node.id);
} else {
var p = previewMod();
if (p) p.showFilePreview(c.node);
}
}
},
{
label: 'Open in new tab',
accel: 'Ctrl+Click',
disabled: function (c) { return !c.node.url; },
action: function (c) {
if (c.node.url) window.open(c.node.url, '_blank', 'noopener');
}
},
{
label: 'Pop out preview',
disabled: function (c) { return c.node.isDir || c.node.isZip; },
action: function (c) {
var p = previewMod();
if (p) p.showFilePreview(c.node, { popup: true });
}
},
{ separator: true },
// ── Download (single item; label flips on type) ──
{
label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; },
icon: '⤓',
disabled: function (c) { return !!c.node.virtual; },
action: function (c) {
var d = window.app.modules.download;
if (!d) return;
if (c.node.isDir) d.downloadFolder(c.node);
else d.downloadFile(c.node);
}
},
{ separator: true },
// ── Create new (in the row's parent folder) ──
{
label: 'New folder',
disabled: !canCreateHere(),
action: function (c) { createInside(c.node, 'folder'); }
},
{
label: 'New markdown file',
disabled: !canCreateHere(),
action: function (c) { createInside(c.node, 'markdown'); }
},
{ separator: true },
// ── Rename + Delete (the permission-gated pair) ──
//
// Two gates compose: canMutate() rules out un-writable
// sources (offline FS-API without a handle, zip members,
// virtual placeholders) and — when the listing carries
// server-cascade verbs — zddc.cap.has(node, verb) applies
// the per-entry ACL. The verbs gate is server-mode only;
// file:// FS-API and plain Caddy listings have no verbs
// field, so we fall back to canMutate alone (FS-API
// enforces locally; Caddy has no PUT/DELETE either way).
// Server-side ACL still has the final say on the actual
// PUT/DELETE if a stale client tries the action.
{
label: 'Rename…',
disabled: function (c) {
if (!canMutate(c)) return true;
if (!serverMode || !window.zddc.cap) return false;
// verbs===undefined → Caddy or other non-zddc
// server, no cascade signal to gate on. verbs===""
// is zddc-server's explicit zero grant; still
// gate (disable). verbs==="rw…" → check the bit.
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'w');
},
tooltip: function (c) {
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'w')) return '';
return "You don't have write access to this item.";
},
action: function (c) { renameNode(c.node); }
},
{
label: 'Delete…',
icon: '🗑',
danger: true,
disabled: function (c) {
if (!canMutate(c)) return true;
if (!serverMode || !window.zddc.cap) return false;
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'd');
},
tooltip: function (c) {
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'd')) return '';
return "You don't have delete access to this item.";
},
action: function (c) { deleteNode(c.node); }
},
{ separator: true },
// ── Clipboard / identifiers ──
{
label: 'Copy path',
action: function (c) {
var path = tree.pathFor(c.node);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(path).then(
function () { statusInfo('Copied: ' + path); },
function () { statusError('Clipboard copy denied'); }
);
} else {
statusInfo(path);
}
}
},
{
label: 'Copy name',
action: function (c) {
// Always include the file extension. node.name
// already does for normal listings, but re-joining
// via zddc.joinExtension is defensive against any
// upstream that ever returns the basename split.
var n = c.node.name;
var ext = c.node.ext;
if (!c.node.isDir && ext
&& !n.toLowerCase().endsWith('.' + ext.toLowerCase())) {
n = window.zddc.joinExtension(n, ext);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(n);
}
statusInfo('Copied: ' + n);
}
},
{ separator: true },
// ── Tree-view ops (folder/zip rows only) ──
{
label: 'Expand subtree',
accel: 'Shift+Click',
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
action: function (c) { tree.expandSubtree(c.node.id); }
},
{
label: 'Collapse subtree',
disabled: function (c) { return !(c.node.isDir || c.node.isZip); },
action: function (c) { tree.collapseSubtree(c.node.id); }
},
{
label: 'Navigate into',
accel: 'Dbl-click',
disabled: function (c) { return !c.node.isDir; },
action: function (c) { navigateIntoFolder(c.node); }
},
{ separator: true },
// ── Plan Review (received/<tracking>/ only, cascade-gated) ──
{
label: 'Plan Review…',
visible: function (c) {
if (!serverMode) return false;
if (!state.scopeOnPlanReview) return false;
var pr = window.app.modules.planReview;
if (!pr) return false;
return pr.isReceivedTrackingFolder(c.node);
},
action: function (c) {
var pr = window.app.modules.planReview;
if (pr) pr.invoke(c.node);
}
},
// ── Accept Transmittal (transmittal folder under incoming/) ──
{
label: 'Accept Transmittal…',
visible: function (c) {
if (!serverMode) return false;
var at = window.app.modules.acceptTransmittal;
if (!at) return false;
return at.isAcceptableTransmittalFolder(c.node);
},
action: function (c) {
var at = window.app.modules.acceptTransmittal;
if (at) at.invoke(c.node);
}
},
// ── Stage / Unstage (files under working/ or staging/) ──
{
label: 'Stage to…',
visible: function (c) {
if (!serverMode) return false;
var s = window.app.modules.stage;
return !!(s && s.isStageableFile(c.node));
},
action: function (c) {
var s = window.app.modules.stage;
if (s) s.invokeStage(c.node);
}
},
{
label: 'Unstage to working/',
visible: function (c) {
if (!serverMode) return false;
var s = window.app.modules.stage;
return !!(s && s.isUnstageableFile(c.node));
},
action: function (c) {
var s = window.app.modules.stage;
if (s) s.invokeUnstage(c.node);
}
},
// ── Version history (history:true subtree, real files only) ──
// Server-mode only: the audit trail (who saved when) is
// server-stamped, so there's no offline equivalent. node.history
// is set by the listing when this file sits in a history-enabled
// cascade subtree (working/).
{
label: 'History…',
icon: '🕘',
visible: function (c) {
if (!serverMode) return false;
if (c.node.isDir || c.node.isZip || c.node.virtual) return false;
return !!c.node.history;
},
action: function (c) {
var h = window.app.modules.history;
if (h) h.open(c.node);
}
},
{ separator: true },
// ── View ──
{ label: 'Sort by', items: SORT_BY_ITEMS },
{ label: 'Show hidden files',
checked: function () { return !!state.showHidden; },
action: function () {
state.showHidden = !state.showHidden;
syncURLToSelection();
refreshListing();
} }
];
}
// Right-click on empty space in the tree pane → directory-scope
// menu. Operations apply to the current scope (state.currentPath),
// not any specific row.
function buildPaneMenu() {
var serverMode = state.source === 'server';
return [
{
label: 'New folder',
disabled: !canCreateHere(),
action: function () { createInDir(state.currentPath || '/', 'folder'); }
},
{
label: 'New markdown file',
disabled: !canCreateHere(),
action: function () { createInDir(state.currentPath || '/', 'markdown'); }
},
// ── Create Transmittal folder (staging/ scope only) ──
{
label: 'Create Transmittal folder…',
visible: function () {
return serverMode && state.scopeCanonicalFolder === 'staging';
},
action: function () {
var ct = window.app.modules.createTransmittal;
if (ct) ct.invoke();
}
},
{ separator: true },
{
label: 'Refresh',
accel: 'F5',
action: function () { refreshListing(); }
},
{ separator: true },
{ label: 'Sort by', items: SORT_BY_ITEMS },
{ label: 'Show hidden files',
checked: function () { return !!state.showHidden; },
action: function () {
state.showHidden = !state.showHidden;
syncURLToSelection();
refreshListing();
} }
];
}
// 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). Then immediately
// replaceState via syncURLToSelection so the new URL also
// carries ?hidden=1 if the toggle is on (selection is null
// at the new scope; the query gets only `hidden`).
try {
history.pushState({ zddcBrowse: true, path: url }, '', url);
} catch (_e) { /* private browsing edge cases */ }
syncURLToSelection();
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
};
})();