ZDDC/browse/js/preview-yaml.js
ZDDC 55328c8c28 feat(browse): editors honor server-side write authority + don't steal focus
Listing JSON gains a writable bool per file row, computed by running
the policy decider with ActionWrite against the parent-dir chain
(with the same admin-bypass branch the file API uses). Cost: one
extra decider call per file in the listing, sharing the parent
chain so the cascade walk is amortized.

Browse loader stores writable on every tree node. The markdown and
YAML editors read it and gate their canSave + initial mount:

- !writable markdown → Toast UI Viewer (rendered, no edit toolbar,
  no caret). Banner above explains why save is disabled.
- !writable YAML → CodeMirror readOnly:'nocursor' (selection for
  copy, no caret). Banner above explains why save is disabled.

Both editors gain autofocus:false so keyboard nav in the browse
tree doesn't divert into the editor — arrow keys keep moving through
files and folders without the caret jumping. User clicks (or tabs)
into the editor when they actually want to type.

.zddc files already route through preview-yaml's isZddcFile path;
bare .zddc (no ext) matches because that function checks the
literal name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:42:36 -05:00

550 lines
23 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;
if (node.virtual) return false;
// Server-computed authority gate. Mirrors the markdown editor's
// check — listing's `writable` bit is the same decision the
// file API would reach on PUT.
if (node.url && window.app.state.source === 'server' && !node.writable) 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;
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;
}
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,
// CodeMirror's "nocursor" mode is the truest read-only:
// selection allowed for copy, no caret, no edit affordances.
readOnly: !writable ? 'nocursor' : false,
});
// 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;
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) {
saveBtn.disabled = !isDirty || !writable;
dirtyEl.textContent = isDirty ? '● modified' : '';
}
editor.on('change', async function () {
var h = await hashContent(editor.getValue());
markDirty(h !== initialHash);
});
async function save() {
if (saveBtn.disabled) 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
};
})();