// events.js — wires up DOM listeners. Idempotent so app.js can call // init() once on load. (function () { 'use strict'; var state = window.app.state; var tree = window.app.modules.tree; var loader = window.app.modules.loader; // preview module is loaded later (concat order); look it up at // call time, not at IIFE-eval time. function previewMod() { return window.app.modules.preview; } // Notifications route through the shared toast helper (shared/ // toast.js) — there's no persistent footer strip in browse. Same // signatures as before so the 70+ existing call sites work // unchanged; statusClear is a no-op (toasts fade on their own and // single-toast policy guarantees only the latest is visible). function status(msg, kind) { if (!msg) return; if (!window.zddc || typeof window.zddc.toast !== 'function') return; var level = kind === 'error' ? 'error' : 'info'; window.zddc.toast(msg, level); } function statusError(msg) { status(msg, 'error'); } function statusInfo(msg) { status(msg, 'info'); } function statusClear() { /* no-op — toasts fade on their own */ } async function pickLocalDir() { if (typeof window.showDirectoryPicker !== 'function') { statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.'); return; } var handle; try { handle = await window.showDirectoryPicker({ mode: 'read' }); } catch (e) { // User cancelled — silent return; } state.source = 'fs'; state.rootHandle = handle; state.currentPath = handle.name + '/'; var raw; try { raw = await loader.fetchFsChildren(handle); } catch (e) { statusError('Failed to read directory: ' + e.message); return; } tree.setRoot(raw); showBrowseRoot(); tree.render(); statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's')); } function showBrowseRoot() { var empty = document.getElementById('emptyState'); var root = document.getElementById('browseRoot'); if (empty) empty.classList.add('hidden'); if (root) root.classList.remove('hidden'); applySourceUI(); } // Visual state of the "Select Directory" button + the refresh // button depends on the source. In server mode the user is // already viewing a server-backed listing — Select Directory // becomes a quiet "switch to local" affordance (subtle styling), // and the refresh button is shown. In FS mode the button is // primary (it's how you got here) and refresh is hidden (the // listing was already a fresh enumeration). function applySourceUI() { var add = document.getElementById('addDirectoryBtn'); var refresh = document.getElementById('refreshHeaderBtn'); if (add) { if (state.source === 'server') { add.classList.remove('btn-primary'); add.classList.add('btn--subtle'); } else { add.classList.add('btn-primary'); add.classList.remove('btn--subtle'); } } if (refresh) { if (state.source) { refresh.classList.remove('hidden'); } else { refresh.classList.add('hidden'); } } } // syncURLToSelection reflects the current scope + selected node + // show-hidden flag into the URL bar via history.replaceState, so: // - bookmarks / copy-paste of the URL re-open the same view // - reload (e.g. after toggling admin mode, which forces a hard // reload to pick up the elevated cookie) lands the user back // on the same selection // // Uses replaceState (not pushState) so a long click sequence doesn't // pollute browser history. Scope changes (rescopeServer) still // pushState — that's the only "intentional" navigation step in the // SPA, and back/forward should walk between scopes, not selections. // // FS-API mode has no shareable URL, so this is a no-op there. function syncURLToSelection() { if (state.source !== 'server') return; var scope = state.currentPath || '/'; if (!scope.endsWith('/')) scope += '/'; var params = new URLSearchParams(); var node = state.selectedId != null ? state.nodes.get(state.selectedId) : null; if (node) { var abs = tree.pathFor(node); var prefix = scope.replace(/\/$/, ''); var rel = abs; if (prefix && abs.indexOf(prefix + '/') === 0) { rel = abs.slice(prefix.length + 1); } // Directory selections get a trailing slash so the URL // round-trips as a navigable folder reference. if (node.isDir && rel && !rel.endsWith('/')) rel += '/'; if (rel) params.set('file', rel); } if (state.showHidden) params.set('hidden', '1'); // URLSearchParams percent-encodes '/' to %2F; the server doesn't // care, but the URL bar reads better with raw slashes. var qs = params.toString().replace(/%2F/g, '/'); var url = scope + (qs ? '?' + qs : ''); try { history.replaceState({ zddcBrowse: true, path: url }, '', url); } catch (_e) { /* private browsing edge cases */ } } async function refreshListing() { // Snapshot expanded paths + selection BEFORE setRoot clears the // tree, then re-apply after the new root is in place. Keeps // the user's layout (which folders were open, which row was // highlighted, what the preview was pinned to) stable across // a refresh — including the auto-refresh triggered by the // "Show hidden files" toggle. var snap = tree.snapshotState(); if (state.source === 'server') { var raw; try { raw = await loader.fetchServerChildren(state.currentPath); } catch (e) { statusError('Refresh failed: ' + e.message); return; } tree.setRoot(raw); await tree.restoreState(snap); tree.render(); statusInfo('Refreshed (' + raw.length + ' item' + (raw.length === 1 ? '' : 's') + ')'); } else if (state.source === 'fs' && state.rootHandle) { var raw2; try { raw2 = await loader.fetchFsChildren(state.rootHandle); } catch (e) { statusError('Refresh failed: ' + e.message); return; } tree.setRoot(raw2); await tree.restoreState(snap); tree.render(); statusInfo('Refreshed'); } } function init() { // Header buttons var btn = document.getElementById('addDirectoryBtn'); if (btn) btn.addEventListener('click', pickLocalDir); var refresh = document.getElementById('refreshHeaderBtn'); if (refresh) refresh.addEventListener('click', refreshListing); // Tree autofilter — parses input through zddc.filter.parse so // the same query grammar that the archive app uses (terms, // quotes, !negation, multi-word AND) works here. The AST is // cached on state.filterAST; tree.render reads it and skips // non-matching rows. Escape clears. var filterInput = document.getElementById('treeFilter'); if (filterInput) { var filterDebounce = null; var applyFilter = function () { var raw = filterInput.value || ''; state.filterText = raw; state.filterAST = raw ? window.zddc.filter.parse(raw) : null; filterInput.classList.toggle('filter-active', !!raw); tree.render(); }; filterInput.addEventListener('input', function () { if (filterDebounce) clearTimeout(filterDebounce); filterDebounce = setTimeout(applyFilter, 80); }); filterInput.addEventListener('keydown', function (e) { if (e.key === 'Escape' && filterInput.value) { e.preventDefault(); filterInput.value = ''; applyFilter(); } }); } // No view-mode buttons; mode is derived from the URL on every // scope change (resolveViewMode below). Pass-through for the // initial path. applyResolvedViewMode(); // Pop-out preview button — opens the current preview in a separate window. var popout = document.getElementById('previewPopout'); if (popout) popout.addEventListener('click', function () { var p = previewMod(); if (p && state.lastPreviewedNodeId != null) { var n = state.nodes.get(state.lastPreviewedNodeId); if (n) p.showFilePreview(n, { popup: true }); } }); // Pane resizer (tree pane width). Drag horizontally; clamps to // [180, 60% of viewport]. State stays in-memory only — refresh // resets to the default 360px. var resizer = document.querySelector('.pane-resizer[data-resizer-for="tree-pane"]'); var treePane = document.getElementById('treePane'); if (resizer && treePane) { var dragging = false; var startX = 0; var startWidth = 0; resizer.addEventListener('mousedown', function (e) { dragging = true; resizer.classList.add('is-dragging'); startX = e.clientX; startWidth = treePane.getBoundingClientRect().width; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; var dx = e.clientX - startX; var w = Math.max(180, Math.min(window.innerWidth * 0.6, startWidth + dx)); treePane.style.width = w + 'px'; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; resizer.classList.remove('is-dragging'); }); } // Tree-row clicks (event delegation on the tree body). // Click semantics on a folder row: // - plain click → toggle expand (deferred so dblclick wins) // - shift-click → recursive expand/collapse of the subtree // - alt-click → ALSO recursive // - dblclick → navigate into the folder // File rows: plain click → preview in right pane; modifier-click // and middle-click open in new tab. // // The plain-click toggle for folders is intentionally deferred // via setTimeout. Reason: toggling re-renders the tree, which // replaces the clicked row element. The browser detects a // double-click only when the second click lands on the same // target element as the first; replacing the row breaks that // continuity and the dblclick event never fires. The deferred // toggle lets a pending dblclick cancel it. var pendingFolderToggle = null; var treeBody = document.getElementById('treeBody'); if (treeBody) { treeBody.addEventListener('click', function (e) { var row = e.target.closest('.tree-row'); if (!row) return; var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; var isExpandable = row.dataset.isdir === 'true' || row.dataset.iszip === 'true'; if (isExpandable) { e.preventDefault(); if (e.shiftKey || e.altKey) { // Modifier-click skips the dblclick race — it's // an explicit recursive toggle, never followed // by a dblclick. if (node.expanded) tree.collapseSubtree(id); else tree.expandSubtree(id); return; } // ZIPs don't navigate-into; toggle immediately. if (row.dataset.iszip === 'true') { tree.toggleFolder(id); return; } // Folder: defer the toggle so a pending dblclick // can pre-empt it. if (pendingFolderToggle) { clearTimeout(pendingFolderToggle.timer); } pendingFolderToggle = { id: id, timer: setTimeout(function () { pendingFolderToggle = null; tree.toggleFolder(id); }, 220) }; return; } // File row: modifier-click → open URL in new tab if // available (server mode preserves the original URL, // useful for direct download / sharing). if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) { if (node.url) window.open(node.url, '_blank', 'noopener'); return; } // Plain click → preview in the right pane. e.preventDefault(); state.selectedId = id; state.lastPreviewedNodeId = id; tree.render(); // refresh selection highlight syncURLToSelection(); var p = previewMod(); if (p) p.showFilePreview(node); }); // Double-click on a folder → "navigate into" it. Distinct // from single-click (which expands inline) so users keep // both UX models. Server mode jumps to the folder URL — // zddc-server returns a fresh browse instance scoped to // that directory. FS-API mode swaps state.rootHandle to // the folder's handle and re-loads, so the user sees // only that subtree at the root level. // // Files: dblclick is left alone — the single-click preview // is already a "look at this file" action; a separate // navigate-into doesn't apply. // ZIPs: skipped too — they're inspected via inline // expansion (JSZip), not navigated into. treeBody.addEventListener('dblclick', function (e) { var row = e.target.closest('.tree-row'); if (!row) return; if (row.dataset.isdir !== 'true') return; var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; e.preventDefault(); // Pre-empt the deferred single-click toggle so the user // doesn't see a flicker of expand/collapse before nav. if (pendingFolderToggle) { clearTimeout(pendingFolderToggle.timer); pendingFolderToggle = null; } navigateIntoFolder(node); }); // Keyboard navigation in the tree. Document-level listener so // the user doesn't have to click into the tree first; bails // out cleanly when focus is in an editable field or when a // modal / context-menu owns the keys. Roving-tabindex-style // semantics, matching the W3C tree-view pattern: // // ↓ / ↑ — move selection (auto-previews files) // → — expand if collapsed; jump to first child // if already expanded; no-op otherwise // ← — collapse if expanded; jump to parent // if collapsed/leaf // Enter / Space — preview file / toggle folder // Home / End — first / last visible row document.addEventListener('keydown', function (e) { // Skip editable contexts. var tag = (e.target && e.target.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (e.target && e.target.isContentEditable) return; // Skip when a modal or context menu is open. if (document.querySelector('.modal-overlay, .zddc-menu')) return; // Skip if any modifier is pressed — lets Ctrl-F, Cmd-T, // Alt-arrow back/forward etc. fall through unchanged. if (e.ctrlKey || e.metaKey || e.altKey) return; var key = e.key; var navKey = key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' || key === 'Home' || key === 'End' || key === 'Enter' || key === ' '; if (!navKey) return; var visible = tree.visibleIds(); if (!visible.length) return; // Commit to handling this key — preventDefault so the // browser doesn't also scroll on arrows / page-down on // Space. Selection / expand actions happen below. e.preventDefault(); var curIdx = visible.indexOf(state.selectedId); var node = state.selectedId != null ? state.nodes.get(state.selectedId) : null; var expandable = !!(node && (node.isDir || node.isZip)); var nextId = null; var previewModule = previewMod(); if (key === 'ArrowDown') { nextId = curIdx < 0 ? visible[0] : visible[Math.min(curIdx + 1, visible.length - 1)]; } else if (key === 'ArrowUp') { nextId = curIdx < 0 ? visible[visible.length - 1] : visible[Math.max(curIdx - 1, 0)]; } else if (key === 'Home') { nextId = visible[0]; } else if (key === 'End') { nextId = visible[visible.length - 1]; } else if (key === 'ArrowRight' && node) { if (expandable && !node.expanded) { tree.toggleFolder(node.id); return; } if (expandable && node.expanded && node.childIds && node.childIds.length) { nextId = node.childIds[0]; } } else if (key === 'ArrowLeft' && node) { if (expandable && node.expanded) { tree.toggleFolder(node.id); return; } if (node.parentId != null) { nextId = node.parentId; } } else if ((key === 'Enter' || key === ' ') && node) { if (expandable) { tree.toggleFolder(node.id); } else if (previewModule) { previewModule.showFilePreview(node); state.lastPreviewedNodeId = node.id; } return; } if (nextId == null) return; state.selectedId = nextId; var nextNode = state.nodes.get(nextId); tree.render(); syncURLToSelection(); // Auto-preview files as the keyboard cursor lands on them // so the right pane keeps up with selection. Folders are // selection-only; their preview is "expand to see inside". if (nextNode && !nextNode.isDir && !nextNode.isZip && previewModule) { // 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) { var id = parseInt(row.dataset.id, 10); var node = state.nodes.get(id); if (!node) return; state.selectedId = id; tree.render(); syncURLToSelection(); window.zddc.menu.open({ x: e.clientX, y: e.clientY, context: { node: node, row: row }, items: buildTreeRowMenu }); } else { window.zddc.menu.open({ x: e.clientX, y: e.clientY, context: { dir: state.currentPath || '/' }, items: buildPaneMenu }); } }); // Per-row drag-drop. Any row is a drop target — folders // upload into themselves; files upload into their parent // folder. Highlighting is purely visual; server-side ACL // is the source of truth (a 403 surfaces as an error toast). wirePerRowDrop(treeBody); } } // ── Per-row drag/drop targets ───────────────────────────────────────── // Translate a node into the directory that should receive uploads // dropped onto its row. Folders → themselves; files → their parent. // Returns a server path with a trailing slash, or null when there's // no usable destination (offline mode, virtual node, etc.). function targetDirForNode(node) { if (!node || node.virtual) return null; if (state.source !== 'server') return null; if (node.isZip) return null; // can't upload INTO a zip via PUT var dirNode = node; if (!node.isDir) { if (node.parentId == null) { // Top-level file → upload to current scope. return state.currentPath || '/'; } dirNode = state.nodes.get(node.parentId); if (!dirNode) return null; } var p = tree.pathFor(dirNode); if (!p.endsWith('/')) p += '/'; return p; } // True when this node is a file viewed through the synthetic // /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; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; }); } // Valid party folder name — mirrors zddc.ValidPartyName server-side // (^[A-Za-z0-9][A-Za-z0-9.-]*$). function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); } // The party-partitioned workspace peers. Each is a physical top-level // directory // 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)); } } function createInside(node, kind) { return createInDir(parentDirFor(node), kind); } // Reload a directory's children in the tree so a create/delete/ // rename is reflected. Works for both the current scope (root) // and any expanded subdirectory. async function reloadDir(dirPath) { var loader = window.app.modules.loader; if (!loader) return; if (!dirPath.endsWith('/')) dirPath += '/'; // Root-scope reload — refresh the visible top-level listing. if (dirPath === state.currentPath) { try { var es = state.source === 'server' ? await loader.fetchServerChildren(dirPath) : (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []); tree.setRoot(es); } catch (_e) { /* swallow */ } tree.render(); return; } // Otherwise find the node whose path matches and reload it. var noSlash = dirPath.replace(/\/$/, ''); var hit = null; state.nodes.forEach(function (n) { if (hit || !n.isDir) return; if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; }); if (hit) { try { var raw = state.source === 'server' ? await loader.fetchServerChildren(dirPath) : (hit.handle ? await loader.fetchFsChildren(hit.handle) : []); tree.setChildren(hit.id, raw); hit.expanded = true; } catch (_e) { /* swallow */ } tree.render(); } } // ── Rename / Delete ─────────────────────────────────────────────────── async function renameNode(node) { var up = window.app.modules.upload; if (!up || !up.canMutate(node)) return; var raw = window.prompt('Rename "' + node.name + '" to:', node.name); if (raw == null) return; var v = validateName(raw); if (!v.ok) { statusError(v.msg); return; } if (v.name === node.name) return; try { await up.renameNode(node, v.name); statusInfo('Renamed to ' + v.name); var parentPath = node.parentId != null ? tree.pathFor(state.nodes.get(node.parentId)) : (state.currentPath || '/'); await reloadDir(parentPath); } catch (e) { statusError('Rename failed: ' + (e.message || e)); } } async function deleteNode(node) { var up = window.app.modules.upload; if (!up || !up.canMutate(node)) return; var what = node.isDir ? 'folder' : 'file'; // Native confirm() is intentional — destructive actions // benefit from the browser's blocking, OS-styled dialog // (signals "this is serious"). A custom modal would look // friendlier; we want it to NOT look friendly. var msg = 'Permanently delete this ' + what + '?\n\n' + node.name; if (node.isDir) { msg += '\n\nThis will remove every file inside it.'; } if (!window.confirm(msg)) return; try { await up.removeNode(node); statusInfo('Deleted ' + node.name); // Clear selection / preview when they pointed at the // now-gone node, so the right pane doesn't keep a ghost. if (state.selectedId === node.id) { state.selectedId = null; syncURLToSelection(); } if (state.lastPreviewedNodeId === node.id) { state.lastPreviewedNodeId = null; var pb = document.getElementById('previewBody'); if (pb) pb.innerHTML = '
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)); } } // Shared submenu (used by both the row menu and the pane menu). // Toggle items so the active sort is checked in both surfaces. var SORT_BY_ITEMS = [ { label: 'Name', checked: function () { return state.sort.key === 'name'; }, action: function () { tree.setSortExplicit('name', 1); } }, { label: 'Modified', checked: function () { return state.sort.key === 'date'; }, action: function () { tree.setSortExplicit('date', -1); } }, { label: 'Size', checked: function () { return state.sort.key === 'size'; }, action: function () { tree.setSortExplicit('size', -1); } }, { label: 'Type', checked: function () { return state.sort.key === 'ext'; }, action: function () { tree.setSortExplicit('ext', 1); } } ]; // Row context menu — traditional file-manager layout: // Open / Open in new tab / Pop out preview // ─ // Download (label flips on type) // ─ // New folder / New markdown file // ─ // Rename / Delete (permission-gated, disabled // when the row can't be mutated) // ─ // Copy path / Copy name // ─ // Expand / Collapse / Navigate into // ─ // Sort by … / Show hidden files // // Items are kept VISIBLE but DISABLED when they don't apply, so // every menu has the same shape regardless of what the user // right-clicked. Predictable position = muscle memory. // canCreateHere — whether New folder/file has a writable target: the // server (ACL decides the rest) or a picked local folder (the // filesystem permission decides, escalated on first write). function canCreateHere() { return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle); } function buildTreeRowMenu(ctx) { var serverMode = state.source === 'server'; var canMutate = function (c) { var up = window.app.modules.upload; return !!(up && up.canMutate(c.node)); }; return [ // ── Open / preview cluster ── { label: function (c) { if (c.node.isDir) return 'Open'; if (c.node.isZip) return 'Open archive'; return 'Preview'; }, disabled: function (c) { return !!c.node.virtual; }, action: function (c) { if (c.node.isDir || c.node.isZip) { tree.toggleFolder(c.node.id); } else { var p = previewMod(); if (p) p.showFilePreview(c.node); } } }, { label: 'Open in new tab', accel: 'Ctrl+Click', disabled: function (c) { return !c.node.url; }, action: function (c) { if (c.node.url) window.open(c.node.url, '_blank', 'noopener'); } }, { label: 'Pop out preview', disabled: function (c) { return c.node.isDir || c.node.isZip; }, action: function (c) { var p = previewMod(); if (p) p.showFilePreview(c.node, { popup: true }); } }, { separator: true }, // ── Download (single item; label flips on type) ── { label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; }, icon: '⤓', disabled: function (c) { return !!c.node.virtual; }, action: function (c) { var d = window.app.modules.download; if (!d) return; if (c.node.isDir) d.downloadFolder(c.node); else d.downloadFile(c.node); } }, { separator: true }, // ── Create new (in the row's parent folder) ── { label: 'New folder', disabled: !canCreateHere(), action: function (c) { createInside(c.node, 'folder'); } }, { label: 'New markdown file', disabled: !canCreateHere(), action: function (c) { createInside(c.node, 'markdown'); } }, { separator: true }, // ── Rename + Delete (the permission-gated pair) ── // // Two gates compose: canMutate() rules out un-writable // sources (offline FS-API without a handle, zip members, // virtual placeholders) and — when the listing carries // server-cascade verbs — zddc.cap.has(node, verb) applies // the per-entry ACL. The verbs gate is server-mode only; // file:// FS-API and plain Caddy listings have no verbs // field, so we fall back to canMutate alone (FS-API // enforces locally; Caddy has no PUT/DELETE either way). // Server-side ACL still has the final say on the actual // PUT/DELETE if a stale client tries the action. { label: 'Rename…', disabled: function (c) { if (!canMutate(c)) return true; if (!serverMode || !window.zddc.cap) return false; // verbs===undefined → Caddy or other non-zddc // server, no cascade signal to gate on. verbs==="" // is zddc-server's explicit zero grant; still // gate (disable). verbs==="rw…" → check the bit. if (typeof c.node.verbs !== 'string') return false; return !window.zddc.cap.has(c.node, 'w'); }, tooltip: function (c) { if (!serverMode || !canMutate(c)) return ''; if (!window.zddc.cap) return ''; if (typeof c.node.verbs !== 'string') return ''; if (window.zddc.cap.has(c.node, 'w')) return ''; return "You don't have write access to this item."; }, action: function (c) { renameNode(c.node); } }, { label: 'Delete…', icon: '🗑', danger: true, disabled: function (c) { if (!canMutate(c)) return true; if (!serverMode || !window.zddc.cap) return false; if (typeof c.node.verbs !== 'string') return false; return !window.zddc.cap.has(c.node, 'd'); }, tooltip: function (c) { if (!serverMode || !canMutate(c)) return ''; if (!window.zddc.cap) return ''; if (typeof c.node.verbs !== 'string') return ''; if (window.zddc.cap.has(c.node, 'd')) return ''; return "You don't have delete access to this item."; }, action: function (c) { deleteNode(c.node); } }, { separator: true }, // ── Clipboard / identifiers ── { label: 'Copy path', action: function (c) { var path = tree.pathFor(c.node); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(path).then( function () { statusInfo('Copied: ' + path); }, function () { statusError('Clipboard copy denied'); } ); } else { statusInfo(path); } } }, { label: 'Copy name', action: function (c) { // Always include the file extension. node.name // already does for normal listings, but re-joining // via zddc.joinExtension is defensive against any // upstream that ever returns the basename split. var n = c.node.name; var ext = c.node.ext; if (!c.node.isDir && ext && !n.toLowerCase().endsWith('.' + ext.toLowerCase())) { n = window.zddc.joinExtension(n, ext); } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(n); } statusInfo('Copied: ' + n); } }, { separator: true }, // ── Tree-view ops (folder/zip rows only) ── { label: 'Expand subtree', accel: 'Shift+Click', disabled: function (c) { return !(c.node.isDir || c.node.isZip); }, action: function (c) { tree.expandSubtree(c.node.id); } }, { label: 'Collapse subtree', disabled: function (c) { return !(c.node.isDir || c.node.isZip); }, action: function (c) { tree.collapseSubtree(c.node.id); } }, { label: 'Navigate into', accel: 'Dbl-click', disabled: function (c) { return !c.node.isDir; }, action: function (c) { navigateIntoFolder(c.node); } }, { separator: true }, // ── Plan Review (received// only, cascade-gated) ── { label: 'Plan Review…', visible: function (c) { if (!serverMode) return false; if (!state.scopeOnPlanReview) return false; var pr = window.app.modules.planReview; if (!pr) return false; return pr.isReceivedTrackingFolder(c.node); }, action: function (c) { var pr = window.app.modules.planReview; if (pr) pr.invoke(c.node); } }, // ── Accept Transmittal (transmittal folder under incoming/) ── { label: 'Accept Transmittal…', visible: function (c) { if (!serverMode) return false; var at = window.app.modules.acceptTransmittal; if (!at) return false; return at.isAcceptableTransmittalFolder(c.node); }, action: function (c) { var at = window.app.modules.acceptTransmittal; if (at) at.invoke(c.node); } }, // ── Stage / Unstage (files under working/ or staging/) ── { label: 'Stage to…', visible: function (c) { if (!serverMode) return false; var s = window.app.modules.stage; return !!(s && s.isStageableFile(c.node)); }, action: function (c) { var s = window.app.modules.stage; if (s) s.invokeStage(c.node); } }, { label: 'Unstage to working/', visible: function (c) { if (!serverMode) return false; var s = window.app.modules.stage; return !!(s && s.isUnstageableFile(c.node)); }, action: function (c) { var s = window.app.modules.stage; if (s) s.invokeUnstage(c.node); } }, // ── Version history (history:true subtree, real files only) ── // Server-mode only: the audit trail (who saved when) is // server-stamped, so there's no offline equivalent. node.history // is set by the listing when this file sits in a history-enabled // cascade subtree (working/). { label: 'History…', icon: '🕘', visible: function (c) { if (!serverMode) return false; if (c.node.isDir || c.node.isZip || c.node.virtual) return false; return !!c.node.history; }, action: function (c) { var h = window.app.modules.history; if (h) h.open(c.node); } }, { separator: true }, // ── View ── { label: 'Sort by', items: SORT_BY_ITEMS }, { label: 'Show hidden files', checked: function () { return !!state.showHidden; }, action: function () { state.showHidden = !state.showHidden; syncURLToSelection(); refreshListing(); } } ]; } // Right-click on empty space in the tree pane → directory-scope // menu. Operations apply to the current scope (state.currentPath), // not any specific row. function buildPaneMenu() { var serverMode = state.source === 'server'; return [ { label: 'New folder', disabled: !canCreateHere(), action: function () { createInDir(state.currentPath || '/', 'folder'); } }, { label: 'New markdown file', disabled: !canCreateHere(), action: function () { createInDir(state.currentPath || '/', 'markdown'); } }, // ── Create Transmittal folder (staging/ scope only) ── { label: 'Create Transmittal folder…', visible: function () { return serverMode && state.scopeCanonicalFolder === 'staging'; }, action: function () { var ct = window.app.modules.createTransmittal; if (ct) ct.invoke(); } }, { separator: true }, { label: 'Refresh', accel: 'F5', action: function () { refreshListing(); } }, { separator: true }, { label: 'Sort by', items: SORT_BY_ITEMS }, { label: 'Show hidden files', checked: function () { return !!state.showHidden; }, action: function () { state.showHidden = !state.showHidden; syncURLToSelection(); refreshListing(); } } ]; } // View mode is URL-driven, not UI-driven. // // ?view=grid → grid mode (only honored where classifier is // available; otherwise falls back to browse) // ?view=browse → browse mode (always) // default → path-based: grid when inside an incoming/ // subtree, browse everywhere else // // resolveViewMode reads the current location and returns the mode // to render; applyResolvedViewMode toggles the panes accordingly. // Called on initial load and on every client-side rescope. function resolveViewMode() { var qs = new URLSearchParams(window.location.search); var explicit = (qs.get('view') || '').toLowerCase(); var grid = window.app.modules.grid; var classifierHere = !!(grid && grid.availableHere && grid.availableHere()); if (explicit === 'grid') return classifierHere ? 'grid' : 'browse'; if (explicit === 'browse') return 'browse'; return classifierHere ? 'grid' : 'browse'; } function applyResolvedViewMode() { var mode = resolveViewMode(); state.viewMode = mode; var browseView = document.getElementById('browseView'); var gridView = document.getElementById('gridView'); if (mode === 'grid') { if (browseView) browseView.classList.add('hidden'); if (gridView) gridView.classList.remove('hidden'); var grid = window.app.modules.grid; if (grid) { if (grid.reset) grid.reset(); if (grid.activate) grid.activate(); } } else { if (browseView) browseView.classList.remove('hidden'); if (gridView) gridView.classList.add('hidden'); } } async function navigateIntoFolder(node) { if (state.source === 'server') { // Rescope client-side rather than hard-navigating. A hard // nav would let zddc-server's auto-serve kick in and swap // us out of browse for canonical folders (e.g. /archive/ // → archive tool, /staging/ → transmittal). Staying in // browse is what the user asked for; pushState keeps the // URL bar accurate so a reload would re-load browse at the // new scope. var url = window.app.modules.tree.pathFor(node); if (!url.endsWith('/')) url += '/'; await rescopeServer(url, node.name); return; } if (state.source === 'fs') { if (!node.handle || node.handle.kind !== 'directory') return; state.rootHandle = node.handle; state.currentPath = node.handle.name + '/'; var raw; try { raw = await loader.fetchFsChildren(node.handle); } catch (e) { statusError('Failed to enter ' + node.name + ': ' + e.message); return; } tree.setRoot(raw); tree.render(); statusInfo('Entered ' + node.name); } } // Client-side rescope for server mode. Updates the URL via // history.pushState, fetches the new directory listing, and // re-renders the tree from scratch. Page DOES NOT reload. async function rescopeServer(url, displayName) { var entries; try { entries = await loader.fetchServerChildren(url); } catch (e) { statusError('Failed to enter ' + displayName + ': ' + (e.message || e)); return; } state.currentPath = url; // Selection / preview belong to the old scope; clear them so // the new root doesn't carry stale highlight state. state.selectedId = null; state.lastPreviewedNodeId = null; // Virtual canonical folders are emitted by zddc-server itself // (so .zddc display: overrides apply uniformly); no client-side // merge needed. tree.setRoot(entries); tree.render(); // Reset the preview pane so the user sees an "empty selection" // state at the new scope instead of the previous file. 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 }; })();