diff --git a/browse/build.sh b/browse/build.sh index ee7e5bf..51469bb 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -91,7 +91,6 @@ concat_files \ "js/preview.js" \ "js/preview-markdown.js" \ "js/preview-yaml.js" \ - "js/preview-zddc-form.js" \ "js/hovercard.js" \ "js/grid.js" \ "js/upload.js" \ diff --git a/browse/css/manage-access.css b/browse/css/manage-access.css index b0b23e7..fde2983 100644 --- a/browse/css/manage-access.css +++ b/browse/css/manage-access.css @@ -27,28 +27,32 @@ word-break: break-all; } .ma-list { display: flex; flex-direction: column; gap: 0.4rem; } -.ma-row { display: flex; gap: 0.5rem; align-items: center; } -.ma-who { - flex: 1 1 auto; - min-width: 0; - padding: 0.35rem 0.5rem; +/* who fills the row and shrinks (min-width:0); level + delete size to content + so nothing overflows the dialog regardless of email/principal length. */ +.ma-row { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content max-content; + gap: 0.5rem; + align-items: center; +} +.ma-who, +.ma-level { + box-sizing: border-box; + padding: 0.4rem 0.5rem; font: inherit; border: 1px solid var(--border, #ccc); border-radius: 4px; background: var(--bg, #fff); color: var(--text, #222); } -.ma-level { - flex: 0 0 auto; - padding: 0.35rem 0.4rem; - font: inherit; - border: 1px solid var(--border, #ccc); - border-radius: 4px; - background: var(--bg, #fff); - color: var(--text, #222); +.ma-who { width: 100%; min-width: 0; } +.ma-level { width: 8.5rem; cursor: pointer; } +.ma-legend { + margin: 0.5rem 0 0; + font-size: 0.74rem; + color: var(--text-muted, #888); } .ma-del { - flex: 0 0 auto; border: none; background: transparent; color: var(--text-muted, #999); diff --git a/browse/js/manage-access.js b/browse/js/manage-access.js index 5874075..80d863c 100644 --- a/browse/js/manage-access.js +++ b/browse/js/manage-access.js @@ -113,13 +113,15 @@ var sel = el('select', 'ma-level'); LEVELS.forEach(function (lv) { - var o = el('option', null, lv.label + ' — ' + lv.hint); + var o = el('option', null, lv.label); o.value = lv.id; + o.title = lv.hint; sel.appendChild(o); }); if (model.level === 'custom') { - var o2 = el('option', null, 'Custom (' + model.custom + ')'); + var o2 = el('option', null, 'Custom'); o2.value = 'custom'; + o2.title = 'verbs: ' + model.custom; sel.appendChild(o2); } sel.value = model.level; @@ -147,6 +149,10 @@ }); box.appendChild(addBtn); + var legend = el('p', 'ma-legend', + 'View = read · Contribute = add new files · Edit = overwrite + add · Manage = admin'); + box.appendChild(legend); + // Inherit / make-private. var inhWrap = el('label', 'ma-inherit'); var inhBox = el('input'); diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index c23faa2..d3930cc 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -43,6 +43,26 @@ return ext === 'yaml' || ext === 'yml'; } + // The CodeMirror editor is the general editor for editable TEXT files that + // aren't markdown (markdown has its own editor). Syntax highlighting is + // YAML-only — that's the one CM mode in the vendored bundle — so every + // other type opens as a plaintext editor (still line numbers, find, + // selection, save). svg/json-as-image etc. stay with their preview + // renderers; this set is deliberately the "edit the source" types. + var CODE_EXTS = { + yaml: 1, yml: 1, txt: 1, text: 1, csv: 1, tsv: 1, tab: 1, + json: 1, xml: 1, html: 1, htm: 1, css: 1, js: 1, mjs: 1, + log: 1, ini: 1, conf: 1, cfg: 1, toml: 1, env: 1, + sh: 1, bash: 1, properties: 1 + }; + function isCodeFile(node) { + if (!node || node.isDir || node.isZip) return false; + if (isYamlFile(node)) return true; + return !!CODE_EXTS[(node.ext || '').toLowerCase()]; + } + // CodeMirror mode by extension — only yaml is vendored; others plaintext. + function codeMode(node) { return isYamlFile(node) ? 'yaml' : null; } + // ── Save (mirrors preview-markdown.js) ───────────────────────────────── function saveContent(node, content, opts) { @@ -345,8 +365,10 @@ schemaTag.addEventListener('keydown', function (ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); } }); - } else { + } else if (isYamlFile(node)) { schemaTag.textContent = 'YAML'; + } else { + schemaTag.textContent = (node.ext || 'text').toUpperCase(); } var dirtyEl = document.createElement('span'); @@ -386,16 +408,22 @@ } var writable = canSave(node); + var mode = codeMode(node); + // Lint (js-yaml + the .zddc schema) only applies to YAML; other text + // types are plaintext, so skip the lint gutter for them. + var yamlMode = mode === 'yaml'; var editor = window.CodeMirror(editorHost, { value: text, - mode: 'yaml', + mode: mode, lineNumbers: true, tabSize: 2, indentUnit: 2, indentWithTabs: false, lineWrapping: false, - gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'], - lint: { hasGutters: true }, + gutters: yamlMode + ? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'] + : ['CodeMirror-linenumbers'], + lint: yamlMode ? { hasGutters: true } : false, // 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 @@ -413,8 +441,8 @@ // 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(); + // Force an initial lint pass now that _zddcNode is set (YAML only). + if (yamlMode) editor.performLint(); // Schema completion + hover docs for .zddc files (the machine grammar // drives keys, enum/boolean values, and nested paths via $ref:"#"). // Plain .yaml gets no schema (lint + highlighting only). @@ -543,8 +571,7 @@ } function handles(node) { - if (!node || node.isDir || node.isZip) return false; - return isYamlFile(node); + return isCodeFile(node); } window.app.modules.yamledit = { diff --git a/browse/js/preview-zddc-form.js b/browse/js/preview-zddc-form.js deleted file mode 100644 index 3fb6104..0000000 --- a/browse/js/preview-zddc-form.js +++ /dev/null @@ -1,325 +0,0 @@ -// preview-zddc-form.js — schema-driven FORM view for .zddc files. -// -// The user shouldn't have to understand YAML cascades to configure a project. -// This renders the .zddc as a form: the OPTION fields (the blanks an operator -// fills — title, admins, role members) are editable widgets; the STRUCTURE -// (paths, WORM, tools, behaviours — what a ZDDC project IS) is shown read-only -// for context. The split is driven by the server's .zddc JSON Schema -// (/.api/zddc-schema, x-zddc-tier: structure|option). Saving merges the edited -// option values back into the file (preserving all structure keys) and PUTs the -// YAML — which works for an on-disk .zddc and for a .zddc.zip bundle member -// (the server's ServeZipWrite). An "Edit raw YAML" escape hands off to the -// CodeMirror editor for anything the form doesn't cover (field_codes, display, -// convert, advanced acl). -// -// This is the primary .zddc editor; the raw-YAML plugin (preview-yaml.js) is -// the power-user fallback. -(function (app) { - 'use strict'; - - var util = app.modules.util || window.app.modules.util; - var escapeHtml = util.escapeHtml; - var saveFile = util.saveFile; - var isEditableZipMember = util.isEditableZipMember; - - var current = null; // { node, dirty, etag, lastModified } - - // Cached .zddc schema (property → {tier, description}). - var schemaProps = null; - function loadSchema() { - if (schemaProps) return Promise.resolve(schemaProps); - return fetch('/.api/zddc-schema', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' }) - .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (j) { schemaProps = (j && j.properties) || {}; return schemaProps; }) - .catch(function () { schemaProps = {}; return schemaProps; }); - } - - function handles(node) { - return !!node && (node.name === '.zddc' || /\.zddc$/i.test(node.name || '')); - } - - function canSave(node) { - if (isEditableZipMember(node)) return true; - if (node.url && window.app.state.source === 'server' && window.zddc.cap) { - // A .zddc edit is an ActionAdmin write — needs the 'a' verb. - return window.zddc.cap.has(node, 'a'); - } - return false; - } - - function isDirty() { return !!(current && current.dirty); } - function currentNode() { return current ? current.node : null; } - function dispose() { current = null; } - - function desc(name) { - return (schemaProps && schemaProps[name] && schemaProps[name].description) || ''; - } - - // ── small DOM helpers ─────────────────────────────────────────────────── - function el(tag, cls, text) { - var e = document.createElement(tag); - if (cls) e.className = cls; - if (text != null) e.textContent = text; - return e; - } - // A growable list of single-string rows (used for admins + role members). - function listEditor(values, placeholder, onChange, readOnly) { - var wrap = el('div', 'zf-list'); - function addRow(val) { - var row = el('div', 'zf-list__row'); - row.style.cssText = 'display:flex;gap:.4rem;margin:.2rem 0;'; - var input = el('input'); - input.type = 'text'; - input.value = val || ''; - input.placeholder = placeholder || ''; - input.style.cssText = 'flex:1;padding:.3rem;font-family:var(--code,monospace);'; - input.disabled = !!readOnly; - input.addEventListener('input', onChange); - row.appendChild(input); - if (!readOnly) { - var del = el('button', null, '−'); - del.type = 'button'; - del.title = 'Remove'; - del.addEventListener('click', function () { row.remove(); onChange(); }); - row.appendChild(del); - } - wrap.appendChild(row); - } - (values || []).forEach(addRow); - if (!readOnly) { - var add = el('button', 'zf-add', '+ add'); - add.type = 'button'; - add.style.cssText = 'margin-top:.2rem;'; - add.addEventListener('click', function () { addRow(''); onChange(); }); - wrap.appendChild(add); - } - wrap._values = function () { - return Array.prototype.slice.call(wrap.querySelectorAll('.zf-list__row input')) - .map(function (i) { return i.value.trim(); }) - .filter(function (v) { return v; }); - }; - return wrap; - } - - async function render(node, container, ctx) { - dispose(); - var text, etag = null, lastModified = null; - try { - if (ctx.getContentWithVersion) { - var loaded = await ctx.getContentWithVersion(node); - text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf); - etag = loaded.etag; - lastModified = loaded.lastModified; - } else { - text = new TextDecoder('utf-8', { fatal: false }).decode(await ctx.getArrayBuffer(node)); - } - } catch (e) { - container.innerHTML = '
' - + 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '
'; - return; - } - - var data = {}; - try { data = (window.jsyaml && window.jsyaml.load(text)) || {}; } catch (_) { data = {}; } - if (typeof data !== 'object' || Array.isArray(data)) data = {}; - await loadSchema(); - - var editable = canSave(node); - current = { node: node, dirty: false, etag: etag, lastModified: lastModified }; - - container.innerHTML = ''; - var shell = el('div', 'yaml-shell zddc-form'); - shell.style.cssText = 'padding:.75rem 1rem;overflow:auto;height:100%;box-sizing:border-box;'; - container.appendChild(shell); - - // Header. - var hdr = el('div', 'md-shell__infohdr'); - hdr.appendChild(el('span', 'md-shell__title', node.name)); - var srcTag = el('span', 'md-shell__source', isEditableZipMember(node) ? 'config bundle' : (editable ? '.zddc form' : 'read-only')); - hdr.appendChild(srcTag); - var dirtyEl = el('span', 'md-shell__dirty'); - hdr.appendChild(dirtyEl); - var statusEl = el('span', 'md-shell__status'); - hdr.appendChild(statusEl); - var rawBtn = el('button', 'btn btn-sm btn-secondary', 'Edit raw YAML'); - rawBtn.type = 'button'; - rawBtn.title = 'Switch to the raw YAML editor (covers every key).'; - rawBtn.addEventListener('click', function () { - var ym = window.app.modules.yamledit; - if (ym && ym.render) { dispose(); ym.render(node, container, ctx); } - }); - hdr.appendChild(rawBtn); - var saveBtn = el('button', 'btn btn-sm btn-primary', 'Save'); - saveBtn.type = 'button'; - saveBtn.disabled = true; - hdr.appendChild(saveBtn); - shell.appendChild(hdr); - - function markDirty() { - if (!current) return; - current.dirty = true; - dirtyEl.textContent = '● modified'; - if (editable) saveBtn.disabled = false; - } - - var help = el('p', 'help'); - help.style.cssText = 'color:var(--color-text-muted,#666);font-size:.85rem;margin:.3rem 0 .5rem;'; - help.textContent = editable - ? 'Project options. Structural keys are read-only — use Edit raw YAML.' - : 'Read-only — you need admin authority over this path to edit it.'; - shell.appendChild(help); - - // ── OPTION fields ─────────────────────────────────────────────────── - function section(title, hint, tight) { - var s = el('section', 'zf-section'); - s.style.cssText = 'margin:0 0 1rem;'; - var h = el('h3', null, title); - // `tight` drops the heading's top margin for the FIRST section so - // it doesn't stack with the intro's bottom margin (the gap above - // Title was reading as excessive). Later sections keep the gap. - h.style.cssText = 'font-size:1em;margin:' + (tight ? '0' : '.6rem') + ' 0 .2rem;'; - s.appendChild(h); - if (hint) { - var p = el('p', 'help', hint); - p.style.cssText = 'color:var(--color-text-muted,#888);font-size:.8rem;margin:0 0 .3rem;'; - s.appendChild(p); - } - shell.appendChild(s); - return s; - } - - // title - var titleSec = section('Title', desc('title'), true); - var titleInput = el('input'); - titleInput.type = 'text'; - titleInput.value = (typeof data.title === 'string') ? data.title : ''; - titleInput.disabled = !editable; - titleInput.style.cssText = 'width:100%;max-width:32rem;padding:.35rem;'; - titleInput.addEventListener('input', markDirty); - titleSec.appendChild(titleInput); - - // admins - var adminsSec = section('Admins', desc('admins')); - var adminsList = listEditor(Array.isArray(data.admins) ? data.admins : [], 'email or *@domain', markDirty, !editable); - adminsSec.appendChild(adminsList); - - // roles (map name → {members:[]}) - var rolesSec = section('Roles', desc('roles') || 'Who belongs to each project role.'); - var rolesHost = el('div', 'zf-roles'); - rolesSec.appendChild(rolesHost); - var roleEditors = []; // {name, membersEl, getName} - function addRole(name, members) { - var box = el('div', 'zf-role'); - box.style.cssText = 'border:1px solid rgba(0,0,0,0.1);border-radius:4px;padding:.4rem .6rem;margin:.3rem 0;'; - var nameRow = el('div'); - nameRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.2rem;'; - var nameInput = el('input'); - nameInput.type = 'text'; - nameInput.value = name || ''; - nameInput.placeholder = 'role name (e.g. document_controller)'; - nameInput.style.cssText = 'font-family:var(--code,monospace);font-weight:600;flex:1;padding:.25rem;'; - nameInput.disabled = !editable; - nameInput.addEventListener('input', markDirty); - nameRow.appendChild(el('span', null, '👥')); - nameRow.appendChild(nameInput); - box.appendChild(nameRow); - var membersList = listEditor(members || [], 'member email or *@domain', markDirty, !editable); - box.appendChild(membersList); - rolesHost.appendChild(box); - roleEditors.push({ getName: function () { return nameInput.value.trim(); }, members: membersList }); - } - var roles = (data.roles && typeof data.roles === 'object') ? data.roles : {}; - Object.keys(roles).forEach(function (rn) { - var m = (roles[rn] && Array.isArray(roles[rn].members)) ? roles[rn].members : []; - addRole(rn, m); - }); - if (editable) { - var addRoleBtn = el('button', 'zf-add', '+ add role'); - addRoleBtn.type = 'button'; - addRoleBtn.addEventListener('click', function () { addRole('', []); markDirty(); }); - rolesSec.appendChild(addRoleBtn); - } - - // ── STRUCTURE (read-only) ─────────────────────────────────────────── - var structKeys = Object.keys(data).filter(function (k) { - return schemaProps[k] && schemaProps[k].tier === 'structure'; - }); - // Also surface option keys this form doesn't render yet, as read-only. - var rawHandled = { title: 1, admins: 1, roles: 1 }; - var otherKeys = Object.keys(data).filter(function (k) { - return !rawHandled[k] && !(schemaProps[k] && schemaProps[k].tier === 'structure'); - }); - if (structKeys.length || otherKeys.length) { - var det = el('details', 'zf-structure'); - det.style.cssText = 'margin-top:.5rem;'; - var sum = el('summary', null, 'Structure & advanced (read-only — edit via raw YAML)'); - sum.style.cssText = 'cursor:pointer;color:var(--color-text-muted,#666);font-size:.85rem;'; - det.appendChild(sum); - var subset = {}; - structKeys.concat(otherKeys).forEach(function (k) { subset[k] = data[k]; }); - var pre = el('pre'); - pre.style.cssText = 'background:var(--code-bg,#f6f8fa);padding:.5rem;border-radius:4px;overflow:auto;font-size:.8rem;'; - try { pre.textContent = window.jsyaml ? window.jsyaml.dump(subset) : JSON.stringify(subset, null, 2); } - catch (_) { pre.textContent = JSON.stringify(subset, null, 2); } - det.appendChild(pre); - shell.appendChild(det); - } - - // ── Save ──────────────────────────────────────────────────────────── - function buildContent() { - var out = {}; - // Preserve everything not managed by the form (structure + unrendered options). - Object.keys(data).forEach(function (k) { if (!rawHandled[k]) out[k] = data[k]; }); - var t = titleInput.value.trim(); - if (t) out.title = t; - var admins = adminsList._values(); - if (admins.length) out.admins = admins; - var rolesOut = {}; - roleEditors.forEach(function (re) { - var n = re.getName(); - if (!n) return; - var mem = re.members._values(); - rolesOut[n] = mem.length ? { members: mem } : { members: [] }; - }); - if (Object.keys(rolesOut).length) out.roles = rolesOut; - return window.jsyaml.dump(out); - } - - saveBtn.addEventListener('click', async function () { - if (!current || !editable) return; - saveBtn.disabled = true; - statusEl.textContent = 'Saving…'; - var content; - try { content = buildContent(); } - catch (e) { statusEl.textContent = 'Serialize failed: ' + (e.message || e); return; } - try { - var res = await saveFile(node, content, 'application/yaml; charset=utf-8', - { etag: current.etag, lastModified: current.lastModified }); - if (!current) return; - current.etag = (res && res.etag) || current.etag; - current.dirty = false; - dirtyEl.textContent = ''; - statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); - if (window.zddc && window.zddc.toast) window.zddc.toast('Saved ' + node.name, 'success'); - } catch (e) { - if (e && e.status === 412 && window.app.modules.conflict) { - window.app.modules.conflict.open({ - name: node.name, theirsText: '', minePut: function () { return saveFile(node, content, 'application/yaml; charset=utf-8', {}); } - }); - statusEl.textContent = 'Conflict — changed on server.'; - } else { - statusEl.textContent = 'Save failed: ' + (e && e.message ? e.message : e); - } - saveBtn.disabled = false; - } - }); - } - - app.modules.zddcform = { - handles: handles, - render: render, - isDirty: isDirty, - currentNode: currentNode, - dispose: dispose - }; -})(window.app); diff --git a/browse/js/preview.js b/browse/js/preview.js index cec602f..385a497 100644 --- a/browse/js/preview.js +++ b/browse/js/preview.js @@ -100,7 +100,7 @@ function editorModules() { var m = window.app.modules; - return [m.markdown, m.yamledit, m.zddcform].filter(Boolean); + return [m.markdown, m.yamledit].filter(Boolean); } function disposeEditors() { @@ -211,39 +211,27 @@ return; } - // .zddc form view: a schema-driven form (option fields editable, - // structure read-only) is the PRIMARY editor for .zddc files. It hands - // off to the raw YAML editor on demand. Other YAML files skip it. - var zddcForm = window.app.modules.zddcform; - if (zddcForm && zddcForm.handles(node)) { - try { - await zddcForm.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); - } catch (e) { - renderError(container, '.zddc form render failed: ' + (e.message || e)); - } - return; - } - - // YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a - // CodeMirror 5 editor with js-yaml linting; .zddc files also - // get a schema-aware lint pass. + // CodeMirror editor: the general editor for editable text files that + // aren't markdown — yaml/.zddc (schema lint + completion + hover) plus + // txt/csv/tsv/json/xml/html/css/js/… as a plaintext code editor. + // Guided dialogs (Manage access, …) are the front door for the common + // .zddc tasks; this is the full/raw edit surface. var yamlMod = window.app.modules.yamledit; if (yamlMod && yamlMod.handles(node)) { try { await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); } catch (e) { - renderError(container, 'YAML render failed: ' + (e.message || e)); + renderError(container, 'Editor failed: ' + (e.message || e)); } return; } - // PDF / HTML → iframe. - if (ext === 'pdf' || ext === 'html' || ext === 'htm') { + // PDF → iframe (HTML now routes to the editor above). + if (ext === 'pdf') { 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 = ''; + container.innerHTML = ''; } catch (e) { renderError(container, e.message || String(e)); }