// 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'); } } // Toolbar New buttons: enabled when there's a writable target, and in // server mode greyed (with a why-tooltip) when the scope lacks the // create verb. Mirrors the menu's create-gate. var canCreate = canCreateHere(); var lacksCreateVerb = state.source === 'server' && state.scopeAccess && typeof state.scopeAccess.path_verbs === 'string' && state.scopeAccess.path_verbs.indexOf('c') === -1; ['newFolderBtn', 'newFileBtn'].forEach(function (id) { var b = document.getElementById(id); if (!b) return; var off = !canCreate || lacksCreateVerb; b.disabled = off; b.title = lacksCreateVerb ? 'You don’t have create access here.' : (!canCreate ? 'Open a folder to create files here.' : ''); }); } // 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: New folder / New file, Sort, Show hidden ── // View settings live on the toolbar (not in per-row right-click // menus); create has a discoverable affordance here now that file // rows no longer offer it. var newFolderBtn = document.getElementById('newFolderBtn'); if (newFolderBtn) newFolderBtn.addEventListener('click', function () { createInDir(state.currentPath || '/', 'folder'); }); var newFileBtn = document.getElementById('newFileBtn'); if (newFileBtn) newFileBtn.addEventListener('click', function () { createInDir(state.currentPath || '/', 'markdown'); }); 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; 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 // 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; 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) { // 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 // /received/ window — the URL has a `received/` segment // that's NOT preceded by `archive//` (the canonical record // form). A drop here is a review-comment intent: server rewrites to // /+C. 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 /received/ window is a "comment // on this file" intent. PUT to the target's URL — the server // rewrites to /+C 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 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 // whose children are / 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 // "///" 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/.yaml per // party (the authoritative registry). A party "exists" iff its ssr row // exists, so this is the canonical source for the picker. Returns [] // on error. async function fetchParties(project) { try { var entries = await loader.fetchServerChildren('/' + project + '/ssr/'); return entries .filter(function (e) { return !e.isDir && /\.yaml$/i.test(e.name); }) .map(function (e) { return e.name.replace(/\.yaml$/i, ''); }) .filter(function (n) { return n !== 'table' && n !== 'form'; }) .sort(function (a, b) { return a.localeCompare(b); }); } catch (_e) { return []; } } // openPartyPicker resolves to { party, name } once the user picks a // party (existing or new) and a name, or null on cancel. Mirrors the // stage.js modal styling. New-party creation is offered but the server // gates it to the document_controller (a 403 surfaces a clear message). function openPartyPicker(opts) { return new Promise(function (resolve) { var kindWord = opts.kind === 'folder' ? 'folder' : 'file'; var overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; var box = document.createElement('div'); box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);'; var partyList = opts.parties.map(function (name) { return ''; }).join(''); box.innerHTML = '

New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/

' + '

' + escapeHtml(opts.slot) + '/ is partitioned by party. ' + 'Pick the party this ' + kindWord + ' belongs to — it lands under ' + escapeHtml(opts.slot) + '/<party>/.' + '

' + '
' + (partyList || 'No parties yet — create one below.') + '' + '
' + '' + '' + '' + '
' + '' + '' + '
'; 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 /// after prompting for the // party. A brand-new party is registered first by creating its // ssr/.yaml row (the authoritative registry; party_source: ssr). async function createInAggregator(agg, kind) { var up = window.app.modules.upload; if (!up) return; var parties = await fetchParties(agg.project); var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties }); if (!choice) return; // Party names are validated to a URL-safe charset, so no encoding // needed for the party segment; makeDir/makeFile encode the leaf. var targetDir = '/' + agg.project + '/' + agg.slot + '/' + choice.party + '/'; try { if (choice.isNew) { // Register the party: its existence is ssr/.yaml. await up.makeFile('/' + agg.project + '/ssr/', choice.party + '.yaml', 'kind: SSR\n', 'application/yaml; charset=utf-8'); } if (kind === 'folder') { await up.makeDir(targetDir, choice.name); statusInfo('Created ' + choice.party + '/' + choice.name + ' in ' + agg.slot + '/'); } else { var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md'; var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n'; await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8'); statusInfo('Created ' + choice.party + '/' + name + ' in ' + agg.slot + '/'); } } catch (e) { var msg = (e && e.message) || String(e); if (/\b403\b/.test(msg)) { statusError('Not allowed — registering a new party requires the document-controller role.'); } else if (/\b409\b/.test(msg)) { statusError('Unknown party — register it first (document controller).'); } else { statusError('Create failed: ' + msg); } return; } await reloadDir('/' + agg.project + '/' + agg.slot + '/'); } async function createInDir(parentDir, kind) { var up = window.app.modules.upload; if (!up) return; // At a party-peer root (incoming/working/staging/reviewing) the // create needs a party — route through the picker. Deeper paths // (a party already chosen, e.g. working//…) 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 = '
Click a file in the tree to preview it.
'; 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 → 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; 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 }; })();