ZDDC/browse/js/events.js
ZDDC 96262a171e feat(browse): full create/edit/rename/delete in local-directory (offline) mode
Local folders are picked read-only, so create/edit/rename/delete were
either disabled or would fail. Now offline mode supports the same CRUD as
server mode, bounded only by what the filesystem grants:

- upload.js: ensureWritable() escalates the picked root to readwrite via
  the FS-Access permission prompt on the first mutation (one prompt, then
  granted for the session; requires the user gesture every caller has).
  makeDir/makeFile gain FS-API branches (getDirectoryHandle/getFileHandle
  {create:true} + createWritable) resolved through handleForDir; removeNode
  and renameNode (already FS-API) now escalate first.
- preview-markdown.js: the markdown editor's save escalates before
  createWritable, so editing a local .md persists.
- events.js: New folder / New markdown file menu items are enabled whenever
  there's a writable target (server, or a picked local folder) via
  canCreateHere(); rename/delete were already gated by canMutate (FS-API).
  The aggregator party-picker stays server-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:46:51 -05:00

1452 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 project-level folder-nav aggregators. These have no physical
// presence: <project>/<slot>/ lists the parties whose
// archive/<party>/<slot>/ has content. Creating something here means
// creating it under a party — see createInAggregator.
var FOLDER_NAV_SLOTS = { working: 1, staging: 1, reviewing: 1 };
// aggregatorRoot returns { project, slot } when parentDir is a
// project-level folder-nav aggregator root (server mode only), else
// null. parentDir is a "/<project>/<slot>/" 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 slot = segs[1].toLowerCase();
return FOLDER_NAV_SLOTS[slot] ? { project: segs[0], slot: slot } : null;
}
// rewriteAggregatorPath maps a path UNDER a folder-nav aggregator
// (a party already chosen — /<project>/<slot>/<party>[/<rest>]) to its
// canonical archive path /<project>/archive/<party>/<slot>[/<rest>],
// mirroring the server's folder-nav redirect. Returns null when
// parentDir isn't under such an aggregator (root case is handled by
// aggregatorRoot + the picker). Covers right-clicking a party row
// shown in an aggregator listing so "New folder" doesn't 409.
function rewriteAggregatorPath(parentDir) {
if (state.source !== 'server') return null;
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
if (segs.length < 3 || !segs[0]) return null;
var slot = segs[1].toLowerCase();
if (!FOLDER_NAV_SLOTS[slot]) return null;
var p = '/' + segs[0] + '/archive/' + segs[2] + '/' + slot + '/';
var rest = segs.slice(3);
if (rest.length) p += rest.join('/') + '/';
return p;
}
// List the parties under a project's archive/ (folder names), sorted.
async function fetchParties(project) {
try {
var entries = await loader.fetchServerChildren('/' + project + '/archive/');
return entries
.filter(function (e) { return e.isDir; })
.map(function (e) { return e.name; })
.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) + '/ aggregates each partys work, so it has no folder of its own. ' +
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>archive/&lt;party&gt;/' + escapeHtml(opts.slot) + '/</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 });
});
});
}
// createInAggregator routes a New folder/file in a virtual aggregator
// root to archive/<party>/<slot>/<name> after prompting for the party.
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 + '/archive/' + choice.party + '/' + agg.slot + '/';
try {
if (kind === 'folder') {
await up.makeDir(targetDir, choice.name);
statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + choice.name);
} 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 + '/' + agg.slot + '/' + name);
}
} catch (e) {
var msg = (e && e.message) || String(e);
if (/\b403\b/.test(msg)) {
statusError('Not allowed — creating a new party requires the document-controller role.');
} else {
statusError('Create failed: ' + msg);
}
return;
}
// Refresh the aggregator view — the party now appears if it had no
// content before.
await reloadDir('/' + agg.project + '/' + agg.slot + '/');
}
async function createInDir(parentDir, kind) {
var up = window.app.modules.upload;
if (!up) return;
// A project-level folder-nav aggregator (working/staging/reviewing)
// has no physical home — route through the party picker instead of
// erroring on an unplaceable mkdir/PUT.
var agg = aggregatorRoot(parentDir);
if (agg) return createInAggregator(agg, kind);
// A party already chosen inside an aggregator view → canonical path.
var rewritten = rewriteAggregatorPath(parentDir);
if (rewritten) parentDir = rewritten;
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
};
})();