@@ -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 = '';
} 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 =
'