(() => {
"use strict";
const $ = (s, r = document) => r.querySelector(s);
const boot = JSON.parse($("#boot").textContent || "{}");
const root = document.documentElement;
const main = $("#main"), docEl = $("#doc"), treeEl = $("#tree"), tocEl = $("#toc"), metaEl = $("#meta"), rail = $("#rail");
const treesEl = $("#trees"), browseEl = $("#browse-nav"), inboxRowEl = $("#inbox-row"), queueEl = $("#queue"), queueBar = $("#queue-bar");
const state = {
tree: boot.tree || [], sub: new Map(Object.entries(boot.sub || {})), view: boot.view || "inbox",
doc: boot.doc || null,
previous: boot.previous || null,
folder: boot.folder || null, queue: boot.queue || [], waiting: boot.waiting != null ? boot.waiting : (boot.queue || []).length, cache: new Map(), split: (() => { try { return localStorage.getItem("snyvi.split") === "1"; } catch { return false; } })(),
comparing: null, browse: boot.browse || [], browseRoot: boot.browseRoot || null,
browsePath: boot.browsePath || "",
preview: null, previewUrl: null,
previewOn: false,
previewKey: null, online: boot.online || {}, };
const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const rel = ts => {
const d = Date.now() / 1000 - ts;
if (d < 45) return "just now";
if (d < 3600) return `${Math.round(d / 60)} min ago`;
if (d < 86400) return `${Math.round(d / 3600)} h ago`;
const dt = new Date(ts * 1000);
if (d < 7 * 86400) return dt.toLocaleDateString(undefined, { weekday: "short" }) + " " + dt.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
return dt.toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
const fmt = ts => new Date(ts * 1000).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
const kindTag = k => ({ markdown: "md", code: "code", diff: "diff", text: "txt", image: "img", binary: "bin", table: "csv" }[k] || k);
const fmtSize = n => n >= 1048576 ? (n / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1024)) + " KB";
const store = { get: k => { try { return localStorage.getItem(k); } catch { return null; } }, set: (k, v) => { try { localStorage.setItem(k, v); } catch {} }, del: k => { try { localStorage.removeItem(k); } catch {} } };
let queueIds = new Set(state.queue.map(d => d.id));
const openProjects = new Set((store.get("snyvi.open") || "").split(",").filter(Boolean));
const liftedCaps = new Set();
const liftedWorkflows = new Set();
const filling = new Set();
const tried = new Set();
function renderBrowse() {
const ids = state.browse.map(r => r.id).join(",");
if (browseEl.dataset.ids === ids) return;
browseEl.dataset.ids = ids;
browseEl.innerHTML = !state.browse.length ? "" : `<div class="b-section"><div class="t-label">Folders</div>` + state.browse.map(r => {
const active = state.browseRoot && state.browseRoot.id === r.id;
return `<details class="b-root" data-root="${r.id}" ${active ? "open" : ""}><summary title="${esc(r.path)}">${esc(r.name)}<button class="b-close" data-close="${r.id}" title="Close folder">✕</button></summary><ul class="b-tree" data-root="${r.id}" data-path=""></ul></details>`;
}).join("") + `</div>`;
for (const ul of browseEl.querySelectorAll(".b-root[open] > .b-tree")) fillTree(ul);
}
function markActive() {
for (const a of treesEl.querySelectorAll("a.active, .t-inbox.active")) { a.classList.remove("active"); a.removeAttribute("aria-current"); }
const on = state.view === "inbox" ? inboxRowEl.querySelector(".t-inbox")
: state.view === "browse" && state.browseRoot ? browseEl.querySelector(`.b-file a[data-browse="${state.browseRoot.id}"][data-path="${CSS.escape(state.browsePath)}"]`)
: state.doc ? treeEl.querySelector(`a[data-id="${state.doc.id}"]`) : null;
if (on) { on.classList.add("active"); on.setAttribute("aria-current", "page"); }
}
const renameBtn = (what, id) =>
`<button class="ren" data-rename="${what}" data-id="${id}" title="Rename ${what}" aria-label="Rename ${what}">✎</button>`;
const projOpen = p => openProjects.has(String(p.id)) || (state.doc && state.doc.project_id === p.id) || state.tree.length === 1;
const docRow = d => {
const cls = [state.doc && state.doc.id === d.id ? "active" : "", waitingRow(d) ? "new" : ""].join(" ").trim();
return `<li class="t-doc${washCls(d.id)}"${moment(d.id)}><a href="/d/${d.id}" class="${cls}" data-id="${d.id}" title="${esc(d.title)} · ${fmt(d.received_at)}${waitingRow(d) ? " · waiting to be read" : ""}"><span class="title">${esc(d.title)}</span>${d.pinned ? `<span class="pin" title="Pinned">●</span>` : ""}<span class="k">${kindTag(d.kind)}</span></a></li>`;
};
const QUEUE_ROWS = 6; const QUEUE_HELD = 24;
const waitingRow = d => queueIds.has(d.id) || !!d.unread;
function unmarkRows(ids) {
for (const wfs of state.sub.values()) for (const w of wfs) for (const d of w.docs) if (d.unread && (!ids || ids.has(d.id))) d.unread = false;
}
let holdTimer = 0;
function holdQueue() {
if (state.queue.length >= QUEUE_HELD || state.waiting <= state.queue.length) return;
clearTimeout(holdTimer);
holdTimer = setTimeout(async () => {
try {
const q = await (await fetch(`/api/queue?limit=${QUEUE_HELD}`)).json();
if (Array.isArray(q)) { state.queue = q; renderTree(); markActive(); if (state.view === "inbox") showInbox(false); }
} catch {}
}, 150);
}
const queueRow = (d, extra = "") => `<li class="t-doc${extra}"${moment(d.id)}><a href="/d/${d.id}" class="new" data-id="${d.id}" title="${esc(d.title)} · ${esc(d.project)} · ${fmt(d.received_at)}"><span class="title">${esc(d.title)}</span><span class="k">${esc(d.project)}</span></a></li>`;
const WASH_MS = 700, LEAVE_MS = 140;
const washes = new Map(); const leaving = new Map(); let lastQueue = []; let sweep = 0;
function moment(id) {
const w = washes.get(id), l = leaving.get(id);
const t = l ? l.when : w;
if (t == null) return "";
const age = Date.now() - t;
return ` style="animation-delay:-${age}ms"`;
}
const washCls = id => (washes.has(id) ? " wash" : "");
function wash(ids) {
const now = Date.now();
for (const id of ids) washes.set(id, now);
schedule();
}
function depart(ids) {
const now = Date.now();
for (const id of ids) {
const at = lastQueue.findIndex(d => d.id === id);
if (at >= 0 && !leaving.has(id)) leaving.set(id, { d: lastQueue[at], at, when: now });
}
schedule();
}
function schedule() {
clearTimeout(sweep);
const now = Date.now();
const due = [...washes.values()].map(t => WASH_MS - (now - t))
.concat([...leaving.values()].map(l => LEAVE_MS - (now - l.when)));
if (!due.length) return;
sweep = setTimeout(() => { renderTree(); markActive(); schedule(); }, Math.max(0, Math.min(...due)) + 40);
}
const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
function renderQueue() {
queueIds = new Set(state.queue.map(d => d.id));
const now = Date.now();
for (const [id, t] of washes) if (now - t >= WASH_MS) washes.delete(id);
for (const [id, l] of leaving) if (now - l.when >= LEAVE_MS) leaving.delete(id);
const n = state.waiting, head = state.queue[0], shown = Math.min(n, QUEUE_ROWS);
const rows = state.queue.slice(0, QUEUE_ROWS).map(d => queueRow(d, washCls(d.id)));
const gone = [...leaving.values()].sort((a, b) => a.at - b.at);
for (const l of gone) if (!queueIds.has(l.d.id)) rows.splice(Math.min(l.at, rows.length), 0, queueRow(l.d, " leaving"));
const empty = (!n || !head) && !gone.length;
queueEl.innerHTML = empty ? "" : `<div class="t-queue${!n ? " leaving" : ""}"><div class="t-label">Waiting<span class="n">${n}</span></div><ul>` +
rows.join("") +
(n > shown ? `<li class="t-more"><a href="/" data-nav="inbox">${n - shown} more</a></li>` : "") + `</ul></div>`;
lastQueue = state.queue.slice(0, QUEUE_ROWS);
const bar = n > 0 && !!head && state.view !== "inbox";
queueBar.hidden = !bar;
if (!bar) { queueBar.innerHTML = ""; return; }
const count = `${n} waiting`, next = `<b>${esc(head.title)}</b> · ${esc(head.project)}`;
const qb = queueBar.querySelector(".qb");
if (!qb) {
queueBar.innerHTML = `<div class="qb"><span class="qb-n">${count}</span><span class="qb-next">${next}</span>` +
`<button type="button" data-q="next">Open<kbd>n</kbd></button><a href="/" class="qb-all" data-nav="inbox">Show all</a>` +
`<button type="button" class="icon" data-q="clear" title="Mark all read" aria-label="Mark all read">✕</button></div>`;
return;
}
const num = qb.querySelector(".qb-n"), nx = qb.querySelector(".qb-next");
if (nx.innerHTML !== next) nx.innerHTML = next;
if (num.textContent !== count) {
num.textContent = count;
const fresh = num.cloneNode(true);
fresh.classList.add("tick");
num.replaceWith(fresh);
}
}
function markRead(id) {
const held = queueIds.has(id);
let marked = false;
for (const wfs of state.sub.values()) for (const w of wfs) for (const d of w.docs) if (d.id === id && d.unread) marked = true;
if (!held && !marked) return;
if (held) { state.queue = state.queue.filter(d => d.id !== id); queueIds.delete(id); depart([id]); }
unmarkRows(new Set([id]));
state.waiting = Math.max(0, state.waiting - 1);
renderTree(); markActive(); fetch(`/api/docs/${id}/read`, { method: "POST" }).catch(() => {});
}
function openNext() {
const d = state.queue[0];
if (!d) { toast("Nothing waiting", "Every document that arrived has been opened."); return; }
showDoc(d.id, true);
}
async function clearQueue() {
const n = state.waiting;
if (!n) return;
depart(state.queue.map(d => d.id));
state.queue = []; state.waiting = 0;
unmarkRows(null);
renderTree(); markActive();
if (state.view === "inbox") showInbox(false);
try { await fetch("/api/queue/clear", { method: "POST" }); } catch {}
toast("Marked read", plural(n, "document"));
}
function dropFromQueue(ids, waiting) {
const gone = new Set(ids);
const known = state.queue.some(d => gone.has(d.id)) || (waiting != null && waiting !== state.waiting);
depart(gone);
state.queue = state.queue.filter(d => !gone.has(d.id));
unmarkRows(gone);
if (waiting != null) state.waiting = waiting;
if (!known) return;
renderTree(); markActive();
if (state.view === "inbox") showInbox(false);
holdQueue();
}
document.addEventListener("click", e => {
const b = e.target.closest("[data-q]");
if (!b) return;
e.preventDefault();
if (b.dataset.q === "next") openNext();
else if (b.dataset.q === "clear") clearQueue();
});
function projectRows(p) {
const wfs = state.sub.get(String(p.id));
if (!wfs) return `<li class="t-wait">…</li>`;
let h = "";
for (const w of wfs) {
h += `<li class="t-wf"><div class="wf-name" title="${esc(w.key)}"><span class="nm">${esc(w.title)}</span>${renameBtn("workflow", w.id)}</div><ul>`;
for (const d of w.docs) h += docRow(d);
if (w.total > w.docs.length) h += `<li class="t-more"><button type="button" data-more-docs="${w.id}">${w.total - w.docs.length} older</button></li>`;
h += `</ul></li>`;
}
if (p.workflows > wfs.length) h += `<li class="t-more"><button type="button" data-more-wf="${p.id}">${p.workflows - wfs.length} older sessions</button></li>`;
return h;
}
function renderTree() {
const projects = state.tree;
const total = projects.reduce((n, p) => n + p.docs, 0);
inboxRowEl.innerHTML = `<a class="t-inbox ${state.view === "inbox" ? "active" : ""}" href="/" data-nav="inbox"><span>Inbox</span><span class="n">${total}</span></a>`;
renderQueue();
renderBrowse();
if (!projects.length) {
treeEl.innerHTML = state.browse.length ? "" : `<div class="t-empty">Nothing here yet. Send something:<br><code>snyvi send README.md</code><br><br>Or read a folder:<br><code>snyvi browse .</code></div>`;
return;
}
let h = state.browse.length ? `<div class="t-label">Projects</div>` : "";
for (const p of projects) {
const open = projOpen(p);
h += `<details class="t-proj" data-pid="${p.id}" ${open ? "open" : ""}><summary title="${esc(p.root)}"><span class="nm">${esc(p.name)}</span>${renameBtn("project", p.id)}</summary><ul>`;
h += open ? projectRows(p) : "";
h += `</ul></details>`;
}
treeEl.innerHTML = h;
for (const p of projects) if (projOpen(p) && !state.sub.has(String(p.id))) fillProject(p.id);
}
async function fillProject(pid, force) {
pid = String(pid);
if (filling.has(pid) || (!force && (state.sub.has(pid) || tried.has(pid)))) return;
filling.add(pid);
tried.add(pid);
const q = new URLSearchParams();
if (liftedCaps.has(pid)) { q.set("workflows", "0"); q.set("docs", "0"); }
if (state.doc && String(state.doc.project_id) === pid) q.set("whole", state.doc.workflow_id);
try {
const wfs = await (await fetch(`/api/projects/${pid}/tree${q.size ? `?${q}` : ""}`)).json();
if (Array.isArray(wfs)) state.sub.set(pid, wfs);
} catch {}
filling.delete(pid);
await Promise.all((state.sub.get(pid) || [])
.filter(w => liftedWorkflows.has(w.id) && w.docs.length < w.total)
.map(w => fillWorkflow(w.id, pid)));
renderTree();
markActive();
}
async function fillWorkflow(wid, pid) {
let w;
try { w = await (await fetch(`/api/workflows/${wid}/tree`)).json(); } catch { return; }
if (!w || !Array.isArray(w.docs)) return;
const wfs = state.sub.get(String(pid));
if (!wfs) return;
const at = wfs.findIndex(x => x.id === wid);
if (at >= 0) wfs[at] = w; else wfs.unshift(w);
state.sub.set(String(pid), wfs);
}
async function ensureWorkflow(doc) {
if (!doc) return;
const pid = String(doc.project_id);
if (!state.sub.has(pid)) await fillProject(pid);
const wfs = state.sub.get(pid);
if (!wfs) return;
const at = wfs.findIndex(w => w.id === doc.workflow_id);
if (at >= 0 && wfs[at].docs.length >= wfs[at].total) return;
await fillWorkflow(doc.workflow_id, pid);
renderTree();
markActive();
}
async function refreshTree(only) {
try { state.tree = await (await fetch("/api/tree")).json(); } catch {}
const pids = only != null ? [String(only)] : [...state.sub.keys()];
await Promise.all(pids.filter(pid => state.sub.has(pid)).map(pid => fillProject(pid, true)));
renderTree();
markActive();
}
const entryHtml = (rootId, e) => e.dir
? `<li class="b-dir"><details data-root="${rootId}" data-path="${esc(e.path)}"><summary>${esc(e.name)}</summary><ul class="b-tree" data-root="${rootId}" data-path="${esc(e.path)}"></ul></details></li>`
: `<li class="b-file"><a href="/b/${rootId}/${e.path}" data-browse="${rootId}" data-path="${esc(e.path)}" title="${esc(e.path)}"><span class="title">${esc(e.name)}</span><span class="k">${fmtSize(e.size)}</span></a></li>`;
async function fillTree(ul) {
if (!ul || ul.dataset.loaded) return;
ul.dataset.loaded = "1";
ul.innerHTML = `<li class="b-empty">…</li>`;
const rootId = ul.dataset.root, path = ul.dataset.path || "";
let entries;
try { entries = await (await fetch(`/api/browse/${rootId}/tree?path=${encodeURIComponent(path)}`)).json(); } catch { ul.dataset.loaded = ""; return; }
if (!Array.isArray(entries)) { ul.dataset.loaded = ""; return; }
if (!entries.length) { ul.innerHTML = `<li class="b-empty">empty</li>`; return; }
ul.innerHTML = entries.map(e => entryHtml(rootId, e)).join("");
markActive();
}
async function reloadTree(ul) {
if (!ul || !ul.dataset.loaded) return;
const rootId = ul.dataset.root, path = ul.dataset.path || "";
let entries;
try { entries = await (await fetch(`/api/browse/${rootId}/tree?path=${encodeURIComponent(path)}`)).json(); } catch { return; }
if (!Array.isArray(entries) || !ul.isConnected) return;
const old = new Map([...ul.children].map(li => [li.querySelector("[data-path]")?.dataset.path, li]));
const tpl = document.createElement("template");
const nodes = entries.map(e => {
const li = old.get(e.path);
if (li && li.classList.contains(e.dir ? "b-dir" : "b-file")) {
const k = li.querySelector(".k"); if (k) k.textContent = fmtSize(e.size);
return li;
}
tpl.innerHTML = entryHtml(rootId, e);
return tpl.content.firstElementChild;
});
if (!nodes.length) { ul.innerHTML = `<li class="b-empty">empty</li>`; return; }
ul.replaceChildren(...nodes);
markActive();
}
treesEl.addEventListener("toggle", e => {
const d = e.target;
if (d.dataset && d.dataset.root && d.open) {
fillTree(d.querySelector(":scope > .b-tree"));
return;
}
if (!d.classList || !d.classList.contains("t-proj")) return;
const pid = d.dataset.pid;
d.open ? openProjects.add(pid) : openProjects.delete(pid);
store.set("snyvi.open", [...openProjects].join(","));
const ul = d.querySelector(":scope > ul");
if (!ul) return;
if (!d.open) {
ul.innerHTML = "";
return;
}
if (ul.firstChild) return;
const p = state.tree.find(x => String(x.id) === pid);
if (!p) return;
if (state.sub.has(pid)) {
ul.innerHTML = projectRows(p);
markActive();
} else {
fillProject(pid, true);
}
}, true);
treesEl.addEventListener("click", async e => {
const more = e.target.closest("[data-more-docs], [data-more-wf]");
if (more) {
e.preventDefault(); e.stopPropagation();
more.disabled = true;
if (more.dataset.moreWf != null) {
liftedCaps.add(String(more.dataset.moreWf));
await fillProject(more.dataset.moreWf, true);
} else {
const wid = Number(more.dataset.moreDocs);
const pid = [...state.sub.keys()].find(k => state.sub.get(k).some(w => w.id === wid));
liftedWorkflows.add(wid);
await fillWorkflow(wid, pid);
renderTree(); markActive();
}
return;
}
const r = e.target.closest("[data-rename]");
if (r) {
e.preventDefault(); e.stopPropagation();
startRename(r);
return;
}
const b = e.target.closest("[data-close]");
if (!b) return;
e.preventDefault(); e.stopPropagation();
const id = b.dataset.close;
try { await fetch(`/api/browse/${id}/close`, { method: "POST" }); } catch {}
state.browse = state.browse.filter(r => r.id !== id);
if (state.browseRoot && state.browseRoot.id === id) showInbox(true); else renderTree();
});
function startRename(btn) {
const holder = btn.parentElement;
const label = holder.querySelector(":scope > .nm");
if (!label || holder.querySelector("input.ren-in")) return;
const what = btn.dataset.rename, id = +btn.dataset.id, before = label.textContent;
const input = document.createElement("input");
input.className = "ren-in";
input.value = before;
input.spellcheck = false;
input.setAttribute("aria-label", `Name of this ${what}`);
label.replaceWith(input);
holder.classList.add("renaming");
input.focus(); input.select();
let settled = false;
const finish = async keep => {
if (settled) return;
settled = true;
const next = input.value.trim();
const label = document.createElement("span");
label.className = "nm";
label.textContent = before;
input.replaceWith(label);
holder.classList.remove("renaming");
if (!keep || !next || next === before) return;
label.textContent = next; try {
const where = what === "project" ? "projects" : "workflows";
const r = await fetch(`/api/${where}/${id}/rename`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: next }),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
await applyRename(what, id);
} catch (e) {
label.textContent = before;
toast("Could not rename", String(e));
}
};
input.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); finish(true); }
else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); finish(false); }
else e.stopPropagation();
});
input.addEventListener("blur", () => finish(true));
input.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); });
}
async function applyRename(what, id) {
state.cache.clear();
await refreshTree();
const shown = state.doc && (what === "project" ? state.doc.project_id === id : state.doc.workflow_id === id);
if (shown) await refreshDoc(state.doc.id);
}
const order = () => [...treeEl.querySelectorAll("a[data-id]")].map(a => a.dataset.id);
const siblings = () => {
if (!state.doc) return [];
for (const w of state.sub.get(String(state.doc.project_id)) || []) {
if (w.id === state.doc.workflow_id) return w.docs.map(d => d.id);
}
return [];
};
function setPreview(kind, url, key) {
if (state.previewKey !== key) { state.previewKey = key; state.previewOn = kind === "pdf"; }
state.preview = kind || null;
state.previewUrl = url || null;
if (!state.preview) state.previewOn = false;
}
function applyPreview() {
const art = docEl.querySelector("article");
if (!art || !state.previewOn || !state.previewUrl) return;
const wrap = document.createElement("article");
wrap.className = "preview";
const frame = document.createElement("iframe");
if (state.preview !== "pdf") frame.setAttribute("sandbox", "allow-scripts");
frame.setAttribute("referrerpolicy", "no-referrer");
frame.setAttribute("title", "Preview");
frame.src = state.previewUrl;
wrap.appendChild(frame);
art.replaceWith(wrap);
}
function togglePreview() {
if (!state.preview) return;
state.previewOn = !state.previewOn;
if (state.doc) showDoc(state.doc.id, false);
else if (browsing()) showBrowse(state.browseRoot.id, state.browsePath, false);
}
function swapIn() {
docEl.classList.remove("swap");
void docEl.offsetWidth;
docEl.classList.add("swap");
}
function docHtml(doc, body) {
let sub = `${esc(doc.project)} · ${esc(doc.workflow_title)}`;
if (doc.branch) sub += ` · <span class="branch">${esc(doc.branch)}</span>`;
sub += ` · ${fmt(doc.received_at)}`;
return `<header class="doc-head"><h1 class="doc-title">${esc(doc.title)}</h1><p class="doc-sub">${sub}</p></header><article class="prose kind-${doc.kind}">${body}</article>`;
}
async function fetchDoc(id) {
if (state.cache.has(id)) return state.cache.get(id);
const r = await fetch(`/api/docs/${id}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const j = await r.json();
if (state.cache.size > 40) state.cache.delete(state.cache.keys().next().value);
state.cache.set(id, j);
return j;
}
async function showDoc(id, push = true, fromHistory = false) {
let j;
try { j = await fetchDoc(id); } catch (e) { toast("Could not open document", String(e)); return; }
if (push) leave();
state.view = "doc"; state.doc = j.doc; state.previous = j.previous; state.comparing = null; state.folder = j.folder;
markRead(id);
setPreview(j.preview, j.preview_url, `d:${id}`);
docEl.innerHTML = j.html;
swapIn();
applyPreview();
if (j.doc.kind === "diff" && state.split) { await applySplit(); }
document.title = j.doc.title;
if (push) history.pushState({ id }, "", `/d/${id}`);
if (fromHistory && kept("id", id)) placeAt(history.state.place); else main.scrollTo({ top: 0, behavior: "instant" });
afterRender();
}
function leave() {
const reading = (state.view === "doc" && state.doc && !state.comparing) || (browsing() && state.browsePath);
if (reading) history.replaceState({ ...(history.state || {}), place: placeOf() }, "", location.pathname + location.hash);
}
let leaveTimer = 0;
main.addEventListener("scroll", () => { clearTimeout(leaveTimer); leaveTimer = setTimeout(leave, 400); }, { passive: true });
const kept = (key, value) => !!(history.state && history.state[key] === value && history.state.place);
function browseHtml(f, root) {
const sub = `${esc(root.name)} · ${esc(f.path)} · ${fmtSize(f.size)} · ${rel(f.modified)}`;
return `<header class="doc-head"><h1 class="doc-title">${esc(f.name)}</h1><p class="doc-sub">${sub}</p></header><article class="prose kind-${f.kind}">${f.html}</article>`;
}
async function showBrowse(rootId, path, push = true, fromHistory = false) {
path = path || "";
if (!path) {
let entries = [], root = state.browse.find(r => r.id === rootId);
try { entries = await (await fetch(`/api/browse/${rootId}/tree?path=`)).json(); } catch {}
if (push) leave();
state.view = "browse"; state.doc = null; state.previous = null; state.comparing = null;
state.browseRoot = root || state.browseRoot; state.browsePath = "";
setPreview(null, null, `b:${rootId}:`);
document.title = root ? root.name : "snyvi";
if (push) history.pushState({ browse: rootId, path: "" }, "", `/b/${rootId}`);
docEl.innerHTML = `<div class="inbox-head"><h1>${esc(root ? root.name : "Folder")}</h1><p>${esc(root ? root.path : "")}</p></div><ul class="inbox">` +
entries.map(e => `<li><a href="/b/${rootId}/${e.path}" data-browse="${rootId}" data-path="${esc(e.path)}"><span class="title">${e.dir ? "▸ " : ""}${esc(e.name)}</span><span class="time">${e.dir ? "" : fmtSize(e.size)}</span></a></li>`).join("") + `</ul>`;
swapIn();
main.scrollTo({ top: 0, behavior: "instant" });
afterRender();
return;
}
let j;
try {
const r = await fetch(`/api/browse/${rootId}/file?path=${encodeURIComponent(path)}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
j = await r.json();
} catch (e) { toast("Could not open file", String(e)); return; }
if (push) leave();
state.view = "browse"; state.doc = null; state.previous = null; state.comparing = null;
state.browseRoot = j.root; state.browsePath = path;
setPreview(j.file.preview, j.file.preview_url, `b:${rootId}:${path}`);
docEl.innerHTML = browseHtml(j.file, j.root);
swapIn();
applyPreview();
document.title = j.file.name;
if (push) history.pushState({ browse: rootId, path }, "", `/b/${rootId}/${path}`);
const restored = fromHistory && history.state.browse === rootId && kept("path", path);
if (restored) placeAt(history.state.place); else main.scrollTo({ top: 0, behavior: "instant" });
afterRender();
if (!restored && location.hash.length > 1 && !lineHash()) jumpToHash();
}
async function showInbox(push = true) {
if (push) leave();
state.view = "inbox"; state.doc = null; state.previous = null; state.browseRoot = null;
let items = boot.inbox;
if (!items || push) {
try { items = await (await fetch("/api/inbox?limit=60")).json(); } catch { items = []; }
}
boot.inbox = null;
let agents = null;
if (!items.length) {
agents = boot.agents; boot.agents = null;
if (!agents) { try { agents = await (await fetch("/api/agents")).json(); } catch {} }
}
document.title = "snyvi";
if (push) history.pushState({ inbox: true }, "", "/");
docEl.innerHTML = inboxHtml(items, agents);
if (push) swapIn();
afterRender();
if (!items.length) watchAgents();
if (state.waiting > state.queue.length) {
try {
const q = await (await fetch("/api/queue")).json();
if (Array.isArray(q) && state.view === "inbox") { state.queue = q; docEl.innerHTML = inboxHtml(items); renderTree(); markActive(); }
} catch {}
}
}
function inboxHtml(items, agents) {
const row = d => `<li><a href="/d/${d.id}" class="${waitingRow(d) ? "new" : ""}" data-id="${d.id}"><span class="title">${esc(d.title)}</span><span class="time">${rel(d.received_at)}</span><span class="sub"><b>${esc(d.project)}</b> · ${esc(d.workflow_title)} · ${kindTag(d.kind)}</span></a></li>`;
if (!items.length) return connectHtml(agents);
const n = state.waiting;
return `<div class="inbox-head"><h1>Inbox</h1><p>${n ? `${plural(n, "document")} waiting to be read, then everything else, newest first.` : "Newest first, across every project."}</p></div>` +
(n ? `<h2 class="inbox-sec">Waiting<span class="n">${n}</span><button type="button" data-q="next">Open the first<kbd>n</kbd></button><button type="button" data-q="clear">Mark all read</button></h2><ul class="inbox waiting">${state.queue.map(row).join("")}</ul><h2 class="inbox-sec">Recent</h2>` : "") +
`<ul class="inbox">${items.map(row).join("")}</ul>`;
}
let agentsSeen = "", agentsTimer = 0;
function connectHtml(a) {
const rows = a ? a.rows : [];
agentsSeen = JSON.stringify(rows);
const cmd = (text, cls) => `<pre class="cmd ${cls || ""}"><code>${esc(text)}</code><button type="button" class="copy" title="Copy">Copy</button></pre>`;
const row = r => {
const other = r.id.startsWith("sender:");
const live = r.live || 0;
const when = r.last_sent != null ? ` · sent ${rel(r.last_sent)}` : "";
let say, state;
if (other) { state = "connected"; say = `Calls itself <code>${esc(r.name)}</code>, and ${live ? "is here now" : "has sent"}: connected.`; }
else if (r.state === "connected") { state = "connected"; say = `Registered in <code>${esc(r.file)}</code> as <code>${esc(r.command)} ${esc(r.args.join(" "))}</code>.${r.last_sent == null ? " Nothing has arrived from it yet." : ""}`; }
else if (r.state === "stale") { state = "stale"; say = `Registered in <code>${esc(r.file)}</code> as <code>${esc(r.command)}</code>, which no longer exists — every send fails.`; }
else if (r.state === "unreadable") { state = "stale"; say = `<code>${esc(r.file)}</code> could not be read (${esc(r.error)}), so it is not edited. Put the entry in by hand.`; }
else if (live) { state = "off"; say = r.file ? `Nothing in <code>${esc(r.file)}</code>, yet it is here: registered somewhere else, a project's own settings perhaps.` : `Here, though not set up in any file snyvi reads.`; }
else { state = "off"; say = r.file ? `Nothing in <code>${esc(r.file)}</code>.` : `Not set up.`; }
const word = live ? `online${live > 1 ? ` ×${live}` : ""}` : { connected: "connected", stale: "needs fixing", off: "not set up" }[state];
if (live) state += " is-live";
const fix = other || r.state === "connected" ? "" :
`<div class="agent-fix">${r.state === "unreadable" ? "" : cmd(r.fix.command)}<details><summary>${r.state === "unreadable" ? "In" : "Or by hand, in"} <code>${esc(r.fix.place)}</code></summary>${cmd(r.fix.snippet, "snippet")}</details></div>`;
const i = r.instructions;
const line = other || !i ? "" : `<p class="agent-instr">${
i.present ? `Asked to send what it writes, in <code>${esc(i.place)}</code>.`
: state === "connected" ? `Not yet asked to send what it writes: the line below goes in <code>${esc(i.place)}</code>.`
: `Then the line below, in <code>${esc(i.place)}</code>.`}</p>`;
return `<li class="agent is-${state}" data-agent="${esc(r.id)}"><div class="agent-head"><span class="agent-dot"></span><b class="agent-name">${esc(r.name)}</b><span class="agent-state">${word}${when}</span></div><p class="agent-say">${say}</p>${fix}${line}</li>`;
};
const line = rows.find(r => r.instructions)?.instructions.line || "";
return `<div class="connect"><header class="doc-head"><h1 class="doc-title">Connect an agent</h1><p class="doc-sub">Any agent that speaks MCP can send documents here. Each row is what that agent's own settings say about snyvi, right now.</p></header>` +
`<ul class="agents">${rows.map(row).join("")}</ul>` +
(line ? `<div class="connect-line"><p>The line that makes an agent send what it writes, for its instructions file or its rules setting:</p>${cmd(line)}</div>` : "") +
`<p class="connect-foot">From a terminal, <code>${esc(a ? a.program : "snyvi")} send PLAN.md</code> sends a file by hand.</p></div>`;
}
async function showConnect(push = true) {
if (push) leave();
state.view = "connect"; state.doc = null; state.previous = null; state.comparing = null; state.browseRoot = null;
document.title = "Connect an agent · snyvi";
if (push) history.pushState({ connect: true }, "", "/connect");
let a = boot.agents; boot.agents = null;
if (!a) { try { a = await (await fetch("/api/agents")).json(); } catch { a = null; } }
docEl.innerHTML = connectHtml(a);
if (push) swapIn();
main.scrollTo({ top: 0, behavior: "instant" });
afterRender();
watchAgents();
}
function watchAgents() {
clearInterval(agentsTimer);
agentsTimer = setInterval(refreshAgents, 2500);
}
async function refreshAgents() {
if (!docEl.querySelector(".connect") || document.hidden) return;
let a; try { a = await (await fetch("/api/agents")).json(); } catch { return; }
if (JSON.stringify(a.rows) === agentsSeen) return;
const open = [...docEl.querySelectorAll(".agent details[open]")].map(d => d.closest(".agent").dataset.agent);
docEl.innerHTML = connectHtml(a);
for (const id of open) docEl.querySelector(`.agent[data-agent="${CSS.escape(id)}"] details`)?.setAttribute("open", "");
}
const liveEl = $("#live");
function renderLive() {
const names = Object.entries(state.online).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const n = names.reduce((t, [, k]) => t + k, 0);
liveEl.textContent = String(n);
liveEl.classList.toggle("on", n > 0);
liveEl.title = n ? `${plural(n, "agent")} connected: ${names.map(([k, c]) => c > 1 ? `${k} ×${c}` : k).join(", ")}` : "No agent is connected";
}
function setOnline(map) {
state.online = map && typeof map === "object" ? map : {};
renderLive();
refreshAgents();
}
renderLive();
docEl.addEventListener("click", e => {
const b = e.target.closest(".connect pre.cmd .copy");
if (!b) return;
navigator.clipboard?.writeText(b.parentElement.querySelector("code").textContent);
b.textContent = "Copied"; setTimeout(() => (b.textContent = "Copy"), 1200);
});
async function showCompare(aId, bId) {
const cur = state.doc;
const a = aId || state.previous, b = bId || (cur && cur.id);
if (!cur || !a) { toast("No previous version", "This is the first document in its workflow."); return; }
let j;
try { j = await (await fetch(`/api/compare/${a}/${b}${state.split ? "?view=split" : ""}`)).json(); } catch (e) { toast("Compare failed", String(e)); return; }
state.comparing = { a, b };
docEl.innerHTML = `<header class="doc-head"><h1 class="doc-title">${esc(cur.title)}</h1><p class="doc-sub">changes ${fmt(j.a.received_at)} → ${fmt(j.b.received_at)}${state.split ? " · split" : " · inline"}</p></header><article class="prose kind-diff">${j.html}</article>`;
swapIn();
main.scrollTo({ top: 0, behavior: "instant" });
buildToc(); renderMeta(true); enhanceCode();
}
async function applySplit() {
const art = docEl.querySelector("article.kind-diff");
if (!art || !state.doc) return;
try { const j = await (await fetch(`/api/docs/${state.doc.id}/split`)).json(); art.innerHTML = j.html; } catch {}
}
async function toggleSplit() {
state.split = !state.split;
store.set("snyvi.split", state.split ? "1" : "0");
if (state.comparing) { await showCompare(state.comparing.a, state.comparing.b); return; }
if (state.doc && state.doc.kind === "diff") { state.cache.delete(state.doc.id); await showDoc(state.doc.id, false); }
else toast("Split view", state.split ? "on, for diffs" : "off");
}
const UNDO_MS = 8000;
let undoing = null;
async function deleteCurrent() {
if (!state.doc) return;
const d = state.doc;
try {
const r = await fetch(`/api/docs/${d.id}/delete`, { method: "POST" });
if (!r.ok) throw new Error(`${r.status}`);
state.cache.delete(d.id);
depart([d.id]);
state.queue = state.queue.filter(x => x.id !== d.id);
state.waiting = Math.max(0, state.waiting - (waitingRow(d) ? 1 : 0));
await refreshTree(d.project_id);
showInbox(true);
offerUndo(d);
} catch (e) { toast("Could not delete", String(e)); }
}
function offerUndo(d) {
const run = async () => {
if (undoing !== run) return;
undoing = null;
try {
const r = await fetch(`/api/docs/${d.id}/undelete`, { method: "POST" });
if (r.status === 410) return toast("Too late to undo", "it has been pruned");
if (!r.ok) throw new Error(`${r.status}`);
wash([d.id]);
await refreshTree(d.project_id);
showDoc(d.id);
} catch (e) { toast("Could not undo", String(e)); }
};
undoing = run;
setTimeout(() => { if (undoing === run) undoing = null; }, UNDO_MS);
toast("Deleted", d.title, null, { label: "Undo", run });
}
function afterRender() {
renderTree();
markActive();
ensureWorkflow(state.doc);
buildToc();
renderMeta(false);
enhanceCode();
prepareMermaid();
renderHistory();
clearFind();
applyLineHash(true);
}
function afterRefresh() {
buildToc();
renderMeta(false);
enhanceCode();
prepareMermaid();
renderHistory();
if (!findBar.hidden && findIn.value) runFind(findIn.value); else clearFind();
applyLineHash(false); }
function placeOf() {
const top = main.scrollTop, edge = main.getBoundingClientRect().top + 1;
const blocks = docEl.querySelectorAll(".prose > *");
let i = -1, delta = 0;
for (let n = 0; n < blocks.length; n++) {
const r = blocks[n].getBoundingClientRect();
if (r.bottom > edge) { i = n; delta = r.top - edge + 1; break; }
}
return { top, i, delta };
}
function placeAt(p) {
const el = p.i >= 0 ? docEl.querySelectorAll(".prose > *")[p.i] : null;
if (!el) { main.scrollTo({ top: p.top, behavior: "instant" }); return; }
const put = () => {
if (!el.isConnected) return; el.scrollIntoView({ block: "start", behavior: "instant" });
main.scrollBy({ top: el.getBoundingClientRect().top - main.getBoundingClientRect().top - p.delta, behavior: "instant" });
};
put();
requestAnimationFrame(() => requestAnimationFrame(put));
if (docEl.classList.contains("swap")) docEl.addEventListener("animationend", put, { once: true });
}
async function refreshDoc(id) {
state.cache.delete(id);
if (!state.doc || state.doc.id !== id || state.comparing) return;
const place = placeOf();
let j; try { j = await fetchDoc(id); } catch { return; }
if (!state.doc || state.doc.id !== id) return;
state.doc = j.doc; state.previous = j.previous; state.folder = j.folder;
setPreview(j.preview, j.preview_url, `d:${id}`);
docEl.innerHTML = j.html;
applyPreview();
if (j.doc.kind === "diff" && state.split) await applySplit();
placeAt(place);
afterRefresh();
}
async function refreshBrowsed() {
if (!browsing() || !state.browsePath) return;
const rootId = state.browseRoot.id, path = state.browsePath;
let j;
try {
const r = await fetch(`/api/browse/${rootId}/file?path=${encodeURIComponent(path)}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
j = await r.json();
} catch {
toast(`${path.split("/").pop()} is gone`, "removed or renamed on disk; showing the last version");
return;
}
if (!browsing() || state.browseRoot.id !== rootId || state.browsePath !== path) return;
const place = placeOf();
setPreview(j.file.preview, j.file.preview_url, `b:${rootId}:${path}`);
docEl.innerHTML = browseHtml(j.file, j.root);
applyPreview();
placeAt(place);
afterRefresh();
}
let mermaidReady = null; let mermaidTheme = null; let mmdToken = 0; let mmdQueue = [];
let mmdDraining = false;
let mmdSeq = 0;
let mmdWatcher = null;
const mmdCache = new Map(); const MMD_CACHE_BYTES = 4 << 20;
const MMD_ID = "__mmd_id__";
let mmdCacheBytes = 0;
const mmdRenderId = fig => `${fig.dataset.mmdId}-svg`;
const mmdKey = src => `${mmdCurrentTheme()}\n${src}`;
const mmdCached = src => mmdCache.has(mmdKey(src));
const mmdSize = e => (e.svg || e.err || "").length;
function mmdTake(key) {
const entry = mmdCache.get(key);
if (entry === undefined) return null;
mmdCache.delete(key);
mmdCache.set(key, entry);
return entry;
}
function mmdKeep(key, entry) {
if (mmdCache.has(key)) mmdCacheBytes -= mmdSize(mmdCache.get(key));
mmdCache.set(key, entry);
mmdCacheBytes += mmdSize(entry);
for (const [k, v] of mmdCache) {
if (mmdCacheBytes <= MMD_CACHE_BYTES || k === key) break;
mmdCache.delete(k);
mmdCacheBytes -= mmdSize(v);
}
}
const MMD_CAP_LINES = 150, MMD_CAP_BYTES = 20000;
function mmdWeight(src) {
let n = 0;
for (const line of src.split("\n")) {
const t = line.trim();
if (t && !t.startsWith("%%")) n++;
}
return n;
}
const yieldToBrowser = () =>
window.scheduler && window.scheduler.yield
? window.scheduler.yield()
: new Promise(r => setTimeout(r, 0));
function mmdNote(fig, ...nodes) {
const note = document.createElement("div");
note.className = "mmd-note";
note.append(...nodes);
const frame = fig.querySelector(".mmd-frame");
frame.textContent = "";
frame.append(note);
return note;
}
function mmdWatch() {
if (mmdWatcher) mmdWatcher.disconnect();
mmdWatcher = new IntersectionObserver(entries => {
for (const e of entries) {
if (!e.isIntersecting) continue;
mmdWatcher.unobserve(e.target);
mmdEnqueue(e.target);
}
}, { root: main, rootMargin: "600px 0px" });
}
function prepareMermaid() {
if (document.fullscreenElement) quiet(document.exitFullscreen());
mmdToken++;
mmdQueue = [];
if (mmdWatcher) mmdWatcher.disconnect();
mmdWatcher = null;
const pres = docEl.querySelectorAll("pre.mermaid");
if (!pres.length) return;
mmdWatch();
for (const pre of pres) {
const fig = document.createElement("figure");
fig.className = "mmd";
fig.dataset.src = pre.textContent.trim();
fig.dataset.mmdId = `mmd-${++mmdSeq}`;
const frame = document.createElement("div");
frame.className = "mmd-frame";
fig.appendChild(frame);
pre.replaceWith(fig);
mmdReserve(fig);
}
mmdPrefetch();
}
function mmdRetheme() {
const figs = [...docEl.querySelectorAll(".mmd")];
if (!figs.length) return;
mmdToken++;
mmdQueue = [];
mmdWatch();
for (const fig of figs) mmdReserve(fig);
}
function mmdReserve(fig) {
const src = fig.dataset.src;
const weight = mmdWeight(src);
fig.classList.remove("mmd-slow");
fig.style.setProperty("--mmd-reserve", `${Math.min(520, Math.max(240, 170 + weight * 4))}px`);
if ((weight > MMD_CAP_LINES || src.length > MMD_CAP_BYTES) && !mmdCached(src)) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mmd-ask";
btn.dataset.mmdRender = "";
btn.textContent = "Render diagram";
const why = document.createElement("span");
why.className = "mmd-why";
why.textContent = `${weight} lines — this one takes a moment`;
fig.style.setProperty("--mmd-reserve", "150px");
fig.dataset.state = "held";
mmdNote(fig, btn, why);
} else {
fig.dataset.state = "pending";
mmdNote(fig, document.createTextNode("Diagram"));
mmdWatcher.observe(fig);
}
}
function mmdPrefetch() {
if (mermaidReady) return;
const go = () => { if (!mermaidReady) mermaidLib().catch(() => {}); };
if (window.requestIdleCallback) requestIdleCallback(go, { timeout: 2000 });
else setTimeout(go, 400);
}
function mmdEnqueue(fig) {
if (fig.dataset.state === "queued" || fig.dataset.state === "rendering" || fig.dataset.state === "done") return;
fig.dataset.state = "queued";
mmdNote(fig, document.createTextNode("Diagram"));
mmdQueue.push(fig);
mmdDrain();
}
function mermaidLib() {
if (!mermaidReady) {
performance.mark("snyvi:mermaid-load");
mermaidReady = new Promise((res, rej) => {
const sc = document.createElement("script");
sc.src = `/assets/mermaid.js${boot.v ? `?v=${boot.v}` : ""}`;
sc.onload = () => { performance.mark("snyvi:mermaid-ready"); res(); };
sc.onerror = () => rej(new Error("could not load the diagram library"));
document.head.appendChild(sc);
});
}
return mermaidReady;
}
function mmdCurrentTheme() {
const dark = root.dataset.theme === "dark" || (!root.dataset.theme && matchMedia("(prefers-color-scheme: dark)").matches);
return dark ? "dark" : "light";
}
function mmdTheme() {
const cs = getComputedStyle(root);
const v = n => cs.getPropertyValue(n).trim();
const bg = v("--bg"), raise = v("--bg-raise"), side = v("--bg-side");
const fg = v("--fg"), fg2 = v("--fg-2"), fg3 = v("--fg-3");
const rule = v("--rule"), rule2 = v("--rule-2");
const accent = v("--accent"), accentBg = v("--accent-bg");
return {
fontFamily: v("--sans"),
themeVariables: {
background: bg, edgeLabelBackground: bg,
mainBkg: raise, primaryColor: raise, actorBkg: raise, stateBkg: raise,
secondaryColor: side, clusterBkg: side, labelBoxBkgColor: side,
primaryTextColor: fg, textColor: fg, nodeTextColor: fg,
stateLabelColor: fg,
signalColor: fg2, signalTextColor: fg2, titleColor: fg2,
lineColor: fg3,
clusterBorder: rule,
nodeBorder: rule2, primaryBorderColor: rule2, actorBorder: rule2,
noteBkgColor: accentBg, activationBkgColor: accentBg,
noteBorderColor: accent, activationBorderColor: accent,
sectionBkgColor: bg, altSectionBkgColor: side, sectionBkgColor2: bg,
taskBkgColor: raise, taskBorderColor: rule2,
activeTaskBkgColor: accentBg, activeTaskBorderColor: accent,
doneTaskBkgColor: side, doneTaskBorderColor: rule2,
critBkgColor: accentBg, critBorderColor: accent,
taskTextColor: fg, taskTextDarkColor: fg, taskTextLightColor: fg,
taskTextOutsideColor: fg2, taskTextClickableColor: accent,
gridColor: rule, todayLineColor: accent,
},
themeCSS: `
.node rect, .node circle, .node ellipse, .node polygon, .node path { stroke-width: 1px; }
.edgePath .path, .flowchart-link { stroke-width: 1.25px; }
.cluster rect { rx: 8px; ry: 8px; }
.nodeLabel, .edgeLabel, .label, .messageText, .loopText, .noteText { letter-spacing: .01em; }
text.title, .titleText { font-family: ${v("--serif")}; font-size: 18px; font-weight: 600; }
.node.focus rect, .node.focus circle, .node.focus ellipse, .node.focus polygon, .node.focus path { fill: ${accent}; stroke: ${accent}; }
.node.focus .nodeLabel { color: ${bg}; fill: ${bg}; }
.node.muted rect, .node.muted circle, .node.muted ellipse, .node.muted polygon, .node.muted path { fill: ${bg}; stroke: ${rule}; }
.node.muted .nodeLabel { color: ${fg3}; fill: ${fg3}; }
`,
};
}
function mmdInit() {
const theme = mmdCurrentTheme();
if (theme === mermaidTheme) return;
mermaidTheme = theme;
window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "base", ...mmdTheme() });
}
async function mmdDrain() {
if (mmdDraining) return;
mmdDraining = true;
const token = mmdToken;
try {
await mermaidLib();
if (token !== mmdToken) return;
mmdInit();
while (mmdQueue.length && token === mmdToken) {
const fig = mmdQueue.shift();
if (!fig.isConnected || fig.dataset.state !== "queued") continue;
await mmdRender(fig, token);
if (mmdQueue.length) await yieldToBrowser();
}
} catch (e) {
console.warn("mermaid", e);
if (token === mmdToken) {
for (const fig of mmdQueue) if (fig.isConnected) mmdFail(fig, e);
mmdQueue = [];
}
} finally {
mmdDraining = false;
if (mmdQueue.length) mmdDrain();
}
}
async function mmdRender(fig, token) {
fig.dataset.state = "rendering";
const key = mmdKey(fig.dataset.src);
const hit = mmdTake(key);
if (hit !== null) {
fig.classList.remove("mmd-slow");
hit.svg ? mmdPaint(fig, hit.svg, performance.now()) : mmdFail(fig, hit.err);
return;
}
const slow = setTimeout(() => fig.classList.add("mmd-slow"), 150);
const t0 = performance.now();
try {
const id = mmdRenderId(fig);
const { svg } = await window.mermaid.render(id, fig.dataset.src);
const kept = svg.split(id).join(MMD_ID);
mmdKeep(key, { svg: kept });
if (token !== mmdToken || !fig.isConnected) return;
mmdPaint(fig, kept, t0);
} catch (e) {
mmdKeep(key, { err: e && e.message ? e.message : String(e) });
if (token === mmdToken && fig.isConnected) mmdFail(fig, e);
} finally {
clearTimeout(slow);
fig.classList.remove("mmd-slow");
}
}
function mmdPaint(fig, svg, t0) {
const frame = fig.querySelector(".mmd-frame");
frame.innerHTML = svg.split(MMD_ID).join(mmdRenderId(fig));
fig.dataset.state = "done";
mmdViewport(fig);
performance.measure("snyvi:diagram", { start: t0, end: performance.now() });
}
const mmdViews = new WeakMap();
const MMD_MAX_ZOOM = 40; const MMD_MIN_FIT = 0.15; let mmdTouched = null;
const mmdCap = () => Math.max(260, Math.min(680, Math.round(innerHeight * 0.7)));
function mmdViewport(fig) {
const svg = fig.querySelector("svg");
const frame = fig.querySelector(".mmd-frame");
if (!svg || !frame) {
mmdViews.delete(fig);
return;
}
const full = fig.dataset.full === "1";
const width = full ? Math.round(innerWidth) : frame.clientWidth;
if (!width) return;
mmdViews.delete(fig);
const vb = (svg.dataset.mmdBase || svg.getAttribute("viewBox") || "").trim().split(/[\s,]+/).map(Number);
if (vb.length !== 4 || vb.some(n => !Number.isFinite(n)) || vb[2] <= 0 || vb[3] <= 0) return;
const base = { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
svg.dataset.mmdBase = `${base.x} ${base.y} ${base.w} ${base.h}`;
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.style.maxWidth = "none";
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
const fitScale = width / base.w;
const smudge = fitScale < MMD_MIN_FIT;
const height = full ? Math.round(innerHeight)
: smudge ? mmdCap()
: base.w > width ? Math.max(220, Math.min(mmdCap(), Math.round(base.h * fitScale)))
: Math.round(base.h);
frame.style.height = `${height}px`;
mmdViews.set(fig, { base, svg, frame, view: { ...base }, fit: null });
mmdFit(fig);
if (smudge && !full) mmdStart(fig);
mmdTools(fig);
}
function mmdFit(fig) {
const v = mmdViews.get(fig);
if (!v) return;
const r = v.frame.getBoundingClientRect();
const shape = (r.width || 1) / (r.height || 1);
const { base } = v;
const w = base.w / base.h > shape ? base.w : base.h * shape;
const h = w / shape;
v.fit = { x: base.x + (base.w - w) / 2, y: base.y + (base.h - h) / 2, w, h };
v.view = { ...v.fit };
v.shrunk = (r.width || 1) / w < 0.995;
mmdApply(fig);
}
function mmdStart(fig) {
const v = mmdViews.get(fig);
if (!v) return;
const r = v.frame.getBoundingClientRect();
const w = Math.min(v.fit.w, r.width || v.fit.w);
const h = w * v.fit.h / v.fit.w;
v.view = { x: v.base.x, y: v.base.y, w, h };
mmdClamp(fig);
mmdApply(fig);
}
function mmdScale(fig) {
const v = mmdViews.get(fig);
if (!v) return 1;
const r = v.svg.getBoundingClientRect();
return Math.min(r.width / v.view.w, r.height / v.view.h) || 1;
}
function mmdPoint(fig, cx, cy) {
const v = mmdViews.get(fig);
if (!v || cx == null) return null;
const r = v.svg.getBoundingClientRect();
const s = Math.min(r.width / v.view.w, r.height / v.view.h);
if (!(s > 0)) return null;
const ox = (r.width - v.view.w * s) / 2, oy = (r.height - v.view.h * s) / 2;
return { x: v.view.x + (cx - r.left - ox) / s, y: v.view.y + (cy - r.top - oy) / s };
}
function mmdClamp(fig) {
const { base, view } = mmdViews.get(fig);
const cx = Math.min(Math.max(view.x + view.w / 2, base.x), base.x + base.w);
const cy = Math.min(Math.max(view.y + view.h / 2, base.y), base.y + base.h);
view.x = cx - view.w / 2;
view.y = cy - view.h / 2;
}
function mmdApply(fig) {
const v = mmdViews.get(fig);
if (!v) return;
const { view, fit } = v;
v.svg.setAttribute("viewBox", `${view.x} ${view.y} ${view.w} ${view.h}`);
const zoomed = !!fit && view.w < fit.w - 0.5;
fig.dataset.zoom = zoomed ? "in" : "fit";
const toggle = fig.querySelector("[data-mmd=zoom]");
if (toggle) {
toggle.hidden = !v.shrunk && !zoomed;
toggle.textContent = zoomed ? "Fit" : "100%";
toggle.title = zoomed ? "Fit the whole diagram 0" : "Show it at full size";
}
}
function mmdZoom(fig, k, cx, cy) {
const v = mmdViews.get(fig);
if (!v || !v.fit) return;
const w = Math.max(v.fit.w / MMD_MAX_ZOOM, Math.min(v.fit.w, v.view.w / k));
if (Math.abs(w - v.view.w) < 0.01) return;
const h = w * v.view.h / v.view.w;
const p = mmdPoint(fig, cx, cy) || { x: v.view.x + v.view.w / 2, y: v.view.y + v.view.h / 2 };
v.view.x = p.x - (p.x - v.view.x) * (w / v.view.w);
v.view.y = p.y - (p.y - v.view.y) * (h / v.view.h);
v.view.w = w;
v.view.h = h;
mmdClamp(fig);
mmdApply(fig);
mmdTouched = fig;
}
function mmdActual(fig) {
const v = mmdViews.get(fig);
if (!v || !v.fit) return;
const r = v.frame.getBoundingClientRect();
mmdZoom(fig, v.view.w / Math.max(1, r.width), null, null);
}
let mmdFullFrom = 0; function quiet(p) { if (p && p.catch) p.catch(() => {}); } function mmdFull(fig) {
const open = docEl.querySelector(".mmd[data-full]");
if (open) { mmdUnfill(open); return; }
mmdFullFrom = main.scrollTop;
fig.style.contentVisibility = "visible";
fig.dataset.full = "1";
mmdRefit();
if (document.documentElement.requestFullscreen && !document.fullscreenElement) quiet(document.documentElement.requestFullscreen());
}
function mmdUnfill(fig) {
delete fig.dataset.full;
main.scrollTo({ top: mmdFullFrom, behavior: "instant" });
mmdRefit();
if (document.fullscreenElement) quiet(document.exitFullscreen());
}
function mmdRefit() {
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const fig of docEl.querySelectorAll('.mmd[data-state="done"]')) mmdViewport(fig);
}));
}
document.addEventListener("fullscreenchange", () => {
const open = docEl.querySelector(".mmd[data-full]");
if (open && !document.fullscreenElement) mmdUnfill(open);
else mmdRefit();
});
function mmdTools(fig) {
if (fig.querySelector(".mmd-tools")) return;
const bar = document.createElement("div");
bar.className = "mmd-tools";
bar.innerHTML =
`<button type="button" data-mmd="out" title="Zoom out" aria-label="Zoom out">−</button>` +
`<button type="button" data-mmd="in" title="Zoom in (double-click, or ⌘/ctrl + scroll)" aria-label="Zoom in">+</button>` +
`<button type="button" data-mmd="zoom" title="Show it at full size">100%</button>` +
`<button type="button" data-mmd="full" title="Fill the screen f" aria-label="Fill the screen">⛶</button>`;
fig.appendChild(bar);
mmdApply(fig);
}
function mmdKeyed() {
const hovered = docEl.querySelector('.mmd[data-state="done"]:hover');
if (hovered && mmdViews.has(hovered)) return hovered;
const middle = innerHeight / 2;
let best = null, nearest = Infinity;
for (const fig of docEl.querySelectorAll('.mmd[data-state="done"]')) {
if (!mmdViews.has(fig)) continue;
const r = fig.getBoundingClientRect();
if (r.bottom < 0 || r.top > innerHeight) continue;
const d = Math.abs((r.top + r.bottom) / 2 - middle);
if (d < nearest) { nearest = d; best = fig; }
}
if (best) return best;
return mmdTouched && mmdTouched.isConnected && mmdViews.has(mmdTouched) ? mmdTouched : null;
}
docEl.addEventListener("click", e => {
const b = e.target.closest("[data-mmd]");
if (!b) return;
const fig = b.closest(".mmd");
if (!fig) return;
mmdTouched = fig;
const what = b.dataset.mmd;
if (what === "in") mmdZoom(fig, 1.6, null, null);
else if (what === "out") mmdZoom(fig, 1 / 1.6, null, null);
else if (what === "full") mmdFull(fig);
else if (what === "zoom") fig.dataset.zoom === "in" ? mmdFit(fig) : mmdActual(fig);
});
docEl.addEventListener("wheel", e => {
if (!(e.ctrlKey || e.metaKey)) return;
const fig = e.target.closest('.mmd[data-state="done"]');
if (!fig || !mmdViews.has(fig)) return;
e.preventDefault();
mmdZoom(fig, Math.exp(-e.deltaY * 0.0025), e.clientX, e.clientY);
}, { passive: false });
docEl.addEventListener("pointerdown", e => {
if (e.button !== 0) return;
const fig = e.target.closest('.mmd[data-state="done"]');
if (!fig || fig.dataset.zoom !== "in" || !mmdViews.has(fig) || e.target.closest("[data-mmd]")) return;
const v = mmdViews.get(fig);
let last = { x: e.clientX, y: e.clientY };
fig.dataset.grab = "1";
mmdTouched = fig;
const move = ev => {
const s = mmdScale(fig);
v.view.x -= (ev.clientX - last.x) / s;
v.view.y -= (ev.clientY - last.y) / s;
last = { x: ev.clientX, y: ev.clientY };
mmdClamp(fig);
mmdApply(fig);
};
const up = () => {
delete fig.dataset.grab;
removeEventListener("pointermove", move);
removeEventListener("pointerup", up);
removeEventListener("pointercancel", up);
};
addEventListener("pointermove", move);
addEventListener("pointerup", up);
addEventListener("pointercancel", up);
e.preventDefault();
});
docEl.addEventListener("dblclick", e => {
const fig = e.target.closest('.mmd[data-state="done"]');
if (!fig || !mmdViews.has(fig)) return;
e.preventDefault();
mmdZoom(fig, 2, e.clientX, e.clientY);
});
let mmdResize = null;
addEventListener("resize", () => {
clearTimeout(mmdResize);
mmdResize = setTimeout(() => {
for (const fig of docEl.querySelectorAll('.mmd[data-state="done"]')) mmdViewport(fig);
}, 150);
});
function mmdFail(fig, e) {
fig.dataset.state = "error";
fig.style.removeProperty("--mmd-reserve");
const msg = document.createElement("p");
msg.className = "mmd-err";
msg.textContent = `This diagram could not be drawn — ${e && e.message ? e.message : e}`;
const pre = document.createElement("pre");
pre.className = "mmd-src";
pre.textContent = fig.dataset.src;
const box = document.createElement("div");
box.className = "mmd-fail";
box.append(msg, pre);
const frame = fig.querySelector(".mmd-frame");
frame.textContent = "";
frame.append(box);
}
docEl.addEventListener("click", e => {
const btn = e.target.closest("[data-mmd-render]");
if (!btn) return;
const fig = btn.closest(".mmd");
if (!fig) return;
fig.dataset.state = "queued";
mmdNote(fig, document.createTextNode("Drawing…"));
fig.classList.add("mmd-slow");
mmdQueue.push(fig);
mmdDrain();
});
async function renderHistory() {
const old = $("#history"); if (old) old.remove();
if (!state.doc || !state.doc.source_path) return;
let h; try { h = await (await fetch(`/api/docs/${state.doc.id}/history`)).json(); } catch { return; }
if (!h || h.length < 2) return;
const box = document.createElement("div"); box.id = "history";
box.innerHTML = `<h4>Versions · ${h.length}</h4>` + h.map(d => `<a href="/d/${d.id}" data-id="${d.id}" class="${d.id === state.doc.id ? "cur" : ""}" title="${esc(d.workflow_title)}">${fmt(d.received_at)}${d.pinned ? " ●" : ""}</a>`).join("");
metaEl.appendChild(box);
}
const findBar = $("#find"), findIn = $("#find-input"), findCount = $("#find-count");
let findMarks = [], findIdx = -1;
const FIND_SKIP = "script,style,.copy,svg,.mmd-note";
function clearFind() {
for (const m of findMarks) { const p = m.parentNode; if (!p) continue; p.replaceChild(document.createTextNode(m.textContent), m); p.normalize(); }
findMarks = []; findIdx = -1; findCount.textContent = "";
}
function runFind(q) {
clearFind();
if (!q) return;
const needle = q.toLowerCase();
const walker = document.createTreeWalker(docEl, NodeFilter.SHOW_TEXT, { acceptNode: n => n.parentNode.closest(FIND_SKIP) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT });
const texts = []; let n; while ((n = walker.nextNode())) texts.push(n);
for (const t of texts) {
let text = t.nodeValue, lower = text.toLowerCase(), pos = lower.indexOf(needle);
if (pos < 0) continue;
const frag = document.createDocumentFragment(); let last = 0;
while (pos >= 0 && findMarks.length < 2000) {
frag.appendChild(document.createTextNode(text.slice(last, pos)));
const m = document.createElement("mark"); m.className = "find"; m.textContent = text.slice(pos, pos + q.length);
frag.appendChild(m); findMarks.push(m);
last = pos + q.length; pos = lower.indexOf(needle, last);
}
frag.appendChild(document.createTextNode(text.slice(last)));
t.parentNode.replaceChild(frag, t);
}
if (findMarks.length) gotoFind(0); else findCount.textContent = "No matches";
}
function gotoFind(i) {
if (!findMarks.length) return;
if (findIdx >= 0) findMarks[findIdx].classList.remove("cur");
findIdx = (i + findMarks.length) % findMarks.length;
const m = findMarks[findIdx]; m.classList.add("cur");
m.scrollIntoView({ block: "center", behavior: "instant" });
findCount.textContent = `${findIdx + 1} / ${findMarks.length}`;
}
function openFind() { findBar.hidden = false; findIn.focus(); findIn.select(); }
function closeFind() { findBar.hidden = true; clearFind(); findIn.value = ""; }
let findTimer = null;
findIn.addEventListener("input", () => { clearTimeout(findTimer); findTimer = setTimeout(() => runFind(findIn.value), 80); });
findIn.addEventListener("keydown", e => {
if (e.key === "Enter") { e.preventDefault(); gotoFind(findIdx + (e.shiftKey ? -1 : 1)); }
if (e.key === "Escape") { e.preventDefault(); closeFind(); }
});
$("#find-next").addEventListener("click", () => gotoFind(findIdx + 1));
$("#find-prev").addEventListener("click", () => gotoFind(findIdx - 1));
$("#find-close").addEventListener("click", closeFind);
const beacon = () => { if (document.hasFocus() && document.visibilityState === "visible") fetch("/api/focus", { method: "POST", keepalive: true }).catch(() => {}); };
window.addEventListener("focus", beacon); document.addEventListener("visibilitychange", beacon); setInterval(beacon, 3000); beacon();
function markCur(links, at) {
let cur = null;
links.forEach((a, i) => {
const on = i === at;
a.classList.toggle("cur", on);
if (on) { a.setAttribute("aria-current", "location"); cur = a; } else a.removeAttribute("aria-current");
});
if (cur) keepCurInView(false);
}
function keepCurInView(now) {
const cur = tocEl.querySelector("a.cur");
if (!cur || (!now && tocEl.matches(":hover"))) return;
const top = cur.offsetTop, bottom = top + cur.offsetHeight;
const seen = tocEl.scrollTop, h = tocEl.clientHeight;
if (top >= seen + 24 && bottom <= seen + h - 24) return;
tocEl.scrollTo({ top: Math.max(0, top - h / 2), behavior: now ? "instant" : "smooth" });
}
function follow(track) {
let queued = false;
const tick = () => { queued = false; track(); };
const poke = () => { if (!queued) { queued = true; requestAnimationFrame(tick); } };
main.addEventListener("scroll", poke, { passive: true });
addEventListener("resize", poke);
poke();
return { disconnect() { main.removeEventListener("scroll", poke); removeEventListener("resize", poke); } };
}
let spy = null;
function buildToc() {
if (spy) { spy.disconnect(); spy = null; }
tocEl.scrollTop = 0; const reading = state.view === "doc" || state.view === "browse";
const hs = reading ? [...docEl.querySelectorAll(".prose h1, .prose h2, .prose h3, .prose h4")] : [];
if (hs.length < 3) { tocEl.innerHTML = ""; buildOutline(); }
else {
tocEl.innerHTML = `<ul>` + hs.map((h, i) => {
const id = h.querySelector("a.anchor[id]")?.id || h.id || (h.id = `h-${i}`);
return `<li class="d${h.tagName[1]}"><a href="#${esc(id)}" data-i="${i}">${esc(h.textContent.replace(/^#\s*/, ""))}</a></li>`;
}).join("") + `</ul>`;
const links = [...tocEl.querySelectorAll("a")];
spy = follow(() => {
let cur = -1;
hs.forEach((h, i) => { if (h.getBoundingClientRect().top < 120) cur = i; });
if (main.scrollTop + main.clientHeight >= main.scrollHeight - 2) cur = hs.length - 1;
markCur(links, cur);
});
}
rail.classList.toggle("empty", state.view === "inbox" || state.view === "connect");
}
tocEl.addEventListener("click", e => {
const a = e.target.closest('a[href^="#"]');
if (!a || e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
const h = headingFor(a.getAttribute("href").slice(1));
if (!h) return;
e.preventDefault();
history.replaceState(history.state, "", location.pathname + a.getAttribute("href"));
jumpTo(h);
if (root.dataset.sheet === "rail") closeSheet();
});
function jumpTo(el) {
const put = () => el.scrollIntoView({ block: "start", behavior: "instant" });
put();
requestAnimationFrame(() => requestAnimationFrame(put));
flash(el);
}
function headingFor(id) {
const el = document.getElementById(decodeURIComponent(id));
return el && (el.closest("h1, h2, h3, h4, h5, h6") || el);
}
function jumpToHash() {
if (lineHash()) { applyLineHash(true); return; }
const h = location.hash.length > 1 && headingFor(location.hash.slice(1));
if (h) jumpTo(h);
}
docEl.addEventListener("click", e => {
const a = e.target.closest("a.anchor[href^='#']");
if (!a || e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
e.preventDefault();
history.replaceState(history.state, "", location.pathname + a.getAttribute("href"));
navigator.clipboard?.writeText(location.href);
a.dataset.said = "Copied";
clearTimeout(a._said);
a._said = setTimeout(() => delete a.dataset.said, 1200);
});
docEl.addEventListener("focusin", e => {
const block = e.target.closest(".prose > *");
if (!block) return;
block.style.contentVisibility = "visible";
const put = () => e.target.scrollIntoView({ block: "nearest", behavior: "instant" });
put();
requestAnimationFrame(() => requestAnimationFrame(put));
});
for (const pane of [$("#side"), rail]) pane.addEventListener("wheel", e => {
if (e.ctrlKey || e.metaKey || !e.deltaY) return;
const box = e.target.closest("#trees, #toc, #meta");
if (box && (e.deltaY < 0 ? box.scrollTop > 0 : box.scrollTop + box.clientHeight < box.scrollHeight - 1)) return;
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * main.clientHeight : e.deltaY;
main.scrollBy({ top: dy, behavior: "instant" });
e.preventDefault();
}, { passive: false });
let outlineSpy = null;
async function buildOutline() {
if (outlineSpy) { outlineSpy.disconnect(); outlineSpy = null; }
const url = state.view === "doc" && state.doc && state.doc.kind === "code"
? `/api/docs/${state.doc.id}/outline`
: (browsing() && state.browsePath ? `/api/browse/${state.browseRoot.id}/outline?path=${encodeURIComponent(state.browsePath)}` : null);
if (!url) return;
const token = ++outlineToken;
let items = [];
try { items = await (await fetch(url)).json(); } catch { return; }
if (token !== outlineToken || !Array.isArray(items) || !items.length) return;
const lines = [...docEl.querySelectorAll("pre.code .ln")];
if (!lines.length) return;
tocEl.innerHTML = `<ul class="outline">` + items.map((o, i) =>
`<li class="d${o.depth + 1}"><a href="#" data-line="${o.line}" data-i="${i}" title="${esc(o.kind)} · line ${o.line}"><span class="ok ok-${o.kind}"></span>${esc(o.name)}</a></li>`
).join("") + `</ul>`;
const links = [...tocEl.querySelectorAll("a")];
links.forEach(a => a.addEventListener("click", e => {
e.preventDefault();
const el = lines[+a.dataset.line - 1];
if (!el) return;
el.scrollIntoView({ block: "start", behavior: "instant" });
flash(el);
}));
outlineSpy = follow(() => {
let cur = -1;
items.forEach((o, i) => { const el = lines[o.line - 1]; if (el && el.getBoundingClientRect().top < 140) cur = i; });
markCur(links, cur);
});
}
let outlineToken = 0;
function flash(el) {
el.classList.add("flash");
setTimeout(() => el.classList.remove("flash"), 700);
}
const codePre = () => docEl.querySelector("article.kind-code pre.code, article.kind-text pre.code");
function lineHash() {
const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(location.hash);
if (!m) return null;
const a = +m[1], b = m[2] ? +m[2] : a;
return a > 0 ? { a: Math.min(a, b), b: Math.max(a, b) } : null;
}
const frag = (a, b) => (a === b ? `#L${a}` : `#L${a}-L${b}`);
function applyLineHash(scroll) {
for (const el of docEl.querySelectorAll("pre.code .ln.at")) el.classList.remove("at");
const r = lineHash(), pre = codePre();
if (!r || !pre) return;
const lines = pre.querySelectorAll(".ln");
let first = null;
for (let n = r.a; n <= r.b; n++) {
const el = lines[n - 1];
if (!el) break;
el.classList.add("at");
first = first || el;
}
if (first && scroll) first.scrollIntoView({ block: "center", behavior: "instant" });
}
function setLines(a, b, scroll) {
history.replaceState(history.state, "", location.pathname + frag(a, b));
applyLineHash(scroll);
}
function gotoLine(n) {
const pre = codePre();
if (!pre) { toast("No line numbers here", "Line links work on code and text documents."); return; }
if (n > pre.querySelectorAll(".ln").length) { toast(`No line ${n}`, "The document is shorter than that."); return; }
setLines(n, n, true);
}
function gutterWidth(ln) {
const s = getComputedStyle(ln, "::before");
if (!s || s.display === "none") return 0;
const w = parseFloat(s.paddingLeft) + parseFloat(s.width) + parseFloat(s.marginRight);
return isFinite(w) ? w : 0;
}
function wireLines(pre) {
pre.addEventListener("click", e => {
const ln = e.target.closest(".ln");
if (!ln || codePre() !== pre) return;
const box = ln.getClientRects()[0];
if (!box || e.clientX - box.left > gutterWidth(ln)) return; e.preventDefault();
const n = [...pre.querySelectorAll(".ln")].indexOf(ln) + 1;
const prev = e.shiftKey && lineHash();
setLines(prev ? Math.min(prev.a, n) : n, prev ? Math.max(prev.a, n) : n, false);
navigator.clipboard?.writeText(location.href);
toast("Link copied", location.pathname + location.hash);
});
}
window.addEventListener("hashchange", () => applyLineHash(true));
function renderMeta(comparing) {
if (state.view === "browse") { renderBrowseMeta(); return; }
const d = state.doc;
if (!d) { metaEl.innerHTML = ""; return; }
const rows = [
["Project", d.project], ["Workflow", d.workflow_title], d.branch ? ["Branch", d.branch] : null,
["Received", fmt(d.received_at)], ["Size", d.size > 1024 * 1024 ? (d.size / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(d.size / 1024)) + " KB"],
d.lang ? ["Lang", d.lang] : null,
].filter(Boolean);
metaEl.innerHTML = rows.map(([k, v]) => `<div class="row"><b>${k}</b><span title="${esc(v)}">${esc(v)}</span></div>`).join("") +
`<div class="actions">` +
(state.previous ? (comparing ? `<button data-act="back">← Back to document</button>` : `<button data-act="compare">Compare with previous<kbd>c</kbd></button>`) : "") +
`<button data-act="pin">${d.pinned ? "Unpin" : "Pin"}<kbd>p</kbd></button>` +
((d.kind === "diff" || comparing) ? `<button data-act="split">${state.split ? "Inline view" : "Split view"}<kbd>s</kbd></button>` : "") +
previewButton() +
`<button data-act="delete">Delete<kbd>Del</kbd></button>` +
`<a href="/api/docs/${d.id}/raw" target="_blank" rel="noopener">Open source<kbd>o</kbd></a>` +
(d.source_path ? `<button data-act="copypath" title="${esc(d.source_path)}">Copy path</button>` : "") +
(state.folder ? `<button data-act="terminal" title="${esc(state.folder)}">Open terminal here</button>` : "") +
`</div>`;
}
const rawUrl = (rootId, path) => `/api/browse/${rootId}/raw/${path.split("/").map(encodeURIComponent).join("/")}`;
function previewButton() {
if (!state.preview) return "";
const label = state.previewOn ? "Source" : (state.preview === "pdf" ? "Open in viewer" : "Preview page");
return `<button data-act="preview">${label}<kbd>v</kbd></button>`;
}
function renderBrowseMeta() {
const r = state.browseRoot;
if (!r) { metaEl.innerHTML = ""; return; }
const p = state.browsePath;
const rows = [["Folder", r.name], p ? ["Path", p] : null].filter(Boolean);
metaEl.innerHTML = rows.map(([k, v]) => `<div class="row"><b>${k}</b><span title="${esc(v)}">${esc(v)}</span></div>`).join("") +
`<div class="actions">` +
previewButton() +
(p ? `<a href="${rawUrl(r.id, p)}" target="_blank" rel="noopener">Open source<kbd>o</kbd></a>` : "") +
`<button data-act="copybrowse">Copy path</button>` +
`<button data-act="terminal" title="${esc(r.path + (p ? "/" + p : ""))}">Open terminal here</button>` +
`<a href="/b/${r.id}" data-browse="${r.id}" data-path="">Folder contents</a>` +
`<button data-act="closebrowse">Close folder</button>` +
`</div>`;
}
metaEl.addEventListener("click", async e => {
const b = e.target.closest("[data-act]");
if (!b) return;
if (b.dataset.act === "compare") showCompare();
if (b.dataset.act === "back") { state.cache.delete(state.doc.id); showDoc(state.doc.id, false); }
if (b.dataset.act === "copypath") { navigator.clipboard?.writeText(state.doc.source_path); toast("Copied", state.doc.source_path); }
if (b.dataset.act === "pin") togglePin();
if (b.dataset.act === "split") toggleSplit();
if (b.dataset.act === "preview") togglePreview();
if (b.dataset.act === "delete") deleteCurrent();
if (b.dataset.act === "copybrowse") {
const full = state.browseRoot.path + (state.browsePath ? "/" + state.browsePath : "");
navigator.clipboard?.writeText(full); toast("Copied", full);
}
if (b.dataset.act === "terminal") openTerminal();
if (b.dataset.act === "closebrowse") {
const id = state.browseRoot.id;
try { await fetch(`/api/browse/${id}/close`, { method: "POST" }); } catch {}
state.browse = state.browse.filter(r => r.id !== id);
showInbox(true);
}
});
async function openTerminal() {
const body = state.view === "browse"
? { root: state.browseRoot.id, path: state.browsePath || "" }
: { doc: state.doc.id };
try {
const r = await fetch("/api/terminal", {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
});
const j = await r.json().catch(() => ({}));
if (r.ok) toast("Terminal", j.dir || "opened");
else toast("No terminal", j.error || `${r.status}`);
} catch (e) { toast("No terminal", String(e)); }
}
async function togglePin() {
if (!state.doc) return;
const pinned = !state.doc.pinned;
try {
await fetch(`/api/docs/${state.doc.id}/pin`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ pinned }) });
state.doc.pinned = pinned; state.cache.delete(state.doc.id);
await refreshTree(state.doc.project_id);
renderMeta(false);
toast(pinned ? "Pinned" : "Unpinned", pinned ? "Kept by prune" : "Prune may remove it");
} catch (e) { toast("Could not pin", String(e)); }
}
function enhanceCode() {
for (const pre of docEl.querySelectorAll("pre.code")) {
if (pre.querySelector(".copy")) continue;
wireLines(pre);
const b = document.createElement("button");
b.className = "copy"; b.textContent = "Copy"; b.title = "Copy code";
b.addEventListener("click", () => {
const text = [...pre.querySelectorAll(".ln")].map(l => l.textContent).join("\n") || pre.textContent;
navigator.clipboard?.writeText(text);
b.textContent = "Copied"; setTimeout(() => (b.textContent = "Copy"), 1200);
});
pre.appendChild(b);
}
}
document.addEventListener("click", e => {
const a = e.target.closest("a[data-id], a[data-browse], [data-nav]");
if (!a || e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
e.preventDefault();
if (a.dataset.nav === "inbox") showInbox(true);
else if (a.dataset.nav === "connect") showConnect(true);
else if (a.dataset.browse !== undefined) showBrowse(a.dataset.browse, a.dataset.path, true);
else showDoc(a.dataset.id, true);
if (root.dataset.sheet === "side") closeSheet();
});
document.addEventListener("mouseover", e => {
const a = e.target.closest("a[data-id]");
if (a && !state.cache.has(a.dataset.id)) fetchDoc(a.dataset.id).catch(() => {});
});
window.addEventListener("popstate", () => {
const d = location.pathname.match(/^\/d\/([a-z0-9]+)$/);
if (d && state.view === "doc" && state.doc && state.doc.id === d[1] && !state.comparing) return jumpToHash();
if (d) return showDoc(d[1], false, true);
const b = location.pathname.match(/^\/b\/([a-z0-9]+)(?:\/(.*))?$/);
if (b) return showBrowse(b[1], decodeURIComponent(b[2] || ""), false, true);
if (location.pathname === "/connect") return showConnect(false);
showInbox(false);
});
const inWindow = (() => {
try {
if (new URLSearchParams(location.search).has("window")) {
sessionStorage.setItem("snyvi.window", "1");
history.replaceState(history.state, "", location.pathname + location.hash);
}
return sessionStorage.getItem("snyvi.window") === "1";
} catch { return false; }
})();
let stream = null, retry = null;
addEventListener("pagehide", () => {
clearTimeout(retry);
if (stream) { stream.close(); stream = null; }
});
addEventListener("pageshow", e => { if (e.persisted && !stream) { connect(); catchUp(); } });
async function catchUp() {
try {
const q = await (await fetch(`/api/queue?limit=${QUEUE_HELD}`)).json();
if (Array.isArray(q)) {
state.queue = q;
state.waiting = q.length < QUEUE_HELD ? q.length : Math.max(state.waiting, q.length);
}
} catch {}
await refreshTree();
if (state.view === "inbox") showInbox(false);
}
const mark = $(".brand-mark");
function linked(on) {
if (on) { delete root.dataset.link; mark.title = ""; }
else { root.dataset.link = "off"; mark.title = "Not connected to snyvi; trying again"; }
}
function connect() {
const es = new EventSource("/api/events" + (inWindow ? "?window=1" : ""));
stream = es;
es.onopen = async () => {
if (root.dataset.link !== "off") return;
linked(true);
let h = null;
try { h = await (await fetch("/api/health")).json(); } catch {}
if (h && h.v && boot.v && h.v !== boot.v) { location.reload(); return; }
if (h) setOnline(h.agents);
catchUp();
};
es.addEventListener("agents", ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
setOnline(j.online);
});
es.addEventListener("doc", async ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
const d = j.doc;
if (j.existing) {
if (state.doc && state.doc.id === d.id) await refreshDoc(d.id);
else state.cache.delete(d.id);
await refreshTree(d.project_id);
return;
}
const opens = state.view === "inbox" && !state.waiting;
if (!queueIds.has(d.id) && state.queue.length === state.waiting) state.queue.push(d);
state.waiting = j.waiting != null ? j.waiting : state.waiting + 1;
if (!opens) wash([d.id]);
holdQueue(); state.cache.delete(d.id);
renderTree(); markActive();
await refreshTree(d.project_id);
if (opens) { await showDoc(d.id, true); toast(d.title, `${d.project} · just now`); }
else if (state.view === "inbox") showInbox(false);
});
es.addEventListener("read", ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
if (Array.isArray(j.ids)) dropFromQueue(j.ids, j.waiting);
});
es.addEventListener("rendered", ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
refreshDoc(j.id);
});
es.addEventListener("changed", ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
if (j.dir) {
reloadTree(browseEl.querySelector(`.b-tree[data-root="${j.root}"][data-path="${CSS.escape(j.path)}"]`));
if (browsing() && state.browseRoot.id === j.root && !state.browsePath && j.path === "") showBrowse(j.root, "", false);
return;
}
if (browsing() && state.browseRoot.id === j.root && state.browsePath === j.path) refreshBrowsed();
});
es.addEventListener("deleted", async ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
state.cache.delete(j.id);
depart([j.id]);
state.queue = state.queue.filter(d => d.id !== j.id);
if (j.waiting != null) state.waiting = j.waiting;
await refreshTree();
if (state.doc && state.doc.id === j.id) showInbox(true);
});
es.addEventListener("restored", async ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
if (j.waiting != null) state.waiting = j.waiting;
if (j.id != null) wash([j.id]);
await refreshTree(j.doc && j.doc.project_id);
holdQueue();
if (state.view === "inbox") showInbox(false);
});
es.addEventListener("reset", () => afterReset());
es.addEventListener("browse", ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
state.browse = j.roots || [];
renderBrowse();
});
es.addEventListener("pinned", async () => { await refreshTree(); });
es.addEventListener("renamed", ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
if (j.project != null) applyRename("project", j.project);
else if (j.workflow != null) applyRename("workflow", j.workflow);
});
es.onerror = () => {
es.close();
if (stream === es) stream = null;
linked(false);
retry = setTimeout(connect, 2000);
};
}
function toast(title, sub, onClick, action) {
const el = document.createElement("div");
el.className = "toast";
el.innerHTML = `<span class="dot"></span><span><div class="t">${esc(title)}</div>${sub ? `<div class="s">${esc(sub)}</div>` : ""}</span>`;
if (action) {
const b = document.createElement("button");
b.type = "button"; b.className = "act"; b.textContent = action.label;
b.addEventListener("click", ev => { ev.stopPropagation(); el.remove(); action.run(); });
el.appendChild(b);
} else {
el.addEventListener("click", () => { el.remove(); onClick && onClick(); });
}
$("#toasts").appendChild(el);
const life = action ? UNDO_MS : onClick ? 8000 : 3500;
setTimeout(() => { el.style.transition = "opacity 160ms"; el.style.opacity = "0"; setTimeout(() => el.remove(), 180); }, life);
return el;
}
const pal = $("#palette"), palIn = $("#palette-input"), palList = $("#palette-list");
let palSel = 0, palItems = [], palTimer = null;
function openPalette() {
palIn.value = "";
palIn.placeholder = browsing() ? `Find a file in ${state.browseRoot.name}… (:120 for a line)`
: codePre() ? "Search documents… (:120 for a line)" : "Search documents… (p:project kind:md|code|diff)";
openDialog(pal, palIn); palSearch("");
}
const browsing = () => state.view === "browse" && state.browseRoot;
function closePalette() { closeDialog(pal); }
async function palSearch(q) {
const g = /^\s*[:lL]\s*(\d+)\s*$/.exec(q);
if (g && codePre()) {
palItems = [{ line: +g[1] }]; palSel = 0;
palList.innerHTML = `<li class="sel" data-i="0"><span class="t">Go to line ${+g[1]}</span><span class="s">${esc(document.title)}</span></li>`;
return;
}
if (browsing()) {
let hits = [];
try { hits = await (await fetch(`/api/browse/${state.browseRoot.id}/find?q=${encodeURIComponent(q)}`)).json(); } catch {}
palItems = hits.map(p => ({ file: p })); palSel = 0;
palList.innerHTML = hits.map((p, i) => `<li class="${i === 0 ? "sel" : ""}" data-i="${i}"><span class="t">${esc(p.split("/").pop())}</span><span class="s">${esc(p)}</span></li>`).join("");
return;
}
let items;
if (!q.trim()) items = (await (await fetch("/api/inbox?limit=12")).json()).map(d => ({ ...d, snippet: "" }));
else items = await (await fetch(`/api/search?q=${encodeURIComponent(q)}`)).json();
palItems = items; palSel = 0;
palList.innerHTML = items.map((d, i) => `<li class="${i === 0 ? "sel" : ""}" data-i="${i}"><span class="t">${esc(d.title)}</span><span class="s">${esc(d.project)} · ${esc(d.workflow_title)} · ${rel(d.received_at)}</span>${d.snippet ? `<span class="snip">${d.snippet}</span>` : ""}</li>`).join("");
}
palIn.addEventListener("input", () => { clearTimeout(palTimer); palTimer = setTimeout(() => palSearch(palIn.value), 60); });
palIn.addEventListener("keydown", e => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
palSel = (palSel + (e.key === "ArrowDown" ? 1 : -1) + palItems.length) % Math.max(1, palItems.length);
palList.querySelectorAll("li").forEach((li, i) => li.classList.toggle("sel", i === palSel));
palList.querySelector("li.sel")?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter" && palItems[palSel]) { closePalette(); openPalItem(palItems[palSel]); }
});
const openPalItem = it => it.line ? gotoLine(it.line) : it.file ? showBrowse(state.browseRoot.id, it.file, true) : showDoc(it.id, true);
palList.addEventListener("click", e => { const li = e.target.closest("li"); if (li) { closePalette(); openPalItem(palItems[+li.dataset.i]); } });
pal.addEventListener("click", e => { if (e.target === pal) closePalette(); });
$("#btn-search").addEventListener("click", openPalette);
$("#btn-theme").addEventListener("click", () => {
const next = { "": "light", light: "dark", dark: "" }[root.dataset.theme || ""];
next ? (root.dataset.theme = next) : delete root.dataset.theme;
store.set("snyvi.theme", next);
toast("Theme", next || "system");
mmdRetheme();
});
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (!root.dataset.theme) mmdRetheme();
});
function toggleWide() {
const on = root.dataset.wide !== "1";
on ? (root.dataset.wide = "1") : delete root.dataset.wide;
store.set("snyvi.wide", on ? "1" : "0");
$("#btn-wide").classList.toggle("on", on);
}
$("#btn-wide").addEventListener("click", toggleWide);
$("#btn-wide").classList.toggle("on", root.dataset.wide === "1");
function toggleWrap() {
const on = root.dataset.wrap !== "1";
on ? (root.dataset.wrap = "1") : delete root.dataset.wrap;
store.set("snyvi.wrap", on ? "1" : "0");
$("#btn-wrap").classList.toggle("on", on);
if (!docEl.querySelector("pre.code")) toast("Line wrap", on ? "on, for code" : "off");
}
$("#btn-wrap").addEventListener("click", toggleWrap);
$("#btn-wrap").classList.toggle("on", root.dataset.wrap === "1");
$("#btn-font").addEventListener("click", () => {
const next = root.dataset.font === "serif" ? "" : "serif";
next ? (root.dataset.font = next) : delete root.dataset.font;
store.set("snyvi.font", next);
});
const appEl = $("#app"), help = $("#help"), aboutDlg = $("#about"), resetDlg = $("#reset");
const dialogs = [pal, help, aboutDlg, resetDlg];
const anyDialogOpen = () => dialogs.some(d => !d.hidden);
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), summary, [tabindex]:not([tabindex="-1"])';
let dialogOpener = null;
function openDialog(el, focusEl) {
if (!el.hidden) { (focusEl || el).focus(); return; }
if (!anyDialogOpen()) dialogOpener = document.activeElement;
el.hidden = false;
appEl.inert = true;
(focusEl || el.querySelector(FOCUSABLE) || el.firstElementChild).focus();
}
function closeDialog(el) {
if (el.hidden) return;
el.hidden = true;
if (anyDialogOpen()) return;
appEl.inert = false;
const back = dialogOpener; dialogOpener = null;
if (back && back.isConnected && back !== document.body) back.focus();
}
document.addEventListener("keydown", e => {
if (e.key !== "Tab") return;
const box = dialogs.find(d => !d.hidden)?.firstElementChild;
if (!box) return;
const f = [...box.querySelectorAll(FOCUSABLE)].filter(x => x.offsetParent !== null);
if (!f.length) { e.preventDefault(); return; }
const at = document.activeElement, first = f[0], last = f[f.length - 1];
if (e.shiftKey ? (at === first || !box.contains(at)) : (at === last || !box.contains(at))) {
e.preventDefault(); (e.shiftKey ? last : first).focus();
}
}, true);
help.addEventListener("click", e => { if (e.target === help) closeDialog(help); });
$("#help-close").addEventListener("click", () => closeDialog(help));
$("#btn-help").addEventListener("click", () => openDialog(help, help.firstElementChild));
const aboutFacts = $("#about-facts");
async function openAbout() {
closeDialog(help);
aboutFacts.replaceChildren();
openDialog(aboutDlg, aboutDlg.firstElementChild);
let a;
try { a = await (await fetch("/api/about")).json(); } catch { $("#about-say").textContent = "The daemon did not answer."; return; }
$("#about-say").textContent = `${a.description}.`;
const fact = (k, v, cls) => {
if (v == null || v === "") return;
const dt = document.createElement("dt"); dt.textContent = k;
const dd = document.createElement("dd"); if (cls) dd.className = cls;
if (v instanceof Node) dd.append(v); else dd.textContent = v;
aboutFacts.append(dt, dd);
};
const ver = document.createDocumentFragment();
ver.append(a.version);
const build = [a.commit, a.target].filter(Boolean).join(", ");
if (build) { const m = document.createElement("span"); m.className = "muted"; m.textContent = ` (${build})`; ver.append(m); }
fact("Version", ver);
fact("Binary", a.binary, "path");
fact("Documents", a.data_dir, "path");
fact("Settings", a.config_dir, "path");
fact("Agents", a.agents, "pre");
fact("License", a.license);
if (a.repository) {
const link = document.createElement("a"); link.href = a.repository; link.target = "_blank"; link.rel = "noopener";
link.textContent = a.repository.replace(/^https?:\/\//, "");
fact("Source", link);
}
}
$("#btn-about").addEventListener("click", openAbout);
$("#btn-connect").addEventListener("click", () => { closeDialog(help); showConnect(); });
$("#about-close").addEventListener("click", () => closeDialog(aboutDlg));
aboutDlg.addEventListener("click", e => { if (e.target === aboutDlg) closeDialog(aboutDlg); });
const resetSay = $("#reset-say"), resetN = $("#reset-n"), resetGo = $("#reset-go"), resetErr = $("#reset-err");
const resetPinRow = $("#reset-pinned-row"), resetPin = $("#reset-pinned");
let resetCensus = null;
function resetArm() {
resetGo.disabled = !resetCensus || resetN.value.trim() !== String(resetCensus.documents) || (resetCensus.pinned > 0 && !resetPin.checked);
}
async function openReset() {
closeDialog(help);
resetCensus = null; resetN.value = ""; resetErr.hidden = true; resetPin.checked = false; resetPinRow.hidden = true;
resetSay.textContent = "Reading what there is…";
resetArm();
openDialog(resetDlg, resetN);
try { resetCensus = await (await fetch("/api/reset")).json(); } catch { resetSay.textContent = "The daemon did not answer."; return; }
resetSay.textContent = `This removes ${plural(resetCensus.documents, "document")} in ${plural(resetCensus.projects, "project")}, the index, the token and this page's preferences. Agents stay connected: the next document they send lands in an empty library. Nothing can be undone.`;
if (resetCensus.pinned > 0) {
$("#reset-pinned-say").textContent = `Also the ${plural(resetCensus.pinned, "pinned document")} — a pin means keep`;
resetPinRow.hidden = false;
}
resetArm();
}
function afterReset() {
try { sessionStorage.setItem("snyvi.reset", "1"); } catch {}
try { Object.keys(localStorage).filter(k => k.startsWith("snyvi.")).forEach(k => localStorage.removeItem(k)); } catch {}
location.replace("/");
}
$("#btn-reset").addEventListener("click", openReset);
$("#reset-close").addEventListener("click", () => closeDialog(resetDlg));
$("#reset-cancel").addEventListener("click", () => closeDialog(resetDlg));
resetDlg.addEventListener("click", e => { if (e.target === resetDlg) closeDialog(resetDlg); });
resetN.addEventListener("input", resetArm);
resetPin.addEventListener("change", resetArm);
resetDlg.firstElementChild.addEventListener("submit", async e => {
e.preventDefault();
if (resetGo.disabled) return;
resetGo.disabled = true; resetGo.textContent = "Resetting…";
let r;
try {
r = await fetch("/api/reset", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ documents: resetCensus.documents, pinned: resetPin.checked }) });
} catch { resetGo.textContent = "Reset"; resetErr.textContent = "The daemon did not answer."; resetErr.hidden = false; return; }
if (r.ok) { afterReset(); return; }
resetGo.textContent = "Reset";
let j = {}; try { j = await r.json(); } catch {}
resetErr.textContent = j.error || `The daemon refused (${r.status}).`;
resetErr.hidden = false;
if (j.census) { resetCensus = j.census; resetN.value = ""; resetSay.textContent = resetSay.textContent.replace(/^This removes [^,]+,/, `This removes ${plural(j.census.documents, "document")} in ${plural(j.census.projects, "project")},`); }
resetArm();
});
const railNarrow = matchMedia("(max-width: 1100px)"), sideNarrow = matchMedia("(max-width: 760px)");
const sideEl = $("#side");
let sheetOpener = null;
function openSheet(which, opener) {
if (root.dataset.sheet === which) return;
sheetOpener = opener || document.activeElement;
root.dataset.sheet = which;
if (which === "rail") keepCurInView(true);
const first = which === "rail"
? tocEl.querySelector("a.cur") || tocEl.querySelector("a") || metaEl.querySelector("button, a")
: sideEl.querySelector("#trees a[aria-current], #trees a, #trees summary");
(first || (which === "rail" ? rail : sideEl)).focus({ preventScroll: true });
}
function closeSheet() {
if (!root.dataset.sheet) return false;
delete root.dataset.sheet;
const back = sheetOpener; sheetOpener = null;
if (back && back.isConnected && back !== document.body) back.focus({ preventScroll: true });
return true;
}
const toggleSheet = (which, opener) => root.dataset.sheet === which ? closeSheet() : openSheet(which, opener);
$("#scrim").addEventListener("click", closeSheet);
$("#btn-rail").addEventListener("click", e => toggleSheet("rail", e.currentTarget));
$("#btn-side").addEventListener("click", e => toggleSheet("side", e.currentTarget));
const sheetFits = () => root.dataset.sheet === "rail" ? railNarrow.matches : root.dataset.sheet === "side" ? sideNarrow.matches : true;
for (const mq of [railNarrow, sideNarrow]) mq.addEventListener("change", () => { if (!sheetFits()) closeSheet(); });
const PANES = [
{ el: sideEl, prop: "--side-w", key: "snyvi.side-w", min: 200, max: 440, dflt: 264, sign: 1 },
{ el: rail, prop: "--rail-w", key: "snyvi.rail-w", min: 180, max: 400, dflt: 232, sign: -1 },
];
for (const pane of PANES) {
const g = pane.el.querySelector(".gutter");
const width = () => parseFloat(getComputedStyle(root).getPropertyValue(pane.prop)) || pane.dflt;
const set = w => {
w = Math.round(Math.max(pane.min, Math.min(pane.max, w)));
root.style.setProperty(pane.prop, `${w}px`);
g.setAttribute("aria-valuenow", w);
return w;
};
g.setAttribute("aria-valuenow", width());
g.addEventListener("pointerdown", e => {
if (e.button !== 0) return;
const x0 = e.clientX, w0 = width();
let w = w0;
g.setPointerCapture(e.pointerId);
root.dataset.resizing = "1";
const move = ev => { w = set(w0 + pane.sign * (ev.clientX - x0)); };
const up = () => {
delete root.dataset.resizing;
g.removeEventListener("pointermove", move);
g.removeEventListener("pointerup", up);
g.removeEventListener("pointercancel", up);
store.set(pane.key, String(w));
};
g.addEventListener("pointermove", move);
g.addEventListener("pointerup", up);
g.addEventListener("pointercancel", up);
e.preventDefault();
});
g.addEventListener("dblclick", () => {
root.style.removeProperty(pane.prop);
store.del(pane.key);
g.setAttribute("aria-valuenow", pane.dflt);
});
g.addEventListener("keydown", e => {
const step = e.shiftKey ? 64 : 16;
const to = e.key === "ArrowRight" ? width() + pane.sign * step
: e.key === "ArrowLeft" ? width() - pane.sign * step
: e.key === "Home" ? pane.min : e.key === "End" ? pane.max : null;
if (to === null) return;
store.set(pane.key, String(set(to)));
e.preventDefault();
e.stopPropagation();
});
}
document.addEventListener("keydown", e => {
const inField = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); pal.hidden ? openPalette() : closePalette(); return; }
if (e.key === "Escape") {
const filled = docEl.querySelector(".mmd[data-full]");
if (filled) mmdUnfill(filled);
closePalette(); closeDialog(help); closeDialog(aboutDlg); closeDialog(resetDlg); closeSheet(); if (!findBar.hidden) closeFind();
return;
}
if (e.altKey && !e.metaKey && !e.ctrlKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
e.preventDefault();
if (e.key === "ArrowLeft") history.back(); else history.forward();
return;
}
if ((e.metaKey || e.ctrlKey) && !e.altKey && (e.key === "z" || e.key === "Z") && undoing) {
e.preventDefault();
undoing();
return;
}
if (inField || e.metaKey || e.ctrlKey || e.altKey) return;
if (browsing() && (e.key === "j" || e.key === "k")) {
const links = [...browseEl.querySelectorAll(".b-file a")];
const at = links.findIndex(a => a.dataset.path === state.browsePath);
const next = links[at + (e.key === "j" ? 1 : -1)] || (at < 0 ? links[0] : null);
if (next) showBrowse(next.dataset.browse, next.dataset.path, true);
e.preventDefault();
return;
}
const ids = order(), i = state.doc ? ids.indexOf(state.doc.id) : -1;
const sib = siblings(), si = state.doc ? sib.indexOf(state.doc.id) : -1;
switch (e.key) {
case "j": if (ids[i + 1]) showDoc(ids[i + 1], true); else if (i < 0 && ids[0]) showDoc(ids[0], true); break;
case "k": if (i > 0) showDoc(ids[i - 1], true); break;
case "[": if (sib[si + 1]) showDoc(sib[si + 1], true); break; case "]": if (si > 0) showDoc(sib[si - 1], true); break;
case "c": showCompare(); break;
case "p": togglePin(); break;
case "s": toggleSplit(); break;
case "v": togglePreview(); break;
case "/": openFind(); break;
case "Delete": deleteCurrent(); break;
case "n": openNext(); break;
case "i": showInbox(true); break;
case "w": toggleWide(); break;
case "z": toggleWrap(); break;
case "t":
if (railNarrow.matches) { if (!rail.classList.contains("empty")) toggleSheet("rail"); }
else { const off = root.dataset.rail !== "0"; root.dataset.rail = off ? "0" : "1"; store.set("snyvi.rail", off ? "0" : "1"); }
break;
case "0": { const fig = mmdKeyed(); if (fig) { mmdFit(fig); mmdTouched = fig; } break; }
case "f": { const fig = mmdKeyed(); if (fig) { mmdFull(fig); mmdTouched = fig; } break; }
case "\\":
if (sideNarrow.matches) toggleSheet("side");
else { const off = root.dataset.side !== "0"; root.dataset.side = off ? "0" : "1"; store.set("snyvi.side", off ? "0" : "1"); }
break;
case "o":
if (state.doc) window.open(`/api/docs/${state.doc.id}/raw`, "_blank");
else if (browsing() && state.browsePath) window.open(rawUrl(state.browseRoot.id, state.browsePath), "_blank");
break;
case "?": help.hidden ? openDialog(help, help.firstElementChild) : closeDialog(help); break;
default: return;
}
e.preventDefault();
});
if (state.view === "doc" && state.doc) {
markRead(state.doc.id);
document.title = state.doc.title; afterRender(); history.replaceState({ id: state.doc.id }, "", location.pathname + location.hash);
if (location.hash && !lineHash()) jumpToHash();
}
else if (state.view === "browse" && state.browseRoot) { showBrowse(state.browseRoot.id, state.browsePath, false); history.replaceState({ browse: state.browseRoot.id, path: state.browsePath }, "", location.pathname + location.hash); }
else if (state.view === "connect") { showConnect(false); history.replaceState({ connect: true }, "", "/connect"); }
else { showInbox(false); history.replaceState({ inbox: true }, "", "/"); }
connect();
})();