ZDDC/browse/js/preview-yaml.js
ZDDC cfb2fab401 fix(browse): editor lifecycle — dispose on switch, guard unsaved edits, kill leaks
The markdown/YAML preview editors were never disposed when switching to a
non-editor file: dispose() was only called from inside the same plugin's
render(), so md→PDF/image/YAML overwrote the pane via innerHTML and leaked
the Toast UI instance, its DOM, and document-level resizer drag listeners.
Unsaved edits were also discarded silently on any file switch (including
arrow-key auto-preview), and debounced change handlers could resolve after
an editor was disposed and write the wrong file's dirty/hash state.

preview.js now owns editor lifecycle centrally in renderInline:
- disposeEditors() up front before replacing the pane (fixes the leak for
  every md/yaml → anything switch).
- dirty guard: deliberate switches (click/Enter/menu) confirm before
  discarding; auto previews (keyboard cursor walking the tree, opts.auto)
  leave the dirty editor in place rather than nagging per keystroke;
  re-selecting the file already being edited is a no-op.
- a renderSeq token bails late-arriving loads so a slow file can't paint
  stale content into the pane after a newer selection.
- clearPreview() exposed and used by rescope (events.js) and popstate
  (app.js) so those resets dispose the editor instead of leaking it.
- beforeunload warns when an editor is dirty at page exit.

preview-markdown.js: per-mount AbortController wired into the resizer
document listeners so dispose() detaches them even mid-drag; debounced
change/save/convert handlers guard `currentInstance !== instance` so a
disposed editor's callbacks can't corrupt the active file; expose
isDirty()/currentNode().

preview-yaml.js: track dirty/node state, guard the change handler the same
way, expose dispose()/isDirty()/currentNode().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:46:31 -05:00

590 lines
25 KiB
JavaScript

// preview-yaml.js — YAML editor plugin for the browse preview pane.
//
// Routes any .yaml / .yml file, plus the .zddc cascade files
// (`.zddc` and `*.zddc.yaml`), through a CodeMirror 5 editor with
// syntax highlighting and live linting. js-yaml.loadAll feeds parse
// errors into CM's lint gutter; for .zddc files an additional
// schema-aware pass flags unknown keys, bad enum values, and wrong
// types.
//
// Layout (single column):
// ┌─────────────────────────────────────────────────────────────┐
// │ name | dirty | status | source | [Save] │
// ├─────────────────────────────────────────────────────────────┤
// │ CodeMirror editor (line numbers + lint gutter) │
// └─────────────────────────────────────────────────────────────┘
//
// Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip members and
// virtual nodes are read-only — Save stays disabled.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Filename routing ────────────────────────────────────────────────────
// True for .zddc cascade files — `.zddc` (literal name, no ext)
// and `<anything>.zddc.yaml` (e.g. `defaults.zddc.yaml`). These
// get the schema-aware lint layer.
function isZddcFile(name) {
if (!name) return false;
if (name === '.zddc') return true;
return /\.zddc\.ya?ml$/i.test(name);
}
function isYamlFile(node) {
if (!node || !node.name) return false;
if (isZddcFile(node.name)) return true;
var ext = (node.ext || '').toLowerCase();
return ext === 'yaml' || ext === 'yml';
}
// ── 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 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;
}
function canSave(node) {
if (isZipMemberNode(node)) return false;
// Virtual .zddc placeholders are designed to be saved — a PUT
// materializes the file from the synthetic body and the next
// listing serves a real entry. Every other virtual node (per-
// user home, canonical-folder virtuals) is just a tree
// affordance, not a writable file.
if (node.virtual && node.name !== '.zddc') return false;
// Server-computed authority gate. The virtual .zddc entry
// requires the admin verb 'a' (matches fileapi.go's
// ActionAdmin gate at the .zddc URL); regular YAML files
// require write 'w'. cap.has falls back to node.writable for
// 'w' when verbs is absent (offline FS-API listings).
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
var needed = node.name === '.zddc' ? 'a' : 'w';
if (!window.zddc.cap.has(node, needed)) return false;
}
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
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;
}
// ── .zddc schema ────────────────────────────────────────────────────────
//
// Mirrors the Go-side decoder in zddc/internal/zddc/*. Allowed
// tool names are the embedded set (always available) plus the
// composable ones served when declared in apps:. Unknown keys at
// any level surface as warnings — typos like `defaul_tool` are
// common and the cascade silently ignores them.
var ALLOWED_TOOLS = {
archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
tables: 1, form: 1
};
var TOP_KEYS = {
title: 'string',
acl: 'acl',
admins: 'string[]',
roles: 'rolemap',
available_tools: 'tools[]',
default_tool: 'tool',
dir_tool: 'tool',
auto_own: 'bool',
auto_own_fenced: 'bool',
virtual: 'bool',
drop_target: 'bool',
worm: 'string[]',
paths: 'pathmap',
display: 'stringmap',
apps: 'appsmap',
apps_pubkey: 'string',
tables: 'stringmap',
convert: 'convert',
created_by: 'string',
inherit: 'bool'
};
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
allow: 'string[]', deny: 'string[]' };
var ROLE_KEYS = { members: 'string[]', reset: 'bool' };
var CONVERT_KEYS = { client: 'string', project: 'string',
contractor: 'string', project_number: 'string' };
function typeOf(v) {
if (v === null || v === undefined) return 'null';
if (Array.isArray(v)) return 'array';
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
}
// Collect schema issues for a parsed .zddc document. Each issue is
// { keyPath: string[], message: string, severity: 'error' | 'warning' }.
// keyPath is used by findLine() to locate the offending source line.
function validateZddc(doc) {
var issues = [];
if (typeOf(doc) === 'null') return issues;
if (typeOf(doc) !== 'object') {
issues.push({ keyPath: [], severity: 'error',
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
return issues;
}
walkObject(doc, TOP_KEYS, [], issues);
return issues;
}
function walkObject(obj, schema, path, issues) {
for (var key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
var here = path.concat([key]);
var kind = schema[key];
if (!kind) {
issues.push({ keyPath: here, severity: 'warning',
message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' });
continue;
}
checkValue(obj[key], kind, here, issues);
}
}
function checkValue(val, kind, path, issues) {
var t = typeOf(val);
switch (kind) {
case 'string':
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'bool':
if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'string[]':
if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues);
return;
case 'tools[]':
if (t !== 'array' && t !== 'null') {
addTypeErr(path, kind, t, issues); return;
}
if (t === 'array') {
for (var i = 0; i < val.length; i++) {
if (typeOf(val[i]) !== 'string') {
issues.push({ keyPath: path, severity: 'error',
message: 'available_tools[' + i + '] must be a string.' });
} else if (!ALLOWED_TOOLS[val[i]]) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown tool "' + val[i]
+ '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
}
}
}
return;
case 'tool':
if (t === 'null') return;
if (t !== 'string') { addTypeErr(path, kind, t, issues); return; }
if (!ALLOWED_TOOLS[val]) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown tool "' + val + '". Known: '
+ Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
}
return;
case 'stringmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var k in val) {
if (!Object.prototype.hasOwnProperty.call(val, k)) continue;
if (typeOf(val[k]) !== 'string') {
issues.push({ keyPath: path.concat([k]), severity: 'error',
message: 'Value must be a string (got '
+ typeOf(val[k]) + ').' });
}
}
return;
case 'pathmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var seg in val) {
if (!Object.prototype.hasOwnProperty.call(val, seg)) continue;
if (seg.indexOf('/') !== -1) {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'Path keys must be a single segment — '
+ 'nest blocks instead of using "' + seg + '".' });
}
var v = val[seg];
if (typeOf(v) === 'null') continue;
if (typeOf(v) !== 'object') {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'paths.' + seg + ' must be a map of cascade rules.' });
continue;
}
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
}
return;
case 'appsmap':
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:.' });
}
if (typeOf(val[app]) !== 'string') {
issues.push({ keyPath: path.concat([app]), severity: 'error',
message: 'apps.' + app + ' must be a spec string '
+ '(channel | v<semver> | URL | path).' });
}
}
return;
case 'rolemap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var rn in val) {
if (!Object.prototype.hasOwnProperty.call(val, rn)) continue;
var rv = val[rn];
if (typeOf(rv) === 'null') continue;
if (typeOf(rv) !== 'object') {
issues.push({ keyPath: path.concat([rn]), severity: 'error',
message: 'roles.' + rn + ' must be a map ({members, reset}).' });
continue;
}
walkObject(rv, ROLE_KEYS, path.concat([rn]), issues);
}
return;
case 'acl':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, ACL_KEYS, path, issues);
return;
case 'convert':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, CONVERT_KEYS, path, issues);
return;
}
}
function addTypeErr(path, expected, got, issues) {
issues.push({ keyPath: path, severity: 'error',
message: 'Expected ' + expected + ', got ' + got + '.' });
}
// Locate the source line for a key path. .zddc files are
// block-style YAML in practice (no flow style, no anchors), so a
// simple indent-aware scan works: for each segment, find a line
// matching "<indent><key>:" whose indent is deeper than the
// previously-matched line. Falls back to line 0 if no match.
function findLine(source, keyPath) {
if (!keyPath || keyPath.length === 0) return 0;
var lines = source.split('\n');
var prevIndent = -1;
var prevLine = 0;
for (var i = 0; i < keyPath.length; i++) {
var key = keyPath[i];
var found = -1;
// Escape regex metachars in the key.
var keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
var re = new RegExp('^(\\s*)"?' + keyRe + '"?\\s*:');
for (var j = prevLine; j < lines.length; j++) {
var m = lines[j].match(re);
if (m && m[1].length > prevIndent) {
found = j;
prevIndent = m[1].length;
prevLine = j + 1;
break;
}
}
if (found === -1) return prevLine > 0 ? prevLine - 1 : 0;
}
return prevLine > 0 ? prevLine - 1 : 0;
}
// ── CodeMirror lint helper ──────────────────────────────────────────────
function registerLinter(CM) {
// The lint helper signature: function(text, options, editor) → annotations[]
// Each annotation: { from, to, message, severity }.
CM.registerHelper('lint', 'yaml', function (text, _opts, editor) {
var out = [];
if (!window.jsyaml) return out;
var parsed;
try {
// loadAll handles multi-doc YAML; we only validate the
// first doc against the schema (the .zddc cascade reads
// only the first document).
var docs = [];
window.jsyaml.loadAll(text, function (d) { docs.push(d); });
parsed = docs[0];
} catch (e) {
var mark = e.mark;
var pos = mark ? CM.Pos(mark.line, mark.column) : CM.Pos(0, 0);
out.push({ from: pos, to: pos, severity: 'error',
message: e.message || String(e) });
return out;
}
// Schema layer — only for .zddc cascade files.
var node = editor._zddcNode;
if (node && isZddcFile(node.name)) {
var issues = validateZddc(parsed);
for (var i = 0; i < issues.length; i++) {
var ln = findLine(text, issues[i].keyPath);
out.push({
from: CM.Pos(ln, 0),
to: CM.Pos(ln, (editor.getLine(ln) || '').length),
severity: issues[i].severity,
message: issues[i].message
});
}
}
return out;
});
}
// ── Mount ───────────────────────────────────────────────────────────────
var currentEditor = null;
var currentDirty = false;
var currentNodeRef = 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;
}
function isDirty() {
return currentDirty;
}
function currentNode() {
return currentNodeRef;
}
async function render(node, container, ctx) {
if (typeof window.CodeMirror === 'undefined') {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'CodeMirror isn\'t bundled in this build.</div>';
return;
}
dispose();
var text;
try {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
} catch (e) {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'Could not read ' + escapeHtml(node.name) + ': '
+ escapeHtml(e.message || String(e)) + '</div>';
return;
}
container.innerHTML = '';
var shell = document.createElement('div');
shell.className = 'yaml-shell';
container.appendChild(shell);
// Info header — same look as the markdown plugin's info-header
// so the two editors feel like one family.
var infohdr = document.createElement('div');
infohdr.className = 'md-shell__infohdr yaml-shell__infohdr';
var titleEl = document.createElement('span');
titleEl.className = 'md-shell__title';
titleEl.textContent = node.name;
titleEl.title = node.name;
var schemaTag = document.createElement('span');
schemaTag.className = 'md-shell__source yaml-shell__schema';
if (isZddcFile(node.name)) {
schemaTag.textContent = '.zddc schema';
schemaTag.title = 'Linted against the .zddc cascade schema '
+ '(unknown keys, bad enums, and wrong types are flagged).';
} else {
schemaTag.textContent = 'YAML';
}
var dirtyEl = document.createElement('span');
dirtyEl.className = 'md-shell__dirty';
var statusEl = document.createElement('span');
statusEl.className = 'md-shell__status';
var sourceEl = document.createElement('span');
sourceEl.className = 'md-shell__source';
if (isZipMemberNode(node)) sourceEl.textContent = 'read-only (zip)';
else if (node.handle) sourceEl.textContent = 'local';
else if (node.url) sourceEl.textContent = 'server';
var saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
saveBtn.type = 'button';
saveBtn.textContent = 'Save';
saveBtn.disabled = true;
infohdr.appendChild(titleEl);
infohdr.appendChild(schemaTag);
infohdr.appendChild(dirtyEl);
infohdr.appendChild(statusEl);
infohdr.appendChild(sourceEl);
infohdr.appendChild(saveBtn);
shell.appendChild(infohdr);
var editorHost = document.createElement('div');
editorHost.className = 'yaml-shell__editor';
shell.appendChild(editorHost);
// Register the lint helper once per page lifetime.
if (!window.CodeMirror.__zddcYamlLinterReady) {
registerLinter(window.CodeMirror);
window.CodeMirror.__zddcYamlLinterReady = true;
}
var writable = canSave(node);
var editor = window.CodeMirror(editorHost, {
value: text,
mode: 'yaml',
lineNumbers: true,
tabSize: 2,
indentUnit: 2,
indentWithTabs: false,
lineWrapping: false,
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
lint: { hasGutters: true },
// autofocus:false keeps the keyboard caret in the browse
// tree pane so arrow-key nav can continue through yaml /
// .zddc files without diverting into the editor. User
// clicks (or tabs) into the editor when they want to type.
autofocus: false,
// Read-only uses readOnly:true (NOT "nocursor"): the editor
// stays focusable so the user can click in, select text, and
// copy — they just can't edit. "nocursor" removes the textarea
// from focus, which also kills click-drag selection (the whole
// reason a viewer would otherwise force admin mode just to copy
// a .zddc snippet). autofocus:false keeps arrow-key tree nav
// intact until the user deliberately clicks into the editor.
readOnly: !writable,
});
// Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer.
editor._zddcNode = node;
// Force an initial lint pass now that _zddcNode is set.
editor.performLint();
currentEditor = editor;
currentNodeRef = node;
currentDirty = false;
if (!writable) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
// Read-only banner above the editor explains why.
var roBanner = document.createElement('div');
roBanner.className = 'yaml-readonly-banner';
roBanner.innerHTML = '<span aria-hidden="true">🔒</span>'
+ ' Read-only — you don\'t have write access to this file.';
editorHost.insertBefore(roBanner, editorHost.firstChild);
}
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);
});
async function save() {
if (saveBtn.disabled) return;
// Re-check authority at click time, not via the mount-time
// `writable` capture — the listing may have re-evaluated
// (e.g. user toggled admin mode without a hard reload).
if (!canSave(node)) return;
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');
}
} catch (e) {
statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
}
}
}
saveBtn.addEventListener('click', save);
editor.setOption('extraKeys', {
'Ctrl-S': save,
'Cmd-S': save
});
// CM defers layout until its host has a size — refresh after
// mount so the gutters and viewport sync to the grid cell.
setTimeout(function () { try { editor.refresh(); } catch (_e) {} }, 0);
}
function handles(node) {
if (!node || node.isDir || node.isZip) return false;
return isYamlFile(node);
}
window.app.modules.yamledit = {
handles: handles,
render: render,
dispose: dispose,
isDirty: isDirty,
currentNode: currentNode
};
})();