From 509839dba961a5c5336afca275dd417d12f95935 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 07:41:23 -0500 Subject: [PATCH] chore(embedded): cut v0.0.27-beta --- zddc/internal/apps/embedded/archive.html | 2 +- zddc/internal/apps/embedded/browse.html | 2692 ++++++++++++------ zddc/internal/apps/embedded/classifier.html | 2 +- zddc/internal/apps/embedded/index.html | 2 +- zddc/internal/apps/embedded/transmittal.html | 2 +- zddc/internal/apps/embedded/versions.txt | 14 +- zddc/internal/handler/tables.html | 2 +- 7 files changed, 1874 insertions(+), 842 deletions(-) diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index c025352..15a9f0d 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.27-beta · 2026-06-03 18:26:16 · f723323 + v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index b752ecf..c3037d2 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -1567,6 +1567,68 @@ body { color: var(--text); } +/* Per-row "⋯" actions button — the visible affordance that a row has a + context menu. Hidden until the row is hovered/selected or the button + itself is keyboard-focused, so it stays out of the way during reading + but is discoverable without knowing to right-click. Pushed to the right + edge; never part of the tab order (rows use roving tabindex). */ +.tree-row__kebab { + margin-left: auto; + align-self: flex-start; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.4rem; + height: 1.4rem; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted, #888); + border-radius: var(--radius); + cursor: pointer; + opacity: 0; + transition: opacity 0.1s, background 0.1s, color 0.1s; +} +.tree-row__kebab svg { width: 1em; height: 1em; } +.tree-row:hover .tree-row__kebab, +.tree-row.is-selected .tree-row__kebab, +.tree-row__kebab:focus-visible { + opacity: 1; +} +.tree-row__kebab:hover, +.tree-row__kebab:focus-visible { + background: var(--bg-hover); + color: var(--text); +} + +/* Tree-pane toolbar controls row (New folder/file, Sort, Show hidden), + sitting under the filter input. */ +.tree-pane__controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin-top: 0.4rem; +} +.tree-pane__controls .tp-control { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + color: var(--text-muted, #888); +} +.tree-pane__controls .tp-control--check { cursor: pointer; } +.tree-pane__controls select { + font-family: var(--font); + font-size: 0.8rem; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.15rem 0.3rem; +} + /* Per-row drop target highlight: applied while a file/folder drag is hovering this row. The dashed outline reads as "drop here" without shifting layout. */ @@ -2484,7 +2546,7 @@ body {
ZDDC Browse - v0.0.27-beta · 2026-06-03 18:26:16 · f723323 + v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
@@ -2535,6 +2597,25 @@ body { aria-label="Filter the tree by name, tracking number, status, revision, or title" autocomplete="off" spellcheck="false"> +
+ + + + +
@@ -2588,10 +2669,16 @@ body {
Recursive expand or collapse — the whole subtree.
Click a file
Preview it in the right pane.
-
Right-click any row
-
Opens a context menu with Open, Download, Copy path, Sort, and - folder-specific actions. Toggle items show a ✓ when active; submenus - open on hover.
+
Row actions — right-click, ⋯, or the menu key
+
Right-click a row, click the ⋯ button that appears on hover, or + press the menu key (or Shift+F10) on the selected row. The menu only + lists actions that apply to that item; actions you can see but can't + use yet (you lack write/create access, or they're for project or site + administrators) appear greyed with a reason — so you can see what a + higher role unlocks.
+
Toolbar (above the tree)
+
Filter, New folder / New file (created in the current directory), + Sort order, and Show hidden files all live here.
⤴ Pop out
Open the current preview in a separate window — useful for a second monitor.
@@ -6602,6 +6689,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // one path instead of two. + '' + '' + + '' + // Horizontal three-dot "kebab" — the per-row actions affordance. + + '' + + '' + + '' + + '' + ''; var injected = false; @@ -7150,8 +7243,27 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // scopeDropTarget: cascade's drop_target at currentPath // scopeDefaultTool: cascade's default_tool at currentPath // (empty when no default declared) + // scopeCanonicalFolder: cascade's canonical-folder slot + // ('incoming'|'received'|'working'|'staging'|…), + // drives scope-aware menu items + // scopeOnPlanReview: cascade above has an on_plan_review block + // All refreshed by loader.js from response headers on each fetch. scopeDropTarget: false, scopeDefaultTool: '', + scopeCanonicalFolder: '', + scopeOnPlanReview: false, + + // Prefetched /.profile/access view for the CURRENT scope + // (state.currentPath), via cap.at() — memoised. Supplies + // path_verbs / path_is_admin / path_roles to the menu model for + // pane-scope create gating and the admin/sub-admin tier items, so + // the menu never fetches at open time. null until prefetched / in + // FS-Access (offline) mode. + scopeAccess: null, + + // Whether the listing includes dotfiles. Toggled by the + // "Show hidden files" menu item; URL-persisted via ?hidden=1. + showHidden: false, // Autofilter — when non-empty, the tree hides files that // don't match and folders whose subtree has no matches. @@ -7162,6 +7274,856 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr }; })(); +// util.js — small browse-local helpers shared across the tool's modules. +// +// Consolidates copies that had drifted across modules: escapeHtml (some +// variants escaped single-quotes and handled null, others didn't), the +// SHA-256 content hasher (byte-identical in both editors), ISO-date and +// YAML-quote helpers (duplicated across the workflow modals), the +// /.profile/access email lookup, byte-size formatting, and the editor +// save/zip-member primitives. Attaches to window.app.modules.util — no new +// global (per the two-globals rule). Concatenated right after init.js so +// it's present when every later module's IIFE runs. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + // Escape a value for HTML text/attribute insertion. Escapes all five + // significant characters (including the single quote, which some call + // sites need for single-quoted attributes) and treats null/undefined + // as an empty string. Strict superset of every previous local copy. + function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return ({ + '&': '&', '<': '<', '>': '>', + '"': '"', "'": ''' + })[c]; + }); + } + + // SHA-256 hex of a string, or null when WebCrypto is unavailable. + // Used to gate editor dirty-state. + async function hashContent(text) { + if (!window.crypto || !window.crypto.subtle) return null; + var enc = new TextEncoder().encode(text); + var buf = await window.crypto.subtle.digest('SHA-256', enc); + var bytes = new Uint8Array(buf); + var hex = ''; + for (var i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0'); + } + return hex; + } + + function pad2(n) { return ('0' + n).slice(-2); } + function fmtIsoDate(d) { + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); + } + // YYYY-MM-DD for today / today + N days (local time). + function isoDateToday() { return fmtIsoDate(new Date()); } + function isoDatePlus(days) { + var d = new Date(); + d.setDate(d.getDate() + days); + return fmtIsoDate(d); + } + + // Double-quoted YAML scalar with backslash + quote escaping. Enough for + // the email/string fields the workflow modals emit. + function yamlQuote(s) { + return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; + } + + // GET /.profile/access → [email] for datalist suggestions. Best-effort: + // returns [] on any error so callers can populate a datalist blind. + async function fetchAccessEmails() { + try { + var r = await fetch('/.profile/access', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + if (!r.ok) return []; + var d = await r.json(); + return (d && d.email) ? [d.email] : []; + } catch (_e) { return []; } + } + + function fmtSize(bytes) { + if (bytes == null) return ''; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; + } + + // A file living inside a .zip is read-only: a ZipFileHandle refuses + // createWritable (offline / nested) and zddc-server refuses writes to a + // "<…>.zip/" URL (405). + function isZipMemberNode(node) { + if (node.handle && node.handle.isZipEntry) return true; + if (node.url && window.app.state.source === 'server' + && /\.zip\//i.test(node.url)) return true; + return false; + } + + // Thrown by saveFile when the server rejects a write with 412 + // Precondition Failed — the file changed under us since we loaded it. + // Callers branch on `.status === 412` to open the conflict UI instead + // of treating it as a generic save failure. + function ConflictError(message) { + var e = new Error(message || 'Conflict: file changed on server'); + e.name = 'ConflictError'; + e.status = 412; + return e; + } + + // Write content back to a file's source, returning { etag } (the new + // server ETag, or null in FS-Access mode). Local (FS-Access) folders are + // picked read-only, so the first write escalates to readwrite via + // upload.ensureWritable (one permission prompt, then granted for the + // session). contentType sets the PUT Content-Type for server files. + // + // opts (server mode only): + // etag — send as `If-Match` so the master 412s if the file + // changed since we observed this version (optimistic + // concurrency; preferred — exact). + // lastModified — fallback precondition sent as `If-Unmodified-Since` + // (raw HTTP-date string) when no etag is available. + // force — skip the precondition entirely (deliberate overwrite). + // + // Throws ConflictError (.status===412) on a precondition failure, a + // plain Error('HTTP ') on any other non-2xx, or "no write + // target" when the source is read-only. + async function saveFile(node, content, contentType, opts) { + opts = opts || {}; + if (node.handle && typeof node.handle.createWritable === 'function') { + var up = window.app.modules.upload; + if (up && up.ensureWritable) await up.ensureWritable(); + var writable = await node.handle.createWritable(); + await writable.write(content); + await writable.close(); + return { etag: null }; + } + if (node.url && window.app.state.source === 'server') { + var headers = { 'Content-Type': contentType }; + if (!opts.force) { + if (opts.etag) headers['If-Match'] = opts.etag; + else if (opts.lastModified) headers['If-Unmodified-Since'] = opts.lastModified; + } + var resp = await fetch(node.url, { + method: 'PUT', + headers: headers, + body: content, + credentials: 'same-origin' + }); + if (resp.status === 412) throw ConflictError(); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return { etag: resp.headers.get('ETag') || null }; + } + throw new Error('No write target for this file (read-only source).'); + } + + // Write `content` to a NEW sibling of `node` named + // `-conflict-.` (server mode only), so a + // conflicting edit can be parked without losing either version. Probes + // for a free name (numeric-suffix bump, capped) so a same-second retry + // doesn't clobber a prior copy. Returns the created filename. The PUT + // uses no precondition — it's a brand-new path. + async function saveCopy(node, content, contentType) { + if (!(node.url && window.app.state.source === 'server')) { + throw new Error('Save a copy is only available for server files.'); + } + var split = window.zddc.splitExtension(node.name); + var stem = split.name || node.name; + var ext = split.extension; + var d = new Date(); + var stamp = d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) + + '-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds()); + var base = stem + '-conflict-' + stamp; + var slash = node.url.lastIndexOf('/'); + var dirUrl = slash >= 0 ? node.url.slice(0, slash + 1) : ''; + var name = '', candidateUrl = ''; + for (var i = 0; i < 20; i++) { + name = window.zddc.joinExtension(base + (i ? '-' + (i + 1) : ''), ext); + candidateUrl = dirUrl + encodeURIComponent(name); + var head; + try { + head = await fetch(candidateUrl, { method: 'HEAD', credentials: 'same-origin' }); + } catch (_e) { + break; // network unknown — attempt the write rather than spin + } + if (head.status === 404) break; // free slot + if (head.status !== 200) break; // HEAD unsupported / odd — attempt anyway + if (i === 19) throw new Error('Could not find a free filename for the copy.'); + } + await saveFile({ url: candidateUrl, name: name, ext: ext }, content, contentType, { force: true }); + return name; + } + + window.app.modules.util = { + escapeHtml: escapeHtml, + hashContent: hashContent, + isoDateToday: isoDateToday, + isoDatePlus: isoDatePlus, + yamlQuote: yamlQuote, + fetchAccessEmails: fetchAccessEmails, + fmtSize: fmtSize, + isZipMemberNode: isZipMemberNode, + saveFile: saveFile, + saveCopy: saveCopy, + ConflictError: ConflictError + }; +})(); + +// conflict.js — shared conflict-resolution dialog for the browse tool. +// +// Surfaced when a save loses an optimistic-concurrency race: the file +// changed on the server since the user loaded it (the editor sends an +// If-Match precondition; the master replies 412). Rather than clobber the +// other writer, the editor opens this dialog showing a mine-vs-theirs diff +// and four choices. +// +// Deliberately CALLBACK-DRIVEN: it never calls saveFile / showFilePreview +// itself — the caller supplies onOverwrite / onReload / onSaveCopy. That +// keeps it reusable by a second consumer (the deferred Phase 5 cache-outbox +// conflict UI, which would resolve `.zddc-outbox/.conflict-/` entries +// against new server endpoints rather than the live file). +// +// Reuses the modal shell + diff markup conventions from history.js and the +// shared css/history.css classes (md-history-*, md-diff-*) — no new CSS. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + function toast(msg, level) { + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast(msg, level || 'info'); + } + } + + // Render a line diff of base→mine into `pane` (theirs treated as the + // base, so additions are what this save would introduce). Mirrors the + // history.js diff view. + function renderDiff(pane, theirsText, mineText) { + pane.innerHTML = ''; + var ops = (window.zddc && window.zddc.diff) + ? window.zddc.diff.lines(theirsText, mineText) + : null; + var diff = document.createElement('div'); + diff.className = 'md-diff'; + if (!ops) { + diff.textContent = 'Diff unavailable (diff module not loaded).'; + pane.appendChild(diff); + return; + } + var unchanged = true; + ops.forEach(function (op) { + if (op.type !== 'eq') unchanged = false; + var line = document.createElement('div'); + line.className = 'md-diff-line md-diff-' + op.type; + var g = document.createElement('span'); + g.className = 'md-diff-gutter'; + g.textContent = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' '); + var t = document.createElement('span'); + t.className = 'md-diff-text'; + t.textContent = op.text; + line.appendChild(g); + line.appendChild(t); + diff.appendChild(line); + }); + if (unchanged) { + var same = document.createElement('div'); + same.className = 'md-diff-line md-diff-eq'; + same.textContent = '(no differences — your copy matches the server)'; + diff.appendChild(same); + } + pane.appendChild(diff); + var s = window.zddc.diff.stats(ops); + var stat = document.createElement('p'); + stat.className = 'md-history-hint'; + stat.textContent = 'Your version vs. current server: +' + s.added + ' / −' + s.removed; + pane.appendChild(stat); + } + + // open(opts) → Promise<'overwrite' | 'reload' | 'savecopy' | 'cancel'> + // + // opts: + // filename — display name (e.g. node.name) + // mineText — the user's current (unsaved) content, for the diff + // theirsText — current server content (string), OR… + // fetchTheirs — async () => string — lazy fetch of current server content + // onOverwrite — async () => void — re-save, forcing past the conflict + // onReload — async () => void — discard mine, reload from server + // onSaveCopy — async () => void — write mine to a sibling path (optional) + // + // The matching callback runs when its button is clicked; on success the + // dialog closes and resolves with the action name. On callback error the + // dialog stays open (a toast explains) so the user can pick another path. + // Cancel / Esc / backdrop resolve 'cancel' and leave the editor untouched. + function open(opts) { + opts = opts || {}; + return new Promise(function (resolve) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay md-history-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom: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.className = 'md-history-box'; + var title = document.createElement('h2'); + title.className = 'md-history-title'; + title.textContent = 'Conflict — ' + (opts.filename || 'file'); + var body = document.createElement('div'); + body.className = 'md-history-body'; + box.appendChild(title); + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + + var settled = false; + function close() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + document.removeEventListener('keydown', onKey); + } + function finish(result) { + if (settled) return; + settled = true; + close(); + resolve(result); + } + function onKey(e) { if (e.key === 'Escape') finish('cancel'); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('mousedown', function (e) { + if (e.target === overlay) finish('cancel'); + }); + + var hint = document.createElement('p'); + hint.className = 'md-history-hint'; + hint.textContent = '"' + (opts.filename || 'This file') + + '" was changed by someone else since you opened it. ' + + 'Pick how to resolve — nothing is saved until you choose.'; + body.appendChild(hint); + + var diffPane = document.createElement('div'); + diffPane.textContent = 'Loading current server version…'; + body.appendChild(diffPane); + + var footer = document.createElement('div'); + footer.className = 'md-history-footer'; + body.appendChild(footer); + + function makeBtn(label, primary) { + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + if (primary) b.className = 'btn-primary'; + footer.appendChild(b); + return b; + } + var overwriteBtn = makeBtn('Overwrite (keep mine)'); + var reloadBtn = makeBtn('Discard mine — reload theirs'); + var copyBtn = opts.onSaveCopy ? makeBtn('Save a copy') : null; + var cancelBtn = makeBtn('Cancel', true); + + function setBusy(busy) { + [overwriteBtn, reloadBtn, copyBtn, cancelBtn].forEach(function (b) { + if (b) b.disabled = busy; + }); + } + + // Each action runs its callback; on success close+resolve, on + // error toast and re-enable so the user can try another path. + function wire(btn, fn, result) { + if (!btn) return; + btn.addEventListener('click', function () { + setBusy(true); + Promise.resolve() + .then(function () { return fn ? fn() : undefined; }) + .then(function () { finish(result); }) + .catch(function (e) { + toast('Could not ' + result + ': ' + (e && e.message ? e.message : e), 'error'); + setBusy(false); + }); + }); + } + wire(overwriteBtn, opts.onOverwrite, 'overwrite'); + wire(reloadBtn, opts.onReload, 'reload'); + wire(copyBtn, opts.onSaveCopy, 'savecopy'); + cancelBtn.addEventListener('click', function () { finish('cancel'); }); + + // Resolve the "theirs" text (eagerly provided or lazily fetched) + // then render the diff. A fetch failure leaves the actions usable + // — the diff is an aid, not a gate. + Promise.resolve() + .then(function () { + if (typeof opts.theirsText === 'string') return opts.theirsText; + if (opts.fetchTheirs) return opts.fetchTheirs(); + return null; + }) + .then(function (theirs) { + if (settled) return; + if (theirs == null) { + diffPane.textContent = 'Could not load the current server version for comparison.'; + return; + } + renderDiff(diffPane, theirs, opts.mineText || ''); + }) + .catch(function (e) { + if (settled) return; + diffPane.textContent = 'Could not load the current server version: ' + + (e && e.message ? e.message : e); + }); + }); + } + + window.app.modules.conflict = { open: open }; +})(); + +// menu-model.js — the declarative source of truth for the browse tool's +// action menus (right-click row menu, right-click pane menu, the keyboard +// menu key, and the hover kebab). +// +// Every action is declared ONCE as a descriptor. The row/pane menus are +// projections over that list, filtered by surface + an `appliesTo` TYPE +// predicate and annotated with an `enabled` CAPABILITY predicate: +// +// appliesTo(ctx) === false → the item is OMITTED (it doesn't make sense +// for this target — e.g. "New folder" on a +// file row, "Expand" on a file). +// appliesTo true, enabled +// (ctx) === false → the item is SHOWN DISABLED with a tooltip +// naming what's required (write access / +// create access / project-admin / site-admin). +// +// That hybrid realizes the cumulative guest ⊂ project-team ⊂ sub-admin ⊂ +// admin menus: a lower tier SEES higher-tier actions greyed and learns they +// exist, while type-irrelevant noise is hidden. +// +// Roles are NOT hardcoded: ordinary actions gate on the verbs the server +// returns per entry (node.verbs) or per scope (cap.at → path_verbs), so any +// operator-defined role works. Only two intrinsically-special tiers are +// recognised by name — site admin (is_super_admin / IsAdmin) and project / +// subtree admin (path_is_admin / IsSubtreeAdmin) — because they govern +// administration itself and can't be expressed as a plain verb bundle. +// +// Deliberately data-shaped so a future server-sourced manifest (zddc.zip) +// can supply or extend the descriptors without touching the tool code. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + var state = window.app.state; + + // Action implementations are injected by events.init() via configure() + // to avoid an events ↔ menu-model circular dependency. Everything else + // (tree, preview, download, workflow modules) is reached through + // window.app.modules at call time. + var act = {}; + function configure(a) { act = a || {}; } + + // ── Predicates ──────────────────────────────────────────────────────── + + function isServer() { return state.source === 'server'; } + function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); } + function appliesToFile(node) { return !!(node && !node.isDir && !node.isZip); } + + // Formats the Export submenu offers for a file (server-side conversion): + // a file of one of these extensions can be exported as the other two. + var EXPORT_FORMATS = ['md', 'docx', 'html']; + function cap() { return window.zddc && window.zddc.cap; } + + function canVerb(node, verb) { + return !!(node && cap() && cap().has(node, verb)); + } + function pathHasVerb(access, verb) { + return !!(access && typeof access.path_verbs === 'string' + && access.path_verbs.indexOf(verb) !== -1); + } + function isSiteAdmin(access) { return !!(access && access.is_super_admin); } + function isSubtreeAdminHere(access) { return !!(access && access.path_is_admin); } + + // Create / mutate / admin actions are HIDDEN when the user can't perform + // them (capability folded into appliesTo), so these gates only need the + // boolean — the `missing` field is retained for potential future tooltips. + + // Rename/Delete gate — preserves today's compose exactly: canMutate rules + // out un-writable sources (offline FS without a handle, zip members, + // virtual placeholders) with no tooltip; when the server cascade reports + // verbs, the per-entry ACL bit gates with a tooltip. FS / Caddy (no verbs + // field) fall back to canMutate alone. Returns { enabled, missing }. + function verbGate(node, verb) { + var up = window.app.modules.upload; + if (!up || !up.canMutate(node)) return { enabled: false, missing: '' }; + if (!isServer() || !cap()) return { enabled: true, missing: '' }; + if (typeof node.verbs !== 'string') return { enabled: true, missing: '' }; + if (cap().has(node, verb)) return { enabled: true, missing: '' }; + return { enabled: false, missing: verb }; + } + + // Create gate (New folder / New file). canCreateHere() rules out the + // no-target case (offline FS without a picked handle) — no tooltip there. + // In server mode, gate on the 'c' verb: per-node for a folder row, per + // scope for the pane. Unknown verbs → optimistic (server is the final + // arbiter, surfacing 403 via cap.handleForbidden, exactly as today). + function createGate(ctx) { + if (!act.canCreateHere || !act.canCreateHere()) return { enabled: false, missing: '' }; + if (!isServer()) return { enabled: true, missing: '' }; + if (ctx.node) { // folder-row create → inside this folder + if (typeof ctx.node.verbs === 'string') { + return canVerb(ctx.node, 'c') + ? { enabled: true, missing: '' } + : { enabled: false, missing: 'c' }; + } + return { enabled: true, missing: '' }; + } + // pane create → current scope + if (ctx.access && typeof ctx.access.path_verbs === 'string') { + return pathHasVerb(ctx.access, 'c') + ? { enabled: true, missing: '' } + : { enabled: false, missing: 'c' }; + } + return { enabled: true, missing: '' }; + } + + // "Edit access rules" (.zddc) — the sub-admin / site-admin tier item. + // Enabled per-node when the entry grants the admin verb 'a', else by the + // scope's subtree-admin / site-admin status (admin authority cascades + // down a subtree). Returns { enabled, missing }. + function manageAccessGate(ctx) { + if (ctx.node && canVerb(ctx.node, 'a')) return { enabled: true, missing: '' }; + if (isSubtreeAdminHere(ctx.access) || isSiteAdmin(ctx.access)) return { enabled: true, missing: '' }; + return { enabled: false, missing: 'subtree-admin' }; + } + + function insideZip(node) { + // Creating inside a zip member is impossible — the server can't PUT + // into an archive. Mirror tree.zipNestedInsideZip's URL heuristic. + if (!node) return false; + if (node.url && /\.zip\//i.test(node.url)) return true; + if (node.handle && node.handle.isZipEntry) return true; + return false; + } + + // ── Descriptors ───────────────────────────────────────────────────────── + // group order = visual order; a separator is inserted on each group change + // among the items that actually render (context-menu.js collapses extras). + var DESCRIPTORS = [ + // ── open ── + { + id: 'open', group: 'open', surfaces: ['row'], + label: function (ctx) { + if (ctx.node.isDir) return 'Open'; + if (ctx.node.isZip) return 'Open archive'; + return 'Preview'; + }, + appliesTo: function (ctx) { return !ctx.node.virtual; }, + action: function (ctx) { + if (ctx.node.isDir) { + // Open = navigate into the folder (rescope). Inline + // expand stays on single-click / chevron / arrow keys. + if (act.navigateIntoFolder) act.navigateIntoFolder(ctx.node); + } else if (ctx.node.isZip) { + // A zip can't be navigated into — expand it inline. + var t = window.app.modules.tree; + if (t) t.toggleFolder(ctx.node.id); + } else { + var p = window.app.modules.preview; + if (p) p.showFilePreview(ctx.node); + } + } + }, + { + id: 'open-new-tab', group: 'open', surfaces: ['row'], + label: 'Open in new tab', accel: 'Ctrl+Click', + appliesTo: function (ctx) { return !!ctx.node.url; }, + action: function (ctx) { window.open(ctx.node.url, '_blank', 'noopener'); } + }, + { + id: 'popout', group: 'open', surfaces: ['row'], + label: 'Pop out preview', + appliesTo: function (ctx) { return appliesToFile(ctx.node) && !ctx.node.virtual; }, + action: function (ctx) { + var p = window.app.modules.preview; + if (p) p.showFilePreview(ctx.node, { popup: true }); + } + }, + + // ── io ── + { + id: 'download', group: 'io', surfaces: ['row'], + label: function (ctx) { return ctx.node.isDir ? 'Download ZIP' : 'Download'; }, + appliesTo: function (ctx) { return !ctx.node.virtual; }, + action: function (ctx) { + var d = window.app.modules.download; + if (!d) return; + if (ctx.node.isDir) d.downloadFolder(ctx.node); + else d.downloadFile(ctx.node); + } + }, + { + // Export submenu: a folder offers ".zip" (both modes); a md/docx/html + // file offers the OTHER two formats (server-side conversion, so + // server mode only). A zip is already an archive — no Export. + id: 'export', group: 'io', surfaces: ['row'], + label: 'Export', + appliesTo: function (ctx) { + var n = ctx.node; + if (!n || n.virtual) return false; + if (n.isDir) return true; + if (n.isZip) return false; + return isServer() && EXPORT_FORMATS.indexOf((n.ext || '').toLowerCase()) !== -1; + }, + items: function (ctx) { + var n = ctx.node; + var d = window.app.modules.download; + if (!d) return []; + if (n.isDir) { + return [{ label: '.zip', action: function () { d.downloadFolder(n); } }]; + } + var cur = (n.ext || '').toLowerCase(); + return EXPORT_FORMATS.filter(function (f) { return f !== cur; }).map(function (fmt) { + return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } }; + }); + } + }, + + // ── create (folder rows + pane; NOT file rows) ── + // Create actions are HIDDEN unless the user can create here (the + // capability is folded into appliesTo, not greyed). On a row they + // apply to folders only (create inside); on the pane, to the scope. + { + id: 'new-folder', group: 'create', surfaces: ['row', 'pane'], + label: 'New folder', + appliesTo: function (ctx) { + var typeOk = ctx.surface === 'pane' + || (appliesToFolderLike(ctx.node) && !insideZip(ctx.node)); + return typeOk && createGate(ctx).enabled; + }, + action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'folder'); } + }, + { + id: 'new-file', group: 'create', surfaces: ['row', 'pane'], + label: 'New file', + appliesTo: function (ctx) { + var typeOk = ctx.surface === 'pane' + || (appliesToFolderLike(ctx.node) && !insideZip(ctx.node)); + return typeOk && createGate(ctx).enabled; + }, + action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'markdown'); } + }, + { + id: 'create-transmittal', group: 'create', surfaces: ['pane'], + label: 'Create Transmittal folder…', + appliesTo: function () { return isServer() && state.scopeCanonicalFolder === 'staging'; }, + action: function () { + var ct = window.app.modules.createTransmittal; + if (ct) ct.invoke(); + } + }, + + // ── mutate (HIDDEN unless permitted — capability folded into appliesTo) ── + { + id: 'rename', group: 'mutate', surfaces: ['row'], + label: 'Rename…', + appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'w').enabled; }, + action: function (ctx) { if (act.renameNode) act.renameNode(ctx.node); } + }, + { + id: 'delete', group: 'mutate', surfaces: ['row'], danger: true, + label: 'Delete…', + appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'd').enabled; }, + action: function (ctx) { if (act.deleteNode) act.deleteNode(ctx.node); } + }, + + // ── treeops (folder/zip rows only) ── + { + id: 'expand-subtree', group: 'treeops', surfaces: ['row'], + label: 'Expand subtree', accel: 'Shift+Click', + appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); }, + action: function (ctx) { + var t = window.app.modules.tree; + if (t) t.expandSubtree(ctx.node.id); + } + }, + { + id: 'collapse-subtree', group: 'treeops', surfaces: ['row'], + label: 'Collapse subtree', + appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); }, + action: function (ctx) { + var t = window.app.modules.tree; + if (t) t.collapseSubtree(ctx.node.id); + } + }, + + // ── workflow (already type+scope gated → omitted when N/A) ── + { + id: 'plan-review', group: 'workflow', surfaces: ['row'], + label: 'Plan Review…', + appliesTo: function (ctx) { + if (!isServer() || !state.scopeOnPlanReview) return false; + var pr = window.app.modules.planReview; + return !!(pr && pr.isReceivedTrackingFolder(ctx.node)); + }, + action: function (ctx) { + var pr = window.app.modules.planReview; + if (pr) pr.invoke(ctx.node); + } + }, + { + id: 'accept-transmittal', group: 'workflow', surfaces: ['row'], + label: 'Accept Transmittal…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var at = window.app.modules.acceptTransmittal; + return !!(at && at.isAcceptableTransmittalFolder(ctx.node)); + }, + action: function (ctx) { + var at = window.app.modules.acceptTransmittal; + if (at) at.invoke(ctx.node); + } + }, + { + id: 'stage', group: 'workflow', surfaces: ['row'], + label: 'Stage to…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var s = window.app.modules.stage; + return !!(s && s.isStageableFile(ctx.node)); + }, + action: function (ctx) { + var s = window.app.modules.stage; + if (s) s.invokeStage(ctx.node); + } + }, + { + id: 'unstage', group: 'workflow', surfaces: ['row'], + label: 'Unstage to working/', + appliesTo: function (ctx) { + if (!isServer()) return false; + var s = window.app.modules.stage; + return !!(s && s.isUnstageableFile(ctx.node)); + }, + action: function (ctx) { + var s = window.app.modules.stage; + if (s) s.invokeUnstage(ctx.node); + } + }, + { + id: 'history', group: 'workflow', surfaces: ['row'], + label: 'History…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var n = ctx.node; + return appliesToFile(n) && !n.virtual && !!n.history; + }, + action: function (ctx) { + var h = window.app.modules.history; + if (h) h.open(ctx.node); + } + }, + + // ── admin / sub-admin tier ── + { + // HIDDEN unless the user can actually edit access rules here + // (admin verb 'a', or subtree/site admin) — not shown greyed. + id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'], + label: 'Edit access rules…', + appliesTo: function (ctx) { + if (!isServer()) return false; // server-only tier + var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node); + return typeOk && manageAccessGate(ctx).enabled; + }, + action: function (ctx) { openZddcEditor(ctx.dir); } + }, + + // ── view (pane) ── + { + id: 'refresh', group: 'view', surfaces: ['pane'], + label: 'Refresh', accel: 'F5', + action: function () { if (act.refreshListing) act.refreshListing(); } + } + ]; + + // Open the `.zddc` for `dir` in the YAML editor. Prefer an existing tree + // node (carries verbs/virtual flags) else synthesize one; the yaml plugin + // recognises name === '.zddc' and gates the save on the admin verb 'a'. + function openZddcEditor(dir) { + var url = (dir || '/'); + if (!url.endsWith('/')) url += '/'; + url += '.zddc'; + var found = null; + var t = window.app.modules.tree; + state.nodes.forEach(function (n) { + if (found || n.name !== '.zddc' || !t) return; + if (t.pathFor(n) === url) found = n; + }); + var node = found || { url: url, name: '.zddc', ext: '' }; + var p = window.app.modules.preview; + if (p) p.showFilePreview(node); + } + + // ── Projection ──────────────────────────────────────────────────────── + + function resolve(v, ctx) { return typeof v === 'function' ? v(ctx) : v; } + function resolveBool(v, ctx, dflt) { + if (v === undefined) return dflt; + return !!(typeof v === 'function' ? v(ctx) : v); + } + + function toMenuItem(d, ctx) { + var item = { + label: resolve(d.label, ctx), + accel: d.accel, + danger: d.danger, + // disabled / tooltip ignore the menu's own context arg — ctx is + // already captured here with the richer browse context. + disabled: function () { return !resolveBool(d.enabled, ctx, true); }, + tooltip: function () { + return resolveBool(d.enabled, ctx, true) ? '' : (resolve(d.tooltip, ctx) || ''); + } + }; + // A descriptor with `items` becomes a submenu (resolved against the + // captured browse ctx); otherwise it's a normal action row. + if (d.items) { + item.items = function () { return resolve(d.items, ctx); }; + } else { + item.action = function () { if (d.action) d.action(ctx); }; + } + return item; + } + + function project(surface, ctx) { + var out = []; + var lastGroup = null; + for (var i = 0; i < DESCRIPTORS.length; i++) { + var d = DESCRIPTORS[i]; + if (d.surfaces.indexOf(surface) === -1) continue; + if (!resolveBool(d.appliesTo, ctx, true)) continue; + if (lastGroup !== null && d.group !== lastGroup) out.push({ separator: true }); + lastGroup = d.group; + out.push(toMenuItem(d, ctx)); + } + return out; // context-menu.js collapses leading/trailing/dup separators + } + + function buildRowItems(node, row, access) { + var dir = act.parentDirFor ? act.parentDirFor(node) : (state.currentPath || '/'); + return project('row', { node: node, row: row, surface: 'row', dir: dir, access: access }); + } + function buildPaneItems(access) { + var dir = state.currentPath || '/'; + return project('pane', { node: null, row: null, surface: 'pane', dir: dir, access: access }); + } + + window.app.modules.menuModel = { + configure: configure, + buildRowItems: buildRowItems, + buildPaneItems: buildPaneItems, + DESCRIPTORS: DESCRIPTORS // exposed for tests + }; +})(); + // loader.js — fetches directory entries for either source mode. // // Server mode: GET with Accept: application/json. zddc-server @@ -7175,10 +8137,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var state = window.app.state; + // Lowercased extension (no leading dot), '' for dotfiles / no-ext / + // trailing-dot names. Delegates to the shared parser so the rule + // stays in one place (CLAUDE.md: all extension handling goes through + // window.zddc). function splitExt(name) { - var i = name.lastIndexOf('.'); - if (i <= 0 || i === name.length - 1) return ''; - return name.substring(i + 1).toLowerCase(); + return window.zddc.splitExtension(name).extension; } // Build a raw entry from the server's FileInfo shape. @@ -7386,7 +8350,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr fetchServerChildren: fetchServerChildren, fetchFsChildren: fetchFsChildren, autoDetectServerMode: autoDetectServerMode, - splitExt: splitExt, ensureJSZip: ensureJSZip }; })(); @@ -7604,13 +8567,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // ── Rendering ──────────────────────────────────────────────────────── - function fmtSize(bytes) { - if (bytes == null) return ''; - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; - } + var fmtSize = window.app.modules.util.fmtSize; function fmtDate(d) { if (!d) return ''; @@ -7619,10 +8576,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); } - function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } + var escapeHtml = window.app.modules.util.escapeHtml; // Per-extension icon map → Lucide outline-icon sprite ids. The // actual SVG markup is produced by window.zddc.icons.html(id), @@ -7794,6 +8748,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr + '' + iconChar + extChip + '' + labelHtml(node) + virtualHint + // Kebab (⋯) — visible affordance that the row has actions; opens + // the same context menu. Revealed on hover/selection/focus (CSS). + // tabindex -1 keeps it out of the tab order (roving tabindex on + // the rows); reachable via right-click / the keyboard menu key. + + '' + ''; } @@ -7862,10 +8824,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr el.innerHTML = html; } - // Sort headers no longer exist in the DOM (the tree replaced the - // table); the tree.setSort() method still works but only via - // programmatic callers — there's no UI for changing sort yet. - // True when this .zip node lives inside another zip, so its bytes // can't be fetched as a standalone server resource: we read them // through the containing handle (offline / nested) or by fetching @@ -7914,7 +8872,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // it as a directory handle; members // become ordinary dir/file nodes async function loadChildren(node) { - if (node.loaded) return; + if (node.loaded || node.loading) return; + // In-flight guard: a folder can be (re)toggled while its first + // load is still pending — rapid Enter/ArrowRight key-repeat, or a + // double-click landing during a single-click's load. Without this, + // both calls pass the !loaded check and fire duplicate fetches that + // race in setChildren. The flag serializes per-node so the second + // caller is a no-op until the first resolves. + node.loading = true; try { if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) { setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/')); @@ -7934,6 +8899,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } catch (e) { window.app.modules.events.statusError( 'Failed to load ' + node.name + ': ' + e.message); + } finally { + node.loading = false; } } @@ -8083,15 +9050,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr loadChildren: loadChildren, snapshotState: snapshotState, restoreState: restoreState, - setSort: function (key) { - if (state.sort.key === key) { - state.sort.dir = -state.sort.dir; - } else { - state.sort.key = key; - state.sort.dir = 1; - } - render(); - }, // Set both key and direction explicitly. dir: 1 (asc) or -1 (desc). // Used by the toolbar's sort dropdown. setSortExplicit: function (key, dir) { @@ -8125,10 +9083,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr console.error('[browse] zddc.preview not loaded — preview disabled.'); } - function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } + var util = window.app.modules.util; + var escapeHtml = util.escapeHtml; var MIME = { 'pdf': 'application/pdf', @@ -8147,13 +9103,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr function getMime(ext) { return MIME[ext] || 'application/octet-stream'; } - function fmtSize(bytes) { - if (bytes == null) return ''; - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; - } + var fmtSize = util.fmtSize; async function getArrayBuffer(node) { // A zip member node carries a ZipFileHandle in node.handle, so @@ -8170,6 +9120,30 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr throw new Error('no source for file'); } + // Like getArrayBuffer, but also returns the server version token + // ({etag, lastModified}) captured from the content GET. The editors use + // it to send an If-Match precondition on save so a concurrent edit is + // rejected (412) instead of silently clobbered. FS-Access mode has no + // server version — etag/lastModified are null and the precondition is a + // clean no-op (a single locally-picked file has no concurrency). + async function getContentWithVersion(node) { + if (state.source === 'server' && node.url) { + var resp = await fetch(node.url, { credentials: 'same-origin' }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + var buf = await resp.arrayBuffer(); + return { + buf: buf, + etag: resp.headers.get('ETag') || null, + lastModified: resp.headers.get('Last-Modified') || null + }; + } + if (node.handle) { + var f = await node.handle.getFile(); + return { buf: await f.arrayBuffer(), etag: null, lastModified: null }; + } + throw new Error('no source for file'); + } + async function getBlobUrl(node) { // Server-served files (including zip members at "<…>.zip/" // URLs) load straight from the server — preserves Content-Type @@ -8182,8 +9156,62 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return { url: URL.createObjectURL(blob), fromServer: false }; } + // ── Editor lifecycle helpers ───────────────────────────────────────────── + // The markdown and YAML plugins each mount a long-lived editor into the + // preview pane. Switching files (or clearing the pane) must dispose the + // live editor first — otherwise the Toast UI instance, its DOM, and its + // document-level resizer listeners leak when we overwrite the container. + + function editorModules() { + var m = window.app.modules; + return [m.markdown, m.yamledit].filter(Boolean); + } + + function disposeEditors() { + editorModules().forEach(function (mod) { + if (mod.dispose) { try { mod.dispose(); } catch (_) { /* ignore */ } } + }); + } + + // The editor module (if any) holding unsaved edits, else null. + function dirtyEditor() { + var mods = editorModules(); + for (var i = 0; i < mods.length; i++) { + if (mods[i].isDirty && mods[i].isDirty()) return mods[i]; + } + return null; + } + + function samePreviewNode(a, b) { + if (!a || !b) return false; + if (a === b) return true; + if (a.url && b.url) return a.url === b.url; + return a.name === b.name && a.parentId === b.parentId; + } + + // Tear down any live editor and blank the pane. Used by callers that + // reset the preview directly (rescope, popstate) so they don't leak the + // editor or strand its dirty state. + function clearPreview() { + disposeEditors(); + var container = document.getElementById('previewBody'); + if (container) container.innerHTML = ''; + } + + // Warn before a full page unload (reload / close / external nav) drops + // unsaved editor changes. SPA-internal switches are guarded in + // renderInline; this catches the browser-level exit. + window.addEventListener('beforeunload', function (e) { + if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; } + }); + // ── Inline rendering ──────────────────────────────────────────────────── + // Bumped on every renderInline entry; a render that loses the race + // (a newer selection started while its bytes were in flight) bails + // before writing stale content into the shared pane. + var renderSeq = 0; + function renderEmpty(container, msg) { container.innerHTML = '
' + escapeHtml(msg) + '
'; } @@ -8193,13 +9221,37 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr + escapeHtml(msg) + ''; } - async function renderInline(node) { + async function renderInline(node, opts) { + opts = opts || {}; var container = document.getElementById('previewBody'); var titleEl = document.getElementById('previewTitle'); var metaEl = document.getElementById('previewMeta'); var popoutBtn = document.getElementById('previewPopout'); if (!container) return; + // Guard unsaved editor edits before we tear the editor down. + var dm = dirtyEditor(); + if (dm) { + var cur = dm.currentNode ? dm.currentNode() : null; + if (samePreviewNode(cur, node)) { + // Re-selecting the file we're already editing — don't reload + // and clobber the in-progress edits. + return; + } + if (opts.auto) { + // Keyboard/auto preview (cursor walking the tree): leave the + // dirty editor in place rather than prompting on every key. + return; + } + var label = cur ? cur.name : 'this file'; + if (!window.confirm('Discard unsaved changes to ' + label + '?')) return; + } + // Safe to replace the pane now: dispose any live editor so its + // instance + document-level listeners don't leak. + disposeEditors(); + + var seq = ++renderSeq; + if (titleEl) titleEl.textContent = node.name; if (metaEl) { var meta = []; @@ -8216,7 +9268,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr window.app.modules.markdown && typeof window.app.modules.markdown.render === 'function') { try { - await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer }); + await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); } catch (e) { renderError(container, 'Markdown render failed: ' + (e.message || e)); } @@ -8229,7 +9281,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var yamlMod = window.app.modules.yamledit; if (yamlMod && yamlMod.handles(node)) { try { - await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer }); + await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); } catch (e) { renderError(container, 'YAML render failed: ' + (e.message || e)); } @@ -8240,6 +9292,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (ext === 'pdf' || ext === 'html' || ext === 'htm') { try { var info = await getBlobUrl(node); + if (seq !== renderSeq) return; var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"'; container.innerHTML = ''; } catch (e) { @@ -8252,6 +9305,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (preview && preview.isImage(ext) && !preview.isTiff(ext)) { try { var imgInfo = await getBlobUrl(node); + if (seq !== renderSeq) return; container.innerHTML = '' + escapeHtml(node.name)
                     + ''; } catch (e) { @@ -8263,6 +9317,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (preview && preview.isTiff(ext)) { try { var tiffBuf = await getArrayBuffer(node); + if (seq !== renderSeq) return; container.innerHTML = ''; await preview.renderTiff(document, container, tiffBuf, { fileName: node.name }); } catch (e) { @@ -8274,6 +9329,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (preview && preview.isZip(ext)) { try { var zipBuf = await getArrayBuffer(node); + if (seq !== renderSeq) return; container.innerHTML = ''; await preview.renderZipListing(document, container, zipBuf, { fileName: node.name }); } catch (e) { @@ -8288,6 +9344,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (preview && preview.isOffice(ext)) { try { var officeBuf = await getArrayBuffer(node); + if (seq !== renderSeq) return; container.innerHTML = ''; if (ext === 'docx') { await preview.renderDocx(document, container, officeBuf, { fileName: node.name }); @@ -8303,6 +9360,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (preview && preview.isText(ext)) { try { var txtBuf = await getArrayBuffer(node); + if (seq !== renderSeq) return; var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf); var MAX = 200000; if (text.length > MAX) { @@ -8323,6 +9381,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // Unknown type — offer a download link. try { var fallbackInfo = await getBlobUrl(node); + if (seq !== renderSeq) return; container.innerHTML = '
' + 'No inline preview for .' + escapeHtml(ext) + '. ' @@ -8464,13 +9523,18 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (node.isDir) return; opts = opts || {}; if (opts.popup) return renderInPopup(node); - return renderInline(node); + return renderInline(node, opts); } window.app.modules.preview = { showFilePreview: showFilePreview, + // Tear down any live editor + blank the pane (rescope / popstate). + clearPreview: clearPreview, // Expose for the markdown plugin so it can read file bytes. - getArrayBuffer: getArrayBuffer + getArrayBuffer: getArrayBuffer, + // Like getArrayBuffer but also returns the {etag, lastModified} + // version token — the editors use it for optimistic-concurrency saves. + getContentWithVersion: getContentWithVersion }; })(); @@ -8518,34 +9582,39 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var SIDEBAR_DEFAULT_WIDTH = 280; var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar - function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } + var util = window.app.modules.util; + var escapeHtml = util.escapeHtml; + var hashContent = util.hashContent; var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl } var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts var lastFmHeight = FM_DEFAULT_HEIGHT; - async function hashContent(text) { - if (!window.crypto || !window.crypto.subtle) return null; - var enc = new TextEncoder().encode(text); - var buf = await window.crypto.subtle.digest('SHA-256', enc); - var bytes = new Uint8Array(buf); - var hex = ''; - for (var i = 0; i < bytes.length; i++) { - hex += bytes[i].toString(16).padStart(2, '0'); - } - return hex; - } - function dispose() { - if (currentInstance && currentInstance.editor) { - try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ } + if (currentInstance) { + // Tear down the document-level resizer drag listeners (added + // lazily on mousedown). They're normally removed on mouseup, + // but a dispose mid-drag — or any switch away — would otherwise + // strand them pointing at the dead shell. The AbortController + // removes whatever is still attached in one call. + if (currentInstance.ac) { + try { currentInstance.ac.abort(); } catch (_) { /* ignore */ } + } + if (currentInstance.editor) { + try { currentInstance.editor.destroy(); } catch (_) { /* ignore */ } + } } currentInstance = null; } + function isDirty() { + return !!(currentInstance && currentInstance.dirty); + } + + function currentNode() { + return currentInstance ? currentInstance.node : null; + } + // ── Front matter ──────────────────────────────────────────────────────── // Lightweight YAML front-matter parser. Same envelope as mdedit's: // `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays. @@ -8749,38 +9818,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // ── Save ──────────────────────────────────────────────────────────────── - async function saveContent(node, content) { - if (node.handle && typeof node.handle.createWritable === 'function') { - // Local folders are picked read-only; escalate to readwrite on - // first save (one FS-Access prompt, then granted for the session). - var up = window.app.modules.upload; - if (up && up.ensureWritable) await up.ensureWritable(); - var writable = await node.handle.createWritable(); - await writable.write(content); - await writable.close(); - return; - } - if (node.url && window.app.state.source === 'server') { - var resp = await fetch(node.url, { - method: 'PUT', - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, - body: content, - credentials: 'same-origin' - }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - return; - } - throw new Error('No write target for this file (read-only source).'); + function saveContent(node, content, opts) { + return util.saveFile(node, content, 'text/markdown; charset=utf-8', opts); } - // A markdown file living inside a .zip is read-only: a ZipFileHandle - // refuses createWritable (offline / nested), and zddc-server refuses - // writes to a "<…>.zip/" URL (405). - function isZipMemberNode(node) { - if (node.handle && node.handle.isZipEntry) return true; - if (node.url && window.app.state.source === 'server' && /\.zip\//i.test(node.url)) return true; - return false; - } + var isZipMemberNode = util.isZipMemberNode; function canSave(node) { if (isZipMemberNode(node)) return false; @@ -8808,11 +9850,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } dispose(); - // Read content. - var text; + // Read content + the server version token (etag/last-modified) so + // the save can send an If-Match precondition and detect a concurrent + // edit instead of clobbering it. Falls back to getArrayBuffer (and a + // null token → no precondition) for callers/sources without it. + var text, loadedEtag = null, loadedLastModified = null; try { - var buf = await ctx.getArrayBuffer(node); - text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + if (ctx.getContentWithVersion) { + var loaded = await ctx.getContentWithVersion(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf); + loadedEtag = loaded.etag; + loadedLastModified = loaded.lastModified; + } else { + var buf = await ctx.getArrayBuffer(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + } } catch (e) { container.innerHTML = '
' @@ -9040,15 +10092,24 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr })); } - currentInstance = { + // One AbortController per mount — wired into the document-level + // resizer listeners below so dispose() can detach them all at once. + var ac = new AbortController(); + var instance = { editor: editor, container: container, dirty: false, node: node, hash: initialHash, tocEl: tocBody, - fmEl: fmTextarea + fmEl: fmTextarea, + ac: ac, + // Server version token captured at load — sent as If-Match on + // save and refreshed from each successful PUT's response ETag. + etag: loadedEtag, + lastModified: loadedLastModified }; + currentInstance = instance; if (!writableMode) { saveBtn.disabled = true; @@ -9085,8 +10146,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr resizer.classList.add('is-dragging'); startX = e.clientX; startW = sidebar.getBoundingClientRect().width; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + document.addEventListener('mousemove', onMove, { signal: ac.signal }); + document.addEventListener('mouseup', onUp, { signal: ac.signal }); e.preventDefault(); }); resizer.addEventListener('keydown', function (e) { @@ -9130,8 +10191,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr fmResizer.classList.add('is-dragging'); startY = e.clientY; startH = fmSection.getBoundingClientRect().height; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + document.addEventListener('mousemove', onMove, { signal: ac.signal }); + document.addEventListener('mouseup', onUp, { signal: ac.signal }); e.preventDefault(); }); fmResizer.addEventListener('keydown', function (e) { @@ -9146,7 +10207,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // ── Change tracking + auto-rerender ──────────────────────────────── function markDirty(isDirty) { - currentInstance.dirty = isDirty; + if (currentInstance !== instance) return; // editor replaced + instance.dirty = isDirty; // Re-read canSave at every transition, not via a closure-captured // value, so the gate reflects current write authority — see the // matching pattern in preview-yaml.js. @@ -9154,35 +10216,106 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr dirtyEl.textContent = isDirty ? '● modified' : ''; } + // The debounced handlers can resolve AFTER this editor was disposed + // and a new file mounted (the timer + the await both outlive the + // switch). Bail when we're no longer the active instance so we never + // call into a destroyed Toast UI editor or write the wrong file's + // dirty/hash state. var onChange = debounce(async function () { + if (currentInstance !== instance) return; var body = editor.getMarkdown(); var h = await hashContent(assembleContent(fmTextarea.value, body)); - markDirty(h !== currentInstance.hash); + if (currentInstance !== instance) return; + markDirty(h !== instance.hash); renderToc(tocBody, body, editor); }, 250); editor.on('change', onChange); var onFmChange = debounce(async function () { + if (currentInstance !== instance) return; var body = editor.getMarkdown(); var h = await hashContent(assembleContent(fmTextarea.value, body)); - markDirty(h !== currentInstance.hash); + if (currentInstance !== instance) return; + markDirty(h !== instance.hash); }, 250); fmTextarea.addEventListener('input', onFmChange); // ── Save ─────────────────────────────────────────────────────────── + // Mark a successful write: adopt the new server ETag (so the next + // save's If-Match matches — no false conflict on save→edit→save), + // refresh the dirty baseline, clear dirty. + async function markSaved(content, res) { + if (currentInstance !== instance) return; + if (res && res.etag) instance.etag = res.etag; + instance.hash = await hashContent(content); + if (currentInstance !== instance) return; + markDirty(false); + statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Saved ' + node.name, 'success'); + } + } + + // 412 → the file changed on the server since we loaded it. Open the + // shared conflict dialog rather than clobbering. Dirty stays set + // until the user resolves. + async function resolveConflict(content) { + var conflict = window.app.modules.conflict; + var prev = window.app.modules.preview; + if (!conflict || !prev) return; // no UI available — leave dirty + await conflict.open({ + filename: node.name, + mineText: content, + fetchTheirs: function () { + return prev.getContentWithVersion(node).then(function (r) { + return new TextDecoder('utf-8', { fatal: false }).decode(r.buf); + }); + }, + // Overwrite: re-fetch the CURRENT version and save against it + // (still 412s on a third concurrent writer rather than blind- + // forcing). + onOverwrite: function () { + return prev.getContentWithVersion(node).then(function (cur) { + return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified }); + }).then(function (res) { return markSaved(content, res); }); + }, + // Reload theirs: discard local edits. Clear dirty first so the + // renderInline dirty-guard skips its confirm; the fresh render + // re-captures content + a new ETag. + onReload: function () { + markDirty(false); + instance.dirty = false; + return prev.showFilePreview(node); + }, + onSaveCopy: function () { + return util.saveCopy(node, content, 'text/markdown; charset=utf-8') + .then(function (name) { + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Saved your version as ' + name, 'success'); + } + }); + } + }); + if (currentInstance === instance) statusEl.textContent = ''; + } + async function save() { - if (!currentInstance.dirty || !canSave(node)) return; + if (currentInstance !== instance) return; + if (!instance.dirty || !canSave(node)) return; var content = assembleContent(fmTextarea.value, editor.getMarkdown()); try { statusEl.textContent = 'Saving…'; - await saveContent(node, content); - currentInstance.hash = await hashContent(content); - markDirty(false); - statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); - if (window.zddc && window.zddc.toast) { - window.zddc.toast('Saved ' + node.name, 'success'); - } + var res = await saveContent(node, content, { + etag: instance.etag, lastModified: instance.lastModified + }); + await markSaved(content, res); } catch (e) { + if (e && e.status === 412) { + if (currentInstance !== instance) return; + statusEl.textContent = 'Conflict — resolving…'; + await resolveConflict(content); + return; + } statusEl.textContent = 'Save failed: ' + (e.message || e); if (window.zddc && window.zddc.toast) { window.zddc.toast('Save failed: ' + (e.message || e), 'error'); @@ -9208,7 +10341,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr convertBtns.forEach(function (a) { a.addEventListener('click', async function (e) { var fmt = a.dataset.fmt; - if (!currentInstance.dirty) { + if (!instance.dirty) { // Clean — let the browser handle the click. The // server's response (DOCX/HTML/PDF bytes, 422, // 503, etc.) lands in whatever target the user @@ -9227,7 +10360,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } statusEl.textContent = 'Saving before download…'; try { await save(); } catch (_) { /* save() surfaces its own error */ } - if (currentInstance.dirty) return; // save failed; toast already shown + if (currentInstance !== instance || instance.dirty) return; // save failed / switched away statusEl.textContent = 'Downloading ' + fmt.toUpperCase() + '…'; // Re-trigger the click. dirty=false now so the handler // exits early on the second pass and the browser @@ -9239,7 +10372,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr window.app.modules.markdown = { render: render, - dispose: dispose + dispose: dispose, + isDirty: isDirty, + currentNode: currentNode }; })(); @@ -9267,10 +10402,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (!window.app || !window.app.modules) return; - function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } + var util = window.app.modules.util; + var escapeHtml = util.escapeHtml; // ── Filename routing ──────────────────────────────────────────────────── @@ -9292,32 +10425,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // ── Save (mirrors preview-markdown.js) ───────────────────────────────── - async function saveContent(node, content) { - if (node.handle && typeof node.handle.createWritable === 'function') { - var writable = await node.handle.createWritable(); - await writable.write(content); - await writable.close(); - return; - } - if (node.url && window.app.state.source === 'server') { - var resp = await fetch(node.url, { - method: 'PUT', - headers: { 'Content-Type': 'application/x-yaml; charset=utf-8' }, - body: content, - credentials: 'same-origin' - }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - return; - } - throw new Error('No write target for this file (read-only source).'); + function saveContent(node, content, opts) { + // Via the shared saveFile so local (FS-Access) saves escalate to + // readwrite the same as the markdown editor — previously this path + // skipped ensureWritable and failed on read-only-picked folders. + return util.saveFile(node, content, 'application/x-yaml; charset=utf-8', opts); } - function isZipMemberNode(node) { - if (node.handle && node.handle.isZipEntry) return true; - if (node.url && window.app.state.source === 'server' - && /\.zip\//i.test(node.url)) return true; - return false; - } + var isZipMemberNode = util.isZipMemberNode; function canSave(node) { if (isZipMemberNode(node)) return false; @@ -9341,17 +10456,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return false; } - async function hashContent(text) { - if (!window.crypto || !window.crypto.subtle) return null; - var enc = new TextEncoder().encode(text); - var buf = await window.crypto.subtle.digest('SHA-256', enc); - var bytes = new Uint8Array(buf); - var hex = ''; - for (var i = 0; i < bytes.length; i++) { - hex += bytes[i].toString(16).padStart(2, '0'); - } - return hex; - } + var hashContent = util.hashContent; // ── .zddc schema ──────────────────────────────────────────────────────── // @@ -9381,9 +10486,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr worm: 'string[]', paths: 'pathmap', display: 'stringmap', - apps: 'appsmap', - apps_pubkey: 'string', tables: 'stringmap', + views: 'viewmap', convert: 'convert', created_by: 'string', inherit: 'bool' @@ -9500,19 +10604,29 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr walkObject(v, TOP_KEYS, path.concat([seg]), issues); } return; - case 'appsmap': + case 'viewmap': if (t === 'null') return; if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } - for (var app in val) { - if (!Object.prototype.hasOwnProperty.call(val, app)) continue; - if (!ALLOWED_TOOLS[app]) { - issues.push({ keyPath: path.concat([app]), severity: 'warning', - message: 'Unknown tool "' + app + '" in apps:.' }); + for (var shape in val) { + if (!Object.prototype.hasOwnProperty.call(val, shape)) continue; + if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) { + issues.push({ keyPath: path.concat([shape]), severity: 'warning', + message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' }); } - if (typeOf(val[app]) !== 'string') { - issues.push({ keyPath: path.concat([app]), severity: 'error', - message: 'apps.' + app + ' must be a spec string ' - + '(channel | v | URL | path).' }); + var vv = val[shape]; + if (typeOf(vv) !== 'object') { + issues.push({ keyPath: path.concat([shape]), severity: 'error', + message: 'views.' + shape + ' must be a map ({tool, config}).' }); + continue; + } + if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) { + issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning', + message: 'views.' + shape + '.tool should be a known tool (' + + Object.keys(ALLOWED_TOOLS).join(', ') + ').' }); + } + if (vv.config !== undefined && typeOf(vv.config) !== 'string') { + issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error', + message: 'views.' + shape + '.config must be a filename string.' }); } } return; @@ -9623,12 +10737,30 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // ── Mount ─────────────────────────────────────────────────────────────── var currentEditor = null; + var currentDirty = false; + var currentNodeRef = null; + // Server version token for the loaded file — sent as If-Match on save + // and refreshed from each successful PUT's response ETag. + var currentEtag = null; + var currentLastModified = null; function dispose() { // CM doesn't have an explicit destroy(); GC handles it once // the host element is removed. Clear our reference so a stale // editor doesn't keep handlers alive. currentEditor = null; + currentDirty = false; + currentNodeRef = null; + currentEtag = null; + currentLastModified = null; + } + + function isDirty() { + return currentDirty; + } + + function currentNode() { + return currentNodeRef; } async function render(node, container, ctx) { @@ -9640,10 +10772,17 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } dispose(); - var text; + var text, loadedEtag = null, loadedLastModified = null; try { - var buf = await ctx.getArrayBuffer(node); - text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + if (ctx.getContentWithVersion) { + var loaded = await ctx.getContentWithVersion(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf); + loadedEtag = loaded.etag; + loadedLastModified = loaded.lastModified; + } else { + var buf = await ctx.getArrayBuffer(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + } } catch (e) { container.innerHTML = '
' @@ -9744,6 +10883,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // Force an initial lint pass now that _zddcNode is set. editor.performLint(); currentEditor = editor; + currentNodeRef = node; + currentDirty = false; + currentEtag = loadedEtag; + currentLastModified = loadedLastModified; if (!writable) { saveBtn.disabled = true; @@ -9759,15 +10902,69 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var initialHash = await hashContent(text); function markDirty(isDirty) { + if (currentEditor !== editor) return; // editor replaced + currentDirty = isDirty; saveBtn.disabled = !isDirty || !canSave(node); dirtyEl.textContent = isDirty ? '● modified' : ''; } editor.on('change', async function () { + if (currentEditor !== editor) return; // switched away var h = await hashContent(editor.getValue()); + if (currentEditor !== editor) return; // replaced during await markDirty(h !== initialHash); }); + // Adopt the new server ETag + refresh the dirty baseline after a + // successful write so save→edit→save doesn't false-conflict. + async function markSaved(content, res) { + if (currentEditor !== editor) return; + if (res && res.etag) currentEtag = res.etag; + initialHash = await hashContent(content); + if (currentEditor !== editor) return; + markDirty(false); + statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Saved ' + node.name, 'success'); + } + } + + // 412 → file changed on the server since load. Open the shared + // conflict dialog instead of clobbering. + async function resolveConflict(content) { + var conflict = window.app.modules.conflict; + var prev = window.app.modules.preview; + if (!conflict || !prev) return; + await conflict.open({ + filename: node.name, + mineText: content, + fetchTheirs: function () { + return prev.getContentWithVersion(node).then(function (r) { + return new TextDecoder('utf-8', { fatal: false }).decode(r.buf); + }); + }, + onOverwrite: function () { + return prev.getContentWithVersion(node).then(function (cur) { + return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified }); + }).then(function (res) { return markSaved(content, res); }); + }, + onReload: function () { + markDirty(false); + currentDirty = false; + return prev.showFilePreview(node); + }, + onSaveCopy: function () { + return util.saveCopy(node, content, 'application/x-yaml; charset=utf-8') + .then(function (name) { + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Saved your version as ' + name, 'success'); + } + }); + } + }); + if (currentEditor === editor) statusEl.textContent = ''; + } + async function save() { if (saveBtn.disabled) return; // Re-check authority at click time, not via the mount-time @@ -9777,14 +10974,17 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var content = editor.getValue(); try { statusEl.textContent = 'Saving…'; - await saveContent(node, content); - initialHash = await hashContent(content); - markDirty(false); - statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); - if (window.zddc && window.zddc.toast) { - window.zddc.toast('Saved ' + node.name, 'success'); - } + var res = await saveContent(node, content, { + etag: currentEtag, lastModified: currentLastModified + }); + await markSaved(content, res); } catch (e) { + if (e && e.status === 412) { + if (currentEditor !== editor) return; + statusEl.textContent = 'Conflict — resolving…'; + await resolveConflict(content); + return; + } statusEl.textContent = 'Save failed: ' + (e.message || e); if (window.zddc && window.zddc.toast) { window.zddc.toast('Save failed: ' + (e.message || e), 'error'); @@ -9809,7 +11009,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr window.app.modules.yamledit = { handles: handles, - render: render + render: render, + dispose: dispose, + isDirty: isDirty, + currentNode: currentNode }; })(); @@ -9873,22 +11076,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr currentRow = null; } - // ── Formatting (kept local so this module is self-contained) ── + // ── Formatting ── - function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } - - function fmtSize(bytes) { - if (bytes == null) return ''; - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - if (bytes < 1024 * 1024 * 1024) { - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - } - return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; - } + var escapeHtml = window.app.modules.util.escapeHtml; + var fmtSize = window.app.modules.util.fmtSize; function fmtDate(d) { if (!d) return ''; @@ -9992,10 +11183,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr + ''; } - // Path comes last (longest, most likely to wrap). - var path = tree ? tree.pathFor(node) : ''; - if (path) html += kv('Path', path, true); - if (node.url && node.url !== path) html += kv('URL', node.url, true); + // URL last (longest, most likely to wrap) — rendered as a clickable + // link the user can open or right-click to copy. The on-disk path is + // intentionally omitted; the URL is the shareable reference. + if (node.url) html += kvLink('URL', node.url, node.url); return html; } @@ -10517,20 +11708,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (lastResolved) msg += ' — last at ' + lastResolved; note(msg, 'success'); } - // Reload the listing of the workflow folder so the new +Cn file - // appears in the tree. The workflow folder is the parent of the - // virtual `received/` (i.e., the URL with one `/received/` - // suffix stripped). - var refreshUrl = targetURL.replace(/\/received\/[^/]+\/?$/, '/'); + // Reload the current listing so the new +Cn file appears in the + // tree. Best-effort. try { var ev = window.app.modules.events; - if (ev && typeof ev.refreshListing === 'function') { - ev.refreshListing(); - } else if (refreshUrl) { - // Best-effort fallback: re-navigate to the workflow folder - // so its listing is refreshed. - // (No action — refreshListing absence implies older browse.) - } + if (ev && typeof ev.refreshListing === 'function') ev.refreshListing(); } catch (_e) { /* refresh is best-effort */ } } @@ -10881,6 +12063,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } // Trigger a download from a same-origin server URL via Content-Disposition. + // NOTE: an click is fire-and-forget — a server error + // (401/403/404/5xx) can't be observed here, so failures surface only as + // the browser's own download error, not a toast. This is deliberate: the + // folder path points at zddc-server's streamed virtual ".zip" + // endpoint, and buffering it through fetch() to make errors catchable + // would defeat the streaming (the archive can be arbitrarily large). function downloadUrl(filename, url) { var a = document.createElement('a'); a.href = url; @@ -10934,9 +12122,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var zip = new window.JSZip(); for (var i = 0; i < files.length; i++) { ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')'); + // Hand JSZip the File (a Blob, backed by disk) rather than + // pre-reading every file's arrayBuffer — otherwise the whole + // tree's raw bytes sit in the JS heap at once before zipping. + // JSZip reads each Blob lazily during generateAsync. var f = await files[i].handle.getFile(); - var buf = await f.arrayBuffer(); - zip.file(rootHandle.name + '/' + files[i].relPath, buf); + zip.file(rootHandle.name + '/' + files[i].relPath, f); } ev.statusInfo('Generating ' + rootHandle.name + '.zip…'); var blob = await zip.generateAsync({ type: 'blob' }); @@ -11011,9 +12202,36 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } } + // Export a file converted to another format. Server-only: builds the + // sibling-extension URL (foo.docx → foo.md) and lets the browser pull it — + // zddc-server recognises the virtual path and converts on the fly, emitting + // Content-Disposition. fmt is a bare extension ("md" | "docx" | "html"). + function exportFile(node, fmt) { + if (!node || node.isDir) { + events().statusError('Not a file: ' + (node && node.name)); + return; + } + if (state.source !== 'server') { + events().statusError('Export to .' + fmt + ' needs a server connection'); + return; + } + var tree = window.app.modules.tree; + var path = tree && tree.pathFor ? tree.pathFor(node) : node.url; + if (!path) { + events().statusError('No path for ' + node.name); + return; + } + var url = path.replace(/\.[^./]+$/, '') + '.' + fmt; + var name = node.name.replace(/\.[^./]+$/, '') + '.' + fmt; + events().statusInfo('Exporting ' + name + '…'); + downloadUrl(name, url); + setTimeout(function () { events().statusClear(); }, 2500); + } + window.app.modules.download = { downloadFile: downloadFile, - downloadFolder: downloadFolder + downloadFolder: downloadFolder, + exportFile: exportFile }; })(); @@ -11064,44 +12282,18 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } } - // Compute today + N days as a YYYY-MM-DD string. - function isoDatePlus(days) { - var d = new Date(); - d.setDate(d.getDate() + days); - var y = d.getFullYear(); - var m = ('0' + (d.getMonth() + 1)).slice(-2); - var dd = ('0' + d.getDate()).slice(-2); - return y + '-' + m + '-' + dd; - } + var util = window.app.modules.util; + var isoDatePlus = util.isoDatePlus; // Fetch suggestion emails from /.profile/access so the originator // field has a datalist of likely values. Best-effort — silent on // failure (the field still accepts free text). - async function fetchOriginatorSuggestions() { - try { - var resp = await fetch('/.profile/access', { - headers: { 'Accept': 'application/json' }, - credentials: 'same-origin' - }); - if (!resp.ok) return []; - var data = await resp.json(); - var out = []; - // The endpoint exposes the current user + any role members - // visible to them. Pull anything that looks like an email - // for the datalist; the field is otherwise free text. - if (data && data.email) out.push(data.email); - return out; - } catch (_e) { - return []; - } - } + var fetchOriginatorSuggestions = util.fetchAccessEmails; // Build the YAML body for the plan-review POST. Quoting is minimal // (just enough for emails with special chars). function buildBody(values) { - function yamlString(s) { - return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; - } + var yamlString = util.yamlQuote; return [ 'review_lead: ' + yamlString(values.reviewLead), 'approver: ' + yamlString(values.approver), @@ -11164,7 +12356,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr }); }); + // Escape handler bound once, removed in close() — every + // dismissal path routes through close() so the document + // listener never outlives the modal. + function onKeydown(e) { + if (e.key === 'Escape') { close(); reject(new Error('cancelled')); } + } function close() { + document.removeEventListener('keydown', onKeydown); if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } @@ -11178,13 +12377,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr reject(new Error('cancelled')); } }); - document.addEventListener('keydown', function escHandler(e) { - if (e.key === 'Escape') { - document.removeEventListener('keydown', escHandler); - close(); - reject(new Error('cancelled')); - } - }); + document.addEventListener('keydown', onKeydown); box.querySelector('#pr-submit').addEventListener('click', function () { var values = { @@ -11206,14 +12399,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr }); } - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, function (c) { - return ({ - '&': '&', '<': '<', '>': '>', - '"': '"', "'": ''' - })[c]; - }); - } + var escapeHtml = util.escapeHtml; // Detect whether a tree node is an archive//received// // folder. The path is path-shaped, not content-based — tracking-number @@ -11230,8 +12416,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr && parts[3].toLowerCase() === 'received'; } + var busy = false; + // Run the Plan Review flow: open the modal, POST the result. async function invoke(node) { + if (busy) return; var tree = window.app.modules.tree; if (!tree) return; var url = tree.pathFor(node); @@ -11246,43 +12435,48 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return; // cancelled } - statusInfo('Plan Review — submitting…'); - var body = buildBody(values); - var resp; + busy = true; try { - resp = await fetch(url, { - method: 'POST', - headers: { - 'X-ZDDC-Op': 'plan-review', - 'Content-Type': 'application/yaml' - }, - body: body, - credentials: 'same-origin' - }); - } catch (e) { - statusError('Plan Review failed: ' + (e && e.message ? e.message : e)); - return; - } - if (!resp.ok) { - var text = ''; - try { text = await resp.text(); } catch (_e) { /* ignore */ } - statusError('Plan Review failed (' + resp.status + '): ' + text); - return; - } - var data; - try { data = await resp.json(); } catch (_e) { data = null; } - if (data && data.reviewing && data.staging) { - var rPart = data.reviewing.created ? 'created' : 'updated'; - var sPart = data.staging.created ? 'created' : 'updated'; - var seal = (data.received && data.received.created) - ? ' Canonical record sealed.' - : (data.received && !data.received.zddc_written) - ? ' Canonical dates left untouched (already sealed).' - : ''; - statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal + - ' Reload the relevant folder to see the new entries.'); - } else { - statusInfo('Plan Review complete.'); + statusInfo('Plan Review — submitting…'); + var body = buildBody(values); + var resp; + try { + resp = await fetch(url, { + method: 'POST', + headers: { + 'X-ZDDC-Op': 'plan-review', + 'Content-Type': 'application/yaml' + }, + body: body, + credentials: 'same-origin' + }); + } catch (e) { + statusError('Plan Review failed: ' + (e && e.message ? e.message : e)); + return; + } + if (!resp.ok) { + var text = ''; + try { text = await resp.text(); } catch (_e) { /* ignore */ } + statusError('Plan Review failed (' + resp.status + '): ' + text); + return; + } + var data; + try { data = await resp.json(); } catch (_e) { data = null; } + if (data && data.reviewing && data.staging) { + var rPart = data.reviewing.created ? 'created' : 'updated'; + var sPart = data.staging.created ? 'created' : 'updated'; + var seal = (data.received && data.received.created) + ? ' Canonical record sealed.' + : (data.received && !data.received.zddc_written) + ? ' Canonical dates left untouched (already sealed).' + : ''; + statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal + + ' Reload the relevant folder to see the new entries.'); + } else { + statusInfo('Plan Review complete.'); + } + } finally { + busy = false; } } @@ -11319,28 +12513,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (t) t(msg, level || 'info'); } - function isoDateToday() { - var d = new Date(); - return d.getFullYear() - + '-' + ('0' + (d.getMonth() + 1)).slice(-2) - + '-' + ('0' + d.getDate()).slice(-2); - } - function isoDatePlus(days) { - var d = new Date(); - d.setDate(d.getDate() + days); - return d.getFullYear() - + '-' + ('0' + (d.getMonth() + 1)).slice(-2) - + '-' + ('0' + d.getDate()).slice(-2); - } - - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, function (c) { - return ({ - '&': '&', '<': '<', '>': '>', - '"': '"', "'": ''' - })[c]; - }); - } + var util = window.app.modules.util; + var escapeHtml = util.escapeHtml; + var isoDateToday = util.isoDateToday; + var isoDatePlus = util.isoDatePlus; // Is this node a direct child of an incoming/ canonical folder // AND a well-formed transmittal folder? The first half is the @@ -11390,19 +12566,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return out; } - function fetchPeopleSuggestions() { - return fetch('/.profile/access', { - headers: { 'Accept': 'application/json' }, - credentials: 'same-origin' - }).then(function (r) { - if (!r.ok) return []; - return r.json().then(function (data) { - var out = []; - if (data && data.email) out.push(data.email); - return out; - }); - }).catch(function () { return []; }); - } + var fetchPeopleSuggestions = util.fetchAccessEmails; function openForm(initial) { return new Promise(function (resolve, reject) { @@ -11483,7 +12647,15 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr }); }); + // Bind the Escape handler once and remove it in close() — every + // dismissal path (cancel, overlay-click, submit, Escape) routes + // through close(), so the document listener can't outlive the + // modal. + function onKeydown(e) { + if (e.key === 'Escape') { close(); reject(new Error('cancelled')); } + } function close() { + document.removeEventListener('keydown', onKeydown); if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } box.querySelector('#acc-cancel').addEventListener('click', function () { @@ -11492,12 +12664,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr overlay.addEventListener('click', function (e) { if (e.target === overlay) { close(); reject(new Error('cancelled')); } }); - document.addEventListener('keydown', function escHandler(e) { - if (e.key === 'Escape') { - document.removeEventListener('keydown', escHandler); - close(); reject(new Error('cancelled')); - } - }); + document.addEventListener('keydown', onKeydown); box.querySelector('#acc-submit').addEventListener('click', function () { var values = { @@ -11521,9 +12688,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr }); } - function quote(s) { - return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; - } + var quote = util.yamlQuote; function buildBody(values) { var lines = ['received_date: ' + values.receivedDate]; if (values.setupPlanReview) { @@ -11537,7 +12702,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return lines.join('\n'); } + var busy = false; + async function invoke(node) { + if (busy) return; var tree = window.app.modules.tree; if (!tree) return; var url = tree.pathFor(node); @@ -11569,34 +12737,43 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return; } - status('Accept Transmittal — submitting…'); - var resp; + busy = true; try { - resp = await fetch(url, { - method: 'POST', - headers: { - 'X-ZDDC-Op': 'accept-transmittal', - 'Content-Type': 'application/yaml' - }, - body: buildBody(values), - credentials: 'same-origin' - }); - } catch (e) { - status('Accept failed: ' + (e && e.message ? e.message : e), 'error'); - return; + status('Accept Transmittal — submitting…'); + var resp; + try { + resp = await fetch(url, { + method: 'POST', + headers: { + 'X-ZDDC-Op': 'accept-transmittal', + 'Content-Type': 'application/yaml' + }, + body: buildBody(values), + credentials: 'same-origin' + }); + } catch (e) { + status('Accept failed: ' + (e && e.message ? e.message : e), 'error'); + return; + } + if (!resp.ok) { + var text = ''; + try { text = await resp.text(); } catch (_e) { /* ignore */ } + status('Accept failed (' + resp.status + '): ' + text, 'error'); + return; + } + var data; try { data = await resp.json(); } catch (_e) { data = null; } + var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into ' + + (data && data.received_path ? data.received_path : 'received/'); + if (data && data.merged) msg += ' (merged with existing tracking)'; + if (data && data.plan_review) msg += ' · Plan Review scaffolded'; + status(msg, 'success'); + // Refresh the incoming/ listing so the now-moved folder drops out + // of the tree — the stale entry was the main re-trigger hazard. + var ev = window.app.modules.events; + if (ev && typeof ev.refreshListing === 'function') ev.refreshListing(); + } finally { + busy = false; } - if (!resp.ok) { - var text = ''; - try { text = await resp.text(); } catch (_e) { /* ignore */ } - status('Accept failed (' + resp.status + '): ' + text, 'error'); - return; - } - var data; try { data = await resp.json(); } catch (_e) { data = null; } - var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into ' - + (data && data.received_path ? data.received_path : 'received/'); - if (data && data.merged) msg += ' (merged with existing tracking)'; - if (data && data.plan_review) msg += ' · Plan Review scaffolded'; - status(msg + ' — reload to see the move.', 'success'); } window.app.modules.acceptTransmittal = { @@ -11632,11 +12809,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var t = window.zddc && window.zddc.toast; if (t) t(msg, level || 'info'); } - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, function (c) { - return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]; - }); + // Re-fetch the current listing so the moved file appears/disappears + // without a manual reload. Best-effort: absent on older builds. + function refreshListing() { + var ev = window.app.modules.events; + if (ev && typeof ev.refreshListing === 'function') ev.refreshListing(); } + // Guard against a second invocation while a move is mid-flight (e.g. a + // double menu click). The picker modal also blocks re-entry while open. + var busy = false; + var escapeHtml = window.app.modules.util.escapeHtml; // ── Scope detection: path-shape, not cascade-content ────────────── // A file is stageable if its path matches @@ -11697,18 +12879,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr .map(function (e) { return e.name; }); } - async function fetchSelfEmail() { - try { - var r = await fetch('/.profile/access', { - headers: { 'Accept': 'application/json' }, - credentials: 'same-origin' - }); - if (!r.ok) return ''; - var d = await r.json(); - return (d && d.email) || ''; - } catch (_e) { return ''; } - } - // POST X-ZDDC-Op: mkdir to create a new directory. Idempotent. async function mkdir(absUrl) { var resp = await fetch(absUrl, { @@ -11874,6 +13044,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // ── Action drivers ───────────────────────────────────────────────── async function invokeStage(node) { + if (busy) return; var tree = window.app.modules.tree; if (!tree) return; var srcUrl = tree.pathFor(node); @@ -11896,26 +13067,46 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr choice = await openStagePicker({ fileCount: 1, folders: folders }); } catch (_e) { return; } - if (choice.create) { + busy = true; + try { + // Stage is a non-atomic mkdir-then-move (no single composite op). + // Track whether the folder was freshly created so that, if the + // move then fails, we can tell the user the folder exists but the + // file didn't make it — otherwise an empty folder appears with a + // generic "move failed" and no explanation. + var createdFolder = false; + if (choice.create) { + try { + await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/'); + createdFolder = true; + } catch (e) { + status((e && e.message) || 'mkdir failed', 'error'); + return; + } + } + + var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name); try { - await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/'); + await moveFile(srcUrl, dstUrl); } catch (e) { - status((e && e.message) || 'mkdir failed', 'error'); + var msg = (e && e.message) || 'move failed'; + if (createdFolder) { + msg += ' — the new folder "' + choice.folderName + + '" was created but ' + node.name + ' was not moved into it.'; + } + status(msg, 'error'); + refreshListing(); // surface the (possibly empty) new folder return; } + status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/', 'success'); + refreshListing(); + } finally { + busy = false; } - - var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name); - try { - await moveFile(srcUrl, dstUrl); - } catch (e) { - status((e && e.message) || 'move failed', 'error'); - return; - } - status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success'); } async function invokeUnstage(node) { + if (busy) return; var tree = window.app.modules.tree; if (!tree) return; var srcUrl = tree.pathFor(node); @@ -11933,13 +13124,19 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var target = choice.target; if (!target.endsWith('/')) target += '/'; var dstUrl = target + encodeURIComponent(node.name); + busy = true; try { - await moveFile(srcUrl, dstUrl); - } catch (e) { - status((e && e.message) || 'move failed', 'error'); - return; + try { + await moveFile(srcUrl, dstUrl); + } catch (e) { + status((e && e.message) || 'move failed', 'error'); + return; + } + status('Unstaged ' + node.name + ' → ' + target, 'success'); + refreshListing(); + } finally { + busy = false; } - status('Unstaged ' + node.name + ' → ' + target + ' — reload to see the move.', 'success'); } window.app.modules.stage = { @@ -11968,11 +13165,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr (function () { 'use strict'; - function escapeHtml(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } + var escapeHtml = window.app.modules.util.escapeHtml; function toast(msg, kind) { if (window.zddc && typeof window.zddc.toast === 'function') { @@ -11992,12 +13185,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return d.toLocaleString(); } - function fmtBytes(n) { - if (n == null) return ''; - if (n < 1024) return n + ' B'; - if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; - return (n / (1024 * 1024)).toFixed(1) + ' MB'; - } + var fmtBytes = window.app.modules.util.fmtSize; // Can the principal write (restore) to this file? Mirrors the // events.js Rename/Delete gating: verbs===undefined means a non-zddc @@ -12298,6 +13486,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) { return; } + // The restore itself (the PUT) is the operation that can "fail". + // Keep it in its own try so a later error while refreshing the UI + // can't surface a misleading "Restore failed" after the restore has + // already been persisted. try { var text = await fetchVersion(node, ent.id); var resp = await fetch(node.url, { @@ -12307,18 +13499,22 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr body: text }); if (!resp.ok) throw new Error('HTTP ' + resp.status); - toast('Restored version from ' + fmtTime(ent.ts), 'success'); - // Reflect the new head: refetch the list. + } catch (e) { + toast('Restore failed: ' + (e.message || e), 'error'); + return; + } + toast('Restored version from ' + fmtTime(ent.ts), 'success'); + // Best-effort UI refresh — the restore already succeeded, so a + // failure here is logged but never reported as a restore failure. + try { var entries = await fetchList(node); renderList(modal, node, entries); // If the file is open in the preview pane, reload it. var preview = window.app && window.app.modules && window.app.modules.preview; if (preview && typeof preview.showFilePreview === 'function') { - try { preview.showFilePreview(node); } catch (_e) { /* best effort */ } + preview.showFilePreview(node); } - } catch (e) { - toast('Restore failed: ' + (e.message || e), 'error'); - } + } catch (_e) { /* refresh is best-effort; restore is done */ } } // ── Entry point ───────────────────────────────────────────────────── @@ -12367,17 +13563,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var t = window.zddc && window.zddc.toast; if (t) t(msg, level || 'info'); } - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, function (c) { - return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]; - }); - } - function isoDateToday() { - var d = new Date(); - return d.getFullYear() - + '-' + ('0' + (d.getMonth() + 1)).slice(-2) - + '-' + ('0' + d.getDate()).slice(-2); - } + var util = window.app.modules.util; + var escapeHtml = util.escapeHtml; + var isoDateToday = util.isoDateToday; function openForm() { return new Promise(function (resolve, reject) { @@ -12427,19 +13615,22 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr input.addEventListener('input', revalidate); revalidate(); - function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } + // Escape handler bound once, removed in close() so it can't + // outlive a modal dismissed via cancel / overlay-click / submit. + function onKeydown(e) { + if (e.key === 'Escape') { close(); reject(new Error('cancelled')); } + } + function close() { + document.removeEventListener('keydown', onKeydown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + } box.querySelector('#ct-cancel').addEventListener('click', function () { close(); reject(new Error('cancelled')); }); overlay.addEventListener('click', function (e) { if (e.target === overlay) { close(); reject(new Error('cancelled')); } }); - document.addEventListener('keydown', function escHandler(e) { - if (e.key === 'Escape') { - document.removeEventListener('keydown', escHandler); - close(); reject(new Error('cancelled')); - } - }); + document.addEventListener('keydown', onKeydown); submit.addEventListener('click', function () { var v = input.value.trim(); var parsed = window.zddc.parseFolder(v); @@ -12584,6 +13775,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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 + @@ -12629,6 +13835,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } 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 @@ -12637,6 +13853,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // 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 { @@ -12645,9 +13862,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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) { @@ -12658,14 +13878,33 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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); @@ -12673,6 +13912,37 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var refresh = document.getElementById('refreshHeaderBtn'); if (refresh) refresh.addEventListener('click', refreshListing); + // ── 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 @@ -12767,6 +14037,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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; @@ -12863,6 +14143,22 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // 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) || ''; @@ -12945,7 +14241,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // selection-only; their preview is "expand to see inside". if (nextNode && !nextNode.isDir && !nextNode.isZip && previewModule) { - previewModule.showFilePreview(nextNode); + // 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. @@ -12961,27 +14260,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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 - }); - } + 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 @@ -13156,11 +14436,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return parentDir; } - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, function (c) { - return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; - }); - } + var escapeHtml = window.app.modules.util.escapeHtml; // Valid party folder name — mirrors zddc.ValidPartyName server-side // (^[A-Za-z0-9][A-Za-z0-9.-]*$). @@ -13356,7 +14632,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } } - 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) @@ -13365,14 +14640,20 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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 { - var es = state.source === 'server' + es = state.source === 'server' ? await loader.fetchServerChildren(dirPath) : (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []); - tree.setRoot(es); - } catch (_e) { /* swallow */ } + } catch (e) { + statusError('Reload failed: ' + (e.message || e)); + return; + } + if (!isCurrentNav(seq)) return; + tree.setRoot(es); tree.render(); return; } @@ -13384,13 +14665,18 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; }); if (hit) { + var raw; try { - var raw = state.source === 'server' + 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 */ } + } catch (e) { + statusError('Reload failed: ' + (e.message || e)); + return; + } + if (!isCurrentNav(seq)) return; + tree.setChildren(hit.id, raw); + hit.expanded = true; tree.render(); } } @@ -13458,42 +14744,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } } - // 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). @@ -13501,316 +14751,65 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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 }, + // ── 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. - // ── 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 }, + // 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; } - // ── 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 }, + function menuModel() { return window.app.modules.menuModel; } - // ── 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(); - } } - ]; + 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()); } + }); } - // 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(); - } } - ]; + 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 @@ -13867,8 +14866,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } if (state.source === 'fs') { if (!node.handle || node.handle.kind !== 'directory') return; - state.rootHandle = node.handle; - state.currentPath = node.handle.name + '/'; + var seq = beginNav(); var raw; try { raw = await loader.fetchFsChildren(node.handle); @@ -13876,6 +14874,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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); @@ -13886,6 +14890,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // 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); @@ -13893,7 +14898,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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; @@ -13904,9 +14914,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr 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 = ''; + // 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'); @@ -13935,7 +14950,22 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr statusInfo: statusInfo, statusClear: statusClear, showBrowseRoot: showBrowseRoot, - applyResolvedViewMode: applyResolvedViewMode + 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 }; })(); @@ -13949,17 +14979,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var tree = window.app.modules.tree; var events = window.app.modules.events; - // Virtual canonical folder injection used to live here (browse - // appended archive/working/staging/reviewing entries at a project - // root when missing). zddc-server now emits them in the listing - // directly so the .zddc `display:` map can override their labels - // the same as real entries. This pass-through stub keeps the - // events.js rescope contract intact without doing any merging. - function passThroughEntries(entries) { return entries; } - - // Expose for events.js's client-side rescope on dblclick. - window.app.modules.augmentRoot = passThroughEntries; - // Walk a `?file=` path segment-by-segment from the current root. // Each non-leaf segment is matched against the parent's children // by name; if found and it's a folder, expand+load it (so its @@ -14043,6 +15062,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr tree.setRoot(detected.entries); events.showBrowseRoot(); tree.render(); + if (events.prefetchScopeAccess) events.prefetchScopeAccess(); events.statusInfo('Loaded ' + detected.entries.length + ' item' + (detected.entries.length === 1 ? '' : 's') + ' from ' + detected.path); @@ -14073,15 +15093,27 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var popQS = new URLSearchParams(location.search); if (popQS.get('hidden') === '1') window.app.state.showHidden = true; else window.app.state.showHidden = false; + // Join the shared nav token: rapid back/forward (or back/forward + // while an in-tool rescope is mid-flight) must not apply a stale + // listing on top of a newer one. + var seq = events.beginNav ? events.beginNav() : 0; try { var es = await loader.fetchServerChildren(path); + if (events.isCurrentNav && !events.isCurrentNav(seq)) return; window.app.state.currentPath = path; window.app.state.selectedId = null; window.app.state.lastPreviewedNodeId = null; tree.setRoot(es); tree.render(); - var previewBody = document.getElementById('previewBody'); - if (previewBody) previewBody.innerHTML = ''; + if (events.prefetchScopeAccess) events.prefetchScopeAccess(); + // Route through clearPreview so a live editor is disposed + // (not leaked) when back/forward swaps scope. + var pmod = window.app.modules.preview; + 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'; // Reapply view mode for the new URL (incoming/ → grid, etc). diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index ad4e9c7..0202715 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -1793,7 +1793,7 @@ body.is-elevated::after {
ZDDC Classifier - v0.0.27-beta · 2026-06-03 18:26:16 · f723323 + v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html index b015025..526440d 100644 --- a/zddc/internal/apps/embedded/index.html +++ b/zddc/internal/apps/embedded/index.html @@ -1536,7 +1536,7 @@ body {
ZDDC - v0.0.27-beta · 2026-06-03 18:26:16 · f723323 + v0.0.27-beta · 2026-06-05 12:41:17 · 382645b
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index 5f764cc..bcde642 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -2635,7 +2635,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.27-beta · 2026-06-03 18:26:16 · f723323 + v0.0.27-beta · 2026-06-05 12:41:16 · 382645b
JavaScript not available