// 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; } function status(msg, kind) { var el = document.getElementById('statusBar'); if (!el) return; el.textContent = msg || ''; el.classList.remove('status-bar--error', 'status-bar--info'); if (kind === 'error') el.classList.add('status-bar--error'); if (kind === 'info') el.classList.add('status-bar--info'); } function statusError(msg) { status(msg, 'error'); } function statusInfo(msg) { status(msg, 'info'); } function statusClear() { status('', null); } 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'); } } } 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 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); }); // 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(); 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; } async function createInDir(parentDir, kind) { var up = window.app.modules.upload; if (!up) return; 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; 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. 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: !serverMode, action: function (c) { createInside(c.node, 'folder'); } }, { label: 'New markdown file', disabled: !serverMode, action: function (c) { createInside(c.node, 'markdown'); } }, { separator: true }, // ── Rename + Delete (the permission-gated pair) ── { label: 'Rename…', disabled: function (c) { return !canMutate(c); }, action: function (c) { renameNode(c.node); } }, { label: 'Delete…', icon: '🗑', danger: true, disabled: function (c) { return !canMutate(c); }, 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); } }, { 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; 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: !serverMode, action: function () { createInDir(state.currentPath || '/', 'folder'); } }, { label: 'New markdown file', disabled: !serverMode, 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; refreshListing(); } } ]; } // View mode is URL-driven, not UI-driven. // // ?view=grid → grid mode (only honored where classifier is // available; otherwise falls back to browse) // ?view=browse → browse mode (always) // default → path-based: grid when inside an incoming/ // subtree, browse everywhere else // // resolveViewMode reads the current location and returns the mode // to render; applyResolvedViewMode toggles the panes accordingly. // Called on initial load and on every client-side rescope. function resolveViewMode() { var qs = new URLSearchParams(window.location.search); var explicit = (qs.get('view') || '').toLowerCase(); var grid = window.app.modules.grid; var classifierHere = !!(grid && grid.availableHere && grid.availableHere()); if (explicit === 'grid') return classifierHere ? 'grid' : 'browse'; if (explicit === 'browse') return 'browse'; return classifierHere ? 'grid' : 'browse'; } function applyResolvedViewMode() { var mode = resolveViewMode(); state.viewMode = mode; var browseView = document.getElementById('browseView'); var gridView = document.getElementById('gridView'); if (mode === 'grid') { if (browseView) browseView.classList.add('hidden'); if (gridView) gridView.classList.remove('hidden'); var grid = window.app.modules.grid; if (grid) { if (grid.reset) grid.reset(); if (grid.activate) grid.activate(); } } else { if (browseView) browseView.classList.remove('hidden'); if (gridView) gridView.classList.add('hidden'); } } async function navigateIntoFolder(node) { if (state.source === 'server') { // Rescope client-side rather than hard-navigating. A hard // nav would let zddc-server's auto-serve kick in and swap // us out of browse for canonical folders (e.g. /archive/ // → archive tool, /staging/ → transmittal). Staying in // browse is what the user asked for; pushState keeps the // URL bar accurate so a reload would re-load browse at the // new scope. var url = window.app.modules.tree.pathFor(node); if (!url.endsWith('/')) url += '/'; await rescopeServer(url, node.name); return; } if (state.source === 'fs') { if (!node.handle || node.handle.kind !== 'directory') return; state.rootHandle = node.handle; state.currentPath = node.handle.name + '/'; var raw; try { raw = await loader.fetchFsChildren(node.handle); } catch (e) { statusError('Failed to enter ' + node.name + ': ' + e.message); return; } tree.setRoot(raw); tree.render(); statusInfo('Entered ' + node.name); } } // Client-side rescope for server mode. Updates the URL via // history.pushState, fetches the new directory listing, and // re-renders the tree from scratch. Page DOES NOT reload. async function rescopeServer(url, displayName) { var entries; try { entries = await loader.fetchServerChildren(url); } catch (e) { statusError('Failed to enter ' + displayName + ': ' + (e.message || e)); return; } state.currentPath = url; // Selection / preview belong to the old scope; clear them so // the new root doesn't carry stale highlight state. state.selectedId = null; state.lastPreviewedNodeId = null; // Virtual canonical folders are emitted by zddc-server itself // (so .zddc display: overrides apply uniformly); no client-side // merge needed. tree.setRoot(entries); tree.render(); // Reset the preview pane so the user sees an "empty selection" // state at the new scope instead of the previous file. var previewBody = document.getElementById('previewBody'); if (previewBody) previewBody.innerHTML = ''; var previewTitle = document.getElementById('previewTitle'); if (previewTitle) previewTitle.textContent = 'No file selected'; var previewMeta = document.getElementById('previewMeta'); if (previewMeta) previewMeta.textContent = ''; // pushState so the URL bar reflects the new scope. A real // reload would re-load browse at this URL (trailing slash → // ServeDirectory → embedded browse SPA). try { history.pushState({ zddcBrowse: true, path: url }, '', url); } catch (_e) { /* private browsing edge cases */ } 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 }; })();