Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.
Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
/ a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
server-side via internal/zipfs (local file, no fetch, no signature;
re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.
Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
walker merges, cascade-summary adds, validate.go apps validation
(ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
apps:/apps_pubkey: in an existing .zddc is now silently ignored
(back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
drops the apps/apps_pubkey keys + appsmap case.
Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
server reads members from the filesystem internally.
Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
610 lines
26 KiB
JavaScript
610 lines
26 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;
|
|
|
|
var util = window.app.modules.util;
|
|
var escapeHtml = util.escapeHtml;
|
|
|
|
// ── 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) ─────────────────────────────────
|
|
|
|
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);
|
|
}
|
|
|
|
var isZipMemberNode = util.isZipMemberNode;
|
|
|
|
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;
|
|
}
|
|
|
|
var hashContent = util.hashContent;
|
|
|
|
// ── .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',
|
|
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 '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;
|
|
// 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) {
|
|
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, loadedEtag = null, loadedLastModified = null;
|
|
try {
|
|
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 =
|
|
'<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;
|
|
currentEtag = loadedEtag;
|
|
currentLastModified = loadedLastModified;
|
|
|
|
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);
|
|
});
|
|
|
|
// Adopt the new server ETag + refresh the dirty baseline after a
|
|
// successful write so save→edit→save doesn't false-conflict.
|
|
async function markSaved(content, res) {
|
|
if (currentEditor !== editor) return;
|
|
if (res && res.etag) currentEtag = res.etag;
|
|
initialHash = await hashContent(content);
|
|
if (currentEditor !== editor) return;
|
|
markDirty(false);
|
|
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
|
if (window.zddc && window.zddc.toast) {
|
|
window.zddc.toast('Saved ' + node.name, 'success');
|
|
}
|
|
}
|
|
|
|
// 412 → file changed on the server since load. Open the shared
|
|
// conflict dialog instead of clobbering.
|
|
async function resolveConflict(content) {
|
|
var conflict = window.app.modules.conflict;
|
|
var prev = window.app.modules.preview;
|
|
if (!conflict || !prev) return;
|
|
await conflict.open({
|
|
filename: node.name,
|
|
mineText: content,
|
|
fetchTheirs: function () {
|
|
return prev.getContentWithVersion(node).then(function (r) {
|
|
return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
|
|
});
|
|
},
|
|
onOverwrite: function () {
|
|
return prev.getContentWithVersion(node).then(function (cur) {
|
|
return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
|
|
}).then(function (res) { return markSaved(content, res); });
|
|
},
|
|
onReload: function () {
|
|
markDirty(false);
|
|
currentDirty = false;
|
|
return prev.showFilePreview(node);
|
|
},
|
|
onSaveCopy: function () {
|
|
return util.saveCopy(node, content, 'application/x-yaml; charset=utf-8')
|
|
.then(function (name) {
|
|
if (window.zddc && window.zddc.toast) {
|
|
window.zddc.toast('Saved your version as ' + name, 'success');
|
|
}
|
|
});
|
|
}
|
|
});
|
|
if (currentEditor === editor) statusEl.textContent = '';
|
|
}
|
|
|
|
async function save() {
|
|
if (saveBtn.disabled) return;
|
|
// Re-check authority at click time, not via the mount-time
|
|
// `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…';
|
|
var res = await saveContent(node, content, {
|
|
etag: currentEtag, lastModified: currentLastModified
|
|
});
|
|
await markSaved(content, res);
|
|
} catch (e) {
|
|
if (e && e.status === 412) {
|
|
if (currentEditor !== editor) return;
|
|
statusEl.textContent = 'Conflict — resolving…';
|
|
await resolveConflict(content);
|
|
return;
|
|
}
|
|
statusEl.textContent = 'Save failed: ' + (e.message || e);
|
|
if (window.zddc && window.zddc.toast) {
|
|
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
|
}
|
|
}
|
|
}
|
|
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
|
|
};
|
|
})();
|