When a user lacks permission, the app should (a) not let them do data entry it
will reject and (b) subtly say who can. General mechanism + the key gates.
Server — compute & expose "who can <verb> here":
- zddc.WhoCan(chain, verb) → Authority{Roles, People}: the acl.permissions
grantees holding the verb across the cascade (roles + their members) plus the
admins (who bypass). New whocan.go + whocan_test.go.
- AccessView gains path_who_can (profilehandler.go), populated only for verbs the
caller LACKS and only when they can read the path (mirrors .zddc readability),
so one cap.at() answers "can I?" and "if not, who?".
- writeForbiddenWho enriches the 403 body with who_can for the missing verb
(errors.go); authorizeAction uses it (fileapi.go) as the safety net for denials
that weren't pre-checked.
Shared — shared/cap.js:
- cap.whoCan(view, verb) + cap.denyHint(view, verb) → {text, title}, role-first
("Only the document controller can create here") with the people in the tooltip.
- handleForbidden appends the hint (from the 403 body, else the cached view), so
every tool that already routes 403s through it (form save, tables save, browse)
now explains who can — for free.
Key gates:
- Browse party-create (the reported bug): pre-check create authority on ssr/ and
the slot BEFORE opening the picker — if the user can do neither, show the hint
instead of the form; if only existing parties are usable, disable "+ New party"
with the who-can hint. The post-hoc 403 catch now names who can too.
- Tables +Add row disabled state shows the who-can hint.
Plus: subtle /_apps/{browse,archive,classifier}.html links in the landing footer.
Tests: Go WhoCan unit test (role/person split, admin bypass, dedupe); cap.spec.js
(denyHint role-first/people/fallback, whoCan, handleForbidden enrichment) — 5
green; Go handler+zddc+policy suites green. (Pre-existing stale browse toolbar
test browse.spec.js:274 unaffected.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1310 lines
62 KiB
JavaScript
1310 lines
62 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 */ }
|
||
}
|
||
|
||
// Navigation sequence token. Every async flow that ends by replacing
|
||
// the tree root (refresh, rescope, reload, back/forward popstate)
|
||
// captures a token before its fetch and bails if a newer navigation
|
||
// has started by the time it resolves — otherwise a slow listing can
|
||
// land on top of a newer one and leave the tree out of sync with
|
||
// state.currentPath / the URL bar.
|
||
var navSeq = 0;
|
||
function beginNav() { return ++navSeq; }
|
||
function isCurrentNav(seq) { return seq === navSeq; }
|
||
|
||
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();
|
||
var seq = beginNav();
|
||
if (state.source === 'server') {
|
||
var raw;
|
||
try {
|
||
raw = await loader.fetchServerChildren(state.currentPath);
|
||
} catch (e) {
|
||
statusError('Refresh failed: ' + e.message);
|
||
return;
|
||
}
|
||
if (!isCurrentNav(seq)) return;
|
||
tree.setRoot(raw);
|
||
await tree.restoreState(snap);
|
||
if (!isCurrentNav(seq)) return;
|
||
tree.render();
|
||
prefetchScopeAccess();
|
||
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;
|
||
}
|
||
if (!isCurrentNav(seq)) return;
|
||
tree.setRoot(raw2);
|
||
await tree.restoreState(snap);
|
||
if (!isCurrentNav(seq)) return;
|
||
tree.render();
|
||
statusInfo('Refreshed');
|
||
}
|
||
}
|
||
|
||
function init() {
|
||
// Inject the action implementations the declarative menu-model
|
||
// delegates to (avoids an events ↔ menu-model circular dependency).
|
||
var mm = window.app.modules.menuModel;
|
||
if (mm && mm.configure) {
|
||
mm.configure({
|
||
createInDir: createInDir,
|
||
renameNode: renameNode,
|
||
deleteNode: deleteNode,
|
||
navigateIntoFolder: navigateIntoFolder,
|
||
refreshListing: refreshListing,
|
||
parentDirFor: parentDirFor,
|
||
canCreateHere: canCreateHere,
|
||
statusInfo: statusInfo,
|
||
statusError: statusError
|
||
});
|
||
}
|
||
|
||
// Header buttons
|
||
var btn = document.getElementById('addDirectoryBtn');
|
||
if (btn) btn.addEventListener('click', pickLocalDir);
|
||
|
||
var refresh = document.getElementById('refreshHeaderBtn');
|
||
if (refresh) refresh.addEventListener('click', refreshListing);
|
||
|
||
// Admin mode (shared/elevation.js) flipped on this page. Listing
|
||
// verbs + editor affordances (canSave) are computed against the
|
||
// server WITH the elevation cookie, so re-fetch the listing (which
|
||
// re-runs prefetchScopeAccess) and re-render the open preview —
|
||
// restoreState only restores the highlight, not the pane contents.
|
||
window.addEventListener('zddc:elevationchange', async function () {
|
||
if (state.source !== 'server') return; // FS mode has no server elevation
|
||
await refreshListing();
|
||
var node = state.lastPreviewedNodeId && state.nodes.get(state.lastPreviewedNodeId);
|
||
var p = window.app.modules.preview;
|
||
if (node && !node.isDir && p && p.showFilePreview) p.showFilePreview(node);
|
||
});
|
||
|
||
// ── Tree-pane toolbar: Sort + Show hidden ──────────────────────
|
||
// View settings only. Create actions (new folder / file) live in
|
||
// the right-click context menu, not the toolbar.
|
||
var sortSelect = document.getElementById('sortSelect');
|
||
if (sortSelect) {
|
||
// Reflect current state, then drive setSortExplicit on change.
|
||
sortSelect.value = state.sort.key + ':' + state.sort.dir;
|
||
sortSelect.addEventListener('change', function () {
|
||
var parts = sortSelect.value.split(':');
|
||
tree.setSortExplicit(parts[0], parseInt(parts[1], 10) === -1 ? -1 : 1);
|
||
});
|
||
}
|
||
var showHiddenChk = document.getElementById('showHiddenChk');
|
||
if (showHiddenChk) {
|
||
showHiddenChk.checked = !!state.showHidden;
|
||
showHiddenChk.addEventListener('change', function () {
|
||
state.showHidden = showHiddenChk.checked;
|
||
syncURLToSelection();
|
||
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;
|
||
// Kebab (⋯) button → open the row menu at the button; must run
|
||
// BEFORE the toggle/preview logic so it doesn't also fire those.
|
||
var kebab = e.target.closest('.tree-row__kebab');
|
||
if (kebab) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var r = kebab.getBoundingClientRect();
|
||
openRowMenuFor(row, r.right, r.bottom);
|
||
return;
|
||
}
|
||
var id = parseInt(row.dataset.id, 10);
|
||
var node = state.nodes.get(id);
|
||
if (!node) return;
|
||
|
||
// Table-leaf dirs (mdl/rsk/ssr) are NOT expandable — they fall
|
||
// through to the preview path, which opens the tables tool.
|
||
var isExpandable = (row.dataset.isdir === 'true' || row.dataset.iszip === 'true')
|
||
&& row.dataset.tableleaf !== '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;
|
||
if (row.dataset.tableleaf === 'true') return; // leaf: single-click previews
|
||
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
|
||
// Keyboard menu key — ContextMenu key or Shift+F10 opens the row
|
||
// menu at the selected row (standard file-manager / a11y gesture).
|
||
document.addEventListener('keydown', function (e) {
|
||
var tag = (e.target && e.target.tagName) || '';
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||
if (e.target && e.target.isContentEditable) return;
|
||
if (document.querySelector('.modal-overlay, .zddc-menu')) return;
|
||
var isMenuKey = e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10');
|
||
if (!isMenuKey || state.selectedId == null) return;
|
||
var selRow = treeBody.querySelector('.tree-row[data-id="' + state.selectedId + '"]');
|
||
if (!selRow) return;
|
||
e.preventDefault();
|
||
var rr = selRow.getBoundingClientRect();
|
||
openRowMenuFor(selRow, rr.left + 16, rr.bottom - 4);
|
||
});
|
||
|
||
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;
|
||
// Table-leaf dirs aren't expandable: Enter/Space previews them
|
||
// (opens the table) rather than toggling.
|
||
var expandable = !!(node && (node.isDir || node.isZip)
|
||
&& !window.app.modules.util.isTableLeaf(node));
|
||
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) {
|
||
// auto:true — keyboard cursor walking the tree. If an
|
||
// editor has unsaved edits, the preview module leaves it
|
||
// in place rather than prompting on every keystroke.
|
||
previewModule.showFilePreview(nextNode, { auto: true });
|
||
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) openRowMenuFor(row, e.clientX, e.clientY);
|
||
else openPaneMenu(e.clientX, e.clientY);
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
|
||
var escapeHtml = window.app.modules.util.escapeHtml;
|
||
|
||
// 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';
|
||
// The "+ New party" affordance is gated on create authority over ssr/
|
||
// (pre-checked in createInAggregator). When denied, disable it and say
|
||
// who can — role-first text inline, the specific people in the tooltip.
|
||
var newPartyAllowed = opts.canNewParty !== false;
|
||
var newPartyNote = newPartyAllowed ? '(registers a new party)'
|
||
: (opts.newPartyHint && opts.newPartyHint.text) || 'You can’t register a new party here.';
|
||
var newPartyTitle = (!newPartyAllowed && opts.newPartyHint && opts.newPartyHint.title) || '';
|
||
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) + '/<party>/</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.</em>') +
|
||
'<label title="' + escapeHtml(newPartyTitle) + '" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;' + (newPartyAllowed ? 'cursor:pointer;' : 'opacity:0.6;') + '">' +
|
||
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
|
||
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</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 cap = window.zddc && window.zddc.cap;
|
||
var ssrPath = '/' + agg.project + '/ssr/';
|
||
var slotPath = '/' + agg.project + '/' + agg.slot + '/';
|
||
// Pre-check create authority BEFORE any data entry: registering a new
|
||
// party needs `c` on ssr/, creating under an existing party needs `c` on
|
||
// the slot. If the user can do neither, don't open the form just to deny
|
||
// them — say (subtly) who can. If they can only use existing parties,
|
||
// open the picker with the "+ New party" option disabled + explained.
|
||
var canNewParty = true, ssrView = null, slotView = null;
|
||
if (cap && cap.at) {
|
||
slotView = await cap.at(slotPath);
|
||
ssrView = await cap.at(ssrPath);
|
||
var canSlot = !slotView || cap.has({ verbs: slotView.path_verbs }, 'c');
|
||
canNewParty = !ssrView || cap.has({ verbs: ssrView.path_verbs }, 'c');
|
||
if (slotView && !canSlot && !canNewParty) {
|
||
statusError(cap.denyHint(slotView, 'c').text);
|
||
return;
|
||
}
|
||
}
|
||
var parties = await fetchParties(agg.project);
|
||
var newPartyHint = (!canNewParty && ssrView && cap.denyHint) ? cap.denyHint(ssrView, 'c') : null;
|
||
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties, canNewParty: canNewParty, newPartyHint: newPartyHint });
|
||
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)) {
|
||
// Name who can — best-effort, for the path the denial came from.
|
||
var denied = choice.isNew ? ssrPath : ('/' + agg.project + '/' + agg.slot + '/' + choice.party + '/');
|
||
var v = (cap && cap.at) ? await cap.at(denied) : null;
|
||
statusError(v && cap.denyHint ? cap.denyHint(v, 'c').text : 'Not allowed — you don’t have create access here.');
|
||
} 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));
|
||
}
|
||
}
|
||
|
||
|
||
// 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 += '/';
|
||
var seq = beginNav();
|
||
// Root-scope reload — refresh the visible top-level listing.
|
||
if (dirPath === state.currentPath) {
|
||
var es;
|
||
try {
|
||
es = state.source === 'server'
|
||
? await loader.fetchServerChildren(dirPath)
|
||
: (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []);
|
||
} catch (e) {
|
||
statusError('Reload failed: ' + (e.message || e));
|
||
return;
|
||
}
|
||
if (!isCurrentNav(seq)) return;
|
||
tree.setRoot(es);
|
||
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) {
|
||
var raw;
|
||
try {
|
||
raw = state.source === 'server'
|
||
? await loader.fetchServerChildren(dirPath)
|
||
: (hit.handle ? await loader.fetchFsChildren(hit.handle) : []);
|
||
} catch (e) {
|
||
statusError('Reload failed: ' + (e.message || e));
|
||
return;
|
||
}
|
||
if (!isCurrentNav(seq)) return;
|
||
tree.setChildren(hit.id, raw);
|
||
hit.expanded = true;
|
||
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));
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// ── Menu opening (row / pane / kebab / keyboard) ──────────────────────
|
||
// The menu CONTENTS come from the declarative menu-model; this layer just
|
||
// resolves the target, syncs selection, and positions the menu. All four
|
||
// entry points (right-click row, right-click pane, kebab button, keyboard
|
||
// menu key) funnel through here so they stay identical.
|
||
|
||
// The prefetched /.profile/access view for the current scope (set on every
|
||
// listing load — see prefetchScopeAccess). Returned synchronously; the
|
||
// menu never triggers a fetch at open time. null until prefetched / FS mode.
|
||
function prefetchedAccess() { return state.scopeAccess; }
|
||
|
||
function menuModel() { return window.app.modules.menuModel; }
|
||
|
||
function openRowMenuFor(row, x, y) {
|
||
var id = parseInt(row.dataset.id, 10);
|
||
var node = state.nodes.get(id);
|
||
if (!node) return;
|
||
// Select the row first so the highlight + menu target agree.
|
||
state.selectedId = id;
|
||
tree.render();
|
||
syncURLToSelection();
|
||
var mm = menuModel();
|
||
if (!mm) return;
|
||
window.zddc.menu.open({
|
||
x: x, y: y,
|
||
context: { node: node, row: row, surface: 'row' },
|
||
items: function () { return mm.buildRowItems(node, row, prefetchedAccess()); }
|
||
});
|
||
}
|
||
|
||
function openPaneMenu(x, y) {
|
||
var mm = menuModel();
|
||
if (!mm) return;
|
||
window.zddc.menu.open({
|
||
x: x, y: y,
|
||
context: { dir: state.currentPath || '/', surface: 'pane' },
|
||
items: function () { return mm.buildPaneItems(prefetchedAccess()); }
|
||
});
|
||
}
|
||
|
||
// Prefetch (memoised) the scope access view so the menu's create-gate and
|
||
// admin/sub-admin tier items resolve without a fetch. Server-mode only;
|
||
// cap.at returns null on file:// so FS mode leaves scopeAccess null.
|
||
function prefetchScopeAccess() {
|
||
if (state.source !== 'server' || !window.zddc || !window.zddc.cap || !window.zddc.cap.at) {
|
||
state.scopeAccess = null;
|
||
return;
|
||
}
|
||
var path = state.currentPath || '/';
|
||
window.zddc.cap.at(path).then(function (view) {
|
||
// Ignore a stale resolution if the scope moved on.
|
||
if ((state.currentPath || '/') === path) {
|
||
state.scopeAccess = view || null;
|
||
applySourceUI();
|
||
}
|
||
}, function () { /* best-effort; leave prior value */ });
|
||
}
|
||
|
||
|
||
// View mode is URL-driven, not UI-driven.
|
||
//
|
||
// ?view=grid → embedded-tool view (only honored where the cascade's
|
||
// default_tool is an embeddable full-page tool —
|
||
// classifier/transmittal/archive; else falls back to browse)
|
||
// ?view=browse → browse listing (always)
|
||
// default → embedded-tool view when the dir's default_tool is one
|
||
// of those tools, browse listing 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 toolHere = !!(grid && grid.availableHere && grid.availableHere());
|
||
if (explicit === 'grid') return toolHere ? 'grid' : 'browse';
|
||
if (explicit === 'browse') return 'browse';
|
||
return toolHere ? '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;
|
||
var seq = beginNav();
|
||
var raw;
|
||
try {
|
||
raw = await loader.fetchFsChildren(node.handle);
|
||
} catch (e) {
|
||
statusError('Failed to enter ' + node.name + ': ' + e.message);
|
||
return;
|
||
}
|
||
// Mutate scope state only after the fetch succeeds and only if
|
||
// we're still the latest navigation — a bail here leaves the
|
||
// previous scope intact rather than half-swapped.
|
||
if (!isCurrentNav(seq)) return;
|
||
state.rootHandle = node.handle;
|
||
state.currentPath = node.handle.name + '/';
|
||
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 seq = beginNav();
|
||
var entries;
|
||
try {
|
||
entries = await loader.fetchServerChildren(url);
|
||
} catch (e) {
|
||
statusError('Failed to enter ' + displayName + ': ' + (e.message || e));
|
||
return;
|
||
}
|
||
// A newer navigation (another dblclick, a refresh, back/forward)
|
||
// started while this listing was in flight — drop this result so we
|
||
// don't pushState/setRoot on top of it.
|
||
if (!isCurrentNav(seq)) return;
|
||
state.currentPath = url;
|
||
prefetchScopeAccess();
|
||
// 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. Route
|
||
// through clearPreview so a live editor is disposed (not leaked).
|
||
var pmod = previewMod();
|
||
if (pmod && pmod.clearPreview) pmod.clearPreview();
|
||
else {
|
||
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,
|
||
// Re-fetch + re-render the current listing (restoring expansion +
|
||
// selection). Workflow modules call this after a move/accept so the
|
||
// tree reflects the change without a manual reload. upload.js already
|
||
// depends on it being present.
|
||
refreshListing: refreshListing,
|
||
// Shared navigation-sequence token so the popstate handler (app.js)
|
||
// can't race the in-tool navigations. beginNav() claims the latest
|
||
// token; isCurrentNav(seq) reports whether it's still latest.
|
||
beginNav: beginNav,
|
||
isCurrentNav: isCurrentNav,
|
||
// Prefetch the current scope's /.profile/access view into
|
||
// state.scopeAccess (memoised) so the menu's create-gate + admin-tier
|
||
// items resolve without a fetch. Called by app.js on initial load +
|
||
// back/forward.
|
||
prefetchScopeAccess: prefetchScopeAccess
|
||
};
|
||
})();
|