(() => {
"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,
opening: null, deskBehind: 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 || {}, notes: boot.notes || [], desks: null, deskId: null, };
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 relShort = ts => {
const d = Date.now() / 1000 - ts;
if (d < 60) return "now";
if (d < 3600) return `${Math.max(1, Math.round(d / 60))}m`;
if (d < 86400) return `${Math.round(d / 3600)}h`;
if (d < 7 * 86400) return `${Math.round(d / 86400)}d`;
return new Date(ts * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric" });
};
const ctx2d = () => { try { return document.createElement("canvas").getContext("2d"); } catch { return null; } };
const fitCtx = ctx2d(), timeCtx = ctx2d();
let fitFont = "", titleRoom = 154, recut = false;
const roomIn = w => Math.max(60, w - 60);
const wide = t => fitCtx.measureText(t).width;
const roomFor = ts => titleRoom - (timeCtx ? timeCtx.measureText(ts).width : 38);
const mid = (t, px) => {
t = String(t);
if (!fitCtx || wide(t) <= px) return t;
let head = 0;
while (head < t.length && wide(t.slice(0, head + 1)) <= px * 0.6) head++;
const back = t.lastIndexOf(" ", head);
if (back > 0 && head - back < 8) head = back;
const out = t.slice(0, head).trimEnd() + "…";
let from = t.length;
while (from > head && wide(out + t.slice(from - 1)) <= px) from--;
const fwd = t.indexOf(" ", from - 1);
if (fwd > 0 && fwd - from < 8) from = fwd + 1;
return out + t.slice(from).trimStart();
};
const dateFmt = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
const fmt = ts => dateFmt.format(new Date(ts * 1000));
const afterPaint = fn => requestAnimationFrame(() => setTimeout(fn, 0));
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();
const folded = new Set((store.get("snyvi.fold") || "").split(",").filter(Boolean));
const applyFolds = () => { for (const k of ["inbox", "desks", "folders"]) treesEl.classList.toggle(`fold-${k}`, folded.has(k)); };
applyFolds();
function toggleFold(key) {
if (!folded.delete(key)) folded.add(key);
store.set("snyvi.fold", [...folded].join(","));
applyFolds();
const open = !folded.has(key);
for (const b of treesEl.querySelectorAll(`[data-fold="${key}"]`)) b.setAttribute("aria-expanded", open);
}
const ICONS = {
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
project: '<path d="M12 3 3 7.5l9 4.5 9-4.5z"/><path d="m3 12 9 4.5 9-4.5"/><path d="m3 16.5 9 4.5 9-4.5"/>',
desk: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="m7.5 10 2.5 2-2.5 2M12.5 14.5h4"/>',
folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2z"/>',
};
const icon = k => `<svg class="r-ico" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[k]}</svg>`;
function secHead(key, name, tail = "") {
const open = !folded.has(key);
return `<div class="s-head" data-sec="${key}"><button type="button" class="s-link" data-fold="${key}" aria-expanded="${open}"><span class="s-nm">${name}</span><span class="s-chev" aria-hidden="true"></span></button>${tail}</div>`;
}
function renderBrowse() {
const ids = state.browse.map(r => r.id).join(",");
if (browseEl.dataset.ids === ids) return;
browseEl.dataset.ids = ids;
const head = secHead("folders", "Folders");
browseEl.innerHTML = head + `<div class="b-body s-body">` + 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)}">${icon("folder")}<span class="nm">${esc(r.name)}</span>${plusDesk()}<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("") + `<button type="button" class="b-empty" data-pick>${state.browse.length ? "Open another folder…" : "Read a folder as it is on disk"}</button></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 === "desk" && state.deskId != null ? $("#desk-nav").querySelector(`a[data-desk="${state.deskId}"]`)
: state.deskBehind != null ? $("#desk-nav").querySelector(`a[data-desk="${state.deskBehind}"]`)
: state.view === "browse" && state.browseRoot ? browseEl.querySelector(`.b-file a[data-browse="${state.browseRoot.id}"][data-path="${CSS.escape(state.browsePath)}"]`)
: state.opening ? treeEl.querySelector(`a[data-id="${state.opening}"]`)
: 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.deskBehind == null && state.doc.project_id === p.id) || state.tree.length === 1;
const docRow = d => {
noteKnown(d);
const ago = relShort(d.received_at);
const cls = waitingRow(d) ? "new" : "";
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(mid(d.title, roomFor(ago)))}</span>${d.pinned ? `<span class="pin" title="Pinned">●</span>` : ""}<span class="k">${ago}</span><button type="button" class="row-x" data-deldoc="${d.id}" title="Delete" aria-label="Delete ${esc(d.title)}">✕</button></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 = "") => (noteKnown(d), `<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><button type="button" class="row-x" data-deldoc="${d.id}" title="Delete" aria-label="Delete ${esc(d.title)}">✕</button></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.", null, null, { face: "love" }); 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"), null, null, { face: "glad" });
}
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();
});
const SHOW_DOCS = 5, SHOW_ROWS = 8;
const wholeWf = w => liftedWorkflows.has(w.id) || (state.doc && state.doc.workflow_id === w.id);
function projectRows(p) {
const pid = String(p.id), wfs = state.sub.get(pid);
if (!wfs) return `<li class="t-wait">…</li>`;
const allWfs = liftedCaps.has(pid);
let h = "", shownWfs = 0, rows = 0;
for (const w of wfs) {
if (!allWfs && rows >= SHOW_ROWS && !(state.doc && state.doc.workflow_id === w.id)) continue;
shownWfs++;
const solo = w.total === 1 && w.docs.length === 1;
const whole = wholeWf(w), docs = whole ? w.docs : w.docs.slice(0, SHOW_DOCS);
h += `<li class="t-wf${solo ? " solo" : ""}">${solo ? "" : `<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 docs) h += docRow(d);
rows += docs.length;
if (w.total > docs.length) h += `<li class="t-more"><button type="button" data-more-docs="${w.id}">${w.total - docs.length} more</button></li>`;
else if (liftedWorkflows.has(w.id) && w.total > SHOW_DOCS) h += `<li class="t-more"><button type="button" data-less-docs="${w.id}">less</button></li>`;
h += `</ul></li>`;
}
if (p.workflows > shownWfs) h += `<li class="t-more"><button type="button" data-more-wf="${p.id}">${p.workflows - shownWfs} more sessions</button></li>`;
else if (allWfs && wfs.length > 1 && liftedCaps.has(pid)) h += `<li class="t-more"><button type="button" data-less-wf="${p.id}">fewer sessions</button></li>`;
return h;
}
let drawnTree = null;
const treeTouched = new MutationObserver(() => { drawnTree = null; });
treeTouched.observe(treeEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["open"] });
function renderTree() {
const projects = state.tree;
const total = projects.reduce((n, p) => n + p.docs, 0);
inboxRowEl.innerHTML = secHead("inbox", "Inbox") +
`<a class="t-inbox s-row" href="/" data-nav="inbox">${icon("inbox")}<span class="title">All documents</span><span class="n">${total}</span></a>`;
renderQueue();
renderBrowse();
renderDesks();
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: <b>+</b> beside Folders, below.</div>`;
return;
}
if (fitCtx && treeEl.clientWidth) {
const cs = getComputedStyle(treeEl);
const f = `550 ${cs.fontSize} ${cs.fontFamily}`;
if (f !== fitFont) {
fitCtx.font = fitFont = f;
if (timeCtx) timeCtx.font = `10px ${getComputedStyle(document.documentElement).getPropertyValue("--mono")}`;
}
titleRoom = roomIn(treeEl.clientWidth);
}
for (const d of treeEl.querySelectorAll(".t-proj[open]")) {
if (d.dataset.pid) openProjects.add(d.dataset.pid);
}
let h = "";
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)}">${icon("project")}<span class="nm">${esc(p.name)}</span>${renameBtn("project", p.id)}</summary><ul>`;
h += open ? projectRows(p) : "";
h += `</ul></details>`;
}
if (h !== drawnTree) {
treeEl.innerHTML = h;
drawnTree = h;
treeTouched.takeRecords();
}
if (fitCtx && treeEl.clientWidth && !recut && roomIn(treeEl.clientWidth) !== titleRoom) {
recut = true;
try { renderTree(); } finally { recut = false; }
return;
}
for (const p of projects) if (projOpen(p) && !state.sub.has(String(p.id))) fillProject(p.id);
}
try {
document.fonts.ready.then(() => { fitFont = ""; renderTree(); markActive(); });
} catch {}
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)}${plusDesk()}</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], [data-less-docs], [data-less-wf]");
if (more) {
e.preventDefault(); e.stopPropagation();
more.disabled = true;
const { moreWf, moreDocs, lessWf, lessDocs } = more.dataset;
if (lessWf != null) { liftedCaps.delete(String(lessWf)); }
else if (lessDocs != null) { liftedWorkflows.delete(Number(lessDocs)); }
else if (moreWf != null) {
const pid = String(moreWf), p = state.tree.find(x => String(x.id) === pid), wfs = state.sub.get(pid) || [];
liftedCaps.add(pid);
if (p && p.workflows > wfs.length) { await fillProject(pid, true); return; }
} else {
const wid = Number(moreDocs);
const pid = [...state.sub.keys()].find(k => state.sub.get(k).some(w => w.id === wid));
const w = (state.sub.get(pid) || []).find(x => x.id === wid);
liftedWorkflows.add(wid);
if (w && w.docs.length < w.total) await fillWorkflow(wid, pid);
}
renderTree(); markActive();
return;
}
const fold = e.target.closest("[data-fold]");
if (fold) { e.preventDefault(); toggleFold(fold.dataset.fold); return; }
if (e.target.closest("[data-pick]")) { e.preventDefault(); e.stopPropagation(); pickFolder(); return; }
const nd = e.target.closest("[data-newdesk]");
if (nd) {
e.preventDefault(); e.stopPropagation();
newDesk(folderOf(nd));
return;
}
const dx = e.target.closest("[data-deldoc]");
if (dx) {
e.preventDefault(); e.stopPropagation();
const id = dx.dataset.deldoc, proj = dx.closest(".t-proj");
const d = state.queue.find(x => x.id === id) || { id, title: dx.closest("a").title.split(" · ")[0], project_id: proj ? +proj.dataset.pid : null };
deleteDoc(d);
return;
}
const dd = e.target.closest("[data-dropdesk]");
if (dd) {
e.preventDefault(); e.stopPropagation();
dropDesk(dd);
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(); markActive(); }
});
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, cls = label.className;
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 = cls;
label.textContent = before;
input.replaceWith(label);
holder.classList.remove("renaming");
if (!keep || !next || next === before) return;
label.textContent = next; try {
if (what === "desk") { await deskApi(`/api/desks/${id}/rename`, { name: next }); await loadDesks(); }
else {
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);
}
let swapAnim = null;
const still = matchMedia("(prefers-reduced-motion: reduce)");
function swapIn() {
if (swapAnim) swapAnim.cancel();
swapAnim = still.matches ? null : docEl.animate(
[{ opacity: 0, transform: "translateY(4px)" }, { opacity: 1, transform: "none" }],
{ duration: 180, easing: "cubic-bezier(.2,.7,.2,1)" });
}
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;
}
const knownDocs = new Map();
function noteKnown(d) {
if (d && d.id && !knownDocs.has(d.id)) knownDocs.set(d.id, { title: d.title, kind: d.kind, received_at: d.received_at });
return d;
}
function pending(id, fromHistory) {
const k = knownDocs.get(id);
docEl.innerHTML =
`<header class="doc-head">${k ? `<h1 class="doc-title">${esc(k.title)}</h1>` : `<h1 class="doc-title sk"><span class="sk-bar" style="width:52%"></span></h1>`}` +
`<p class="doc-sub sk"><span class="sk-bar" style="width:${k ? "34%" : "44%"}"></span></p></header>` +
`<article class="prose sk-body" aria-busy="true">` +
SK_WIDTHS.map(w => w ? `<span class="sk-bar" style="width:${w}%"></span>` : `<span class="sk-gap"></span>`).join("") +
`</article>`;
if (k) document.title = k.title;
markPending(id);
if (!fromHistory) main.scrollTo({ top: 0, behavior: "instant" });
}
const SK_WIDTHS = [96, 91, 97, 88, 94, 42, 0, 93, 96, 89, 95, 37];
function markPending(id) {
if (state.deskBehind != null) { markActive(); return; } for (const a of treesEl.querySelectorAll("a.active, .t-inbox.active")) { a.classList.remove("active"); a.removeAttribute("aria-current"); }
const on = treeEl.querySelector(`a[data-id="${id}"]`);
if (on) { on.classList.add("active"); on.setAttribute("aria-current", "page"); }
}
let opening = 0;
async function showDoc(id, push = true, fromHistory = false, over) {
if (over === undefined) over = !push && state.deskBehind != null && !!state.doc && state.doc.id === id;
const turn = ++opening;
let j = state.cache.get(id), waited = false;
if (!j) {
waited = true;
if (push) leave();
behindDesk(id, over);
state.view = "doc"; state.opening = id; state.comparing = null;
pending(id, fromHistory);
if (push) { history.pushState({ id, over: state.deskBehind }, "", `/d/${id}`); push = false; }
try { j = await fetchDoc(id); } catch (e) { if (turn === opening) { state.opening = null; toast("Could not open document", String(e)); } return; }
if (turn !== opening) return;
state.opening = null;
}
if (push) leave();
behindDesk(id, over);
state.view = "doc"; state.doc = j.doc; state.previous = j.previous; state.comparing = null; state.folder = j.folder;
afterPaint(() => markRead(id));
setPreview(j.preview, j.preview_url, `d:${id}`);
docEl.innerHTML = j.html;
if (!waited) swapIn();
applyPreview();
if (j.doc.kind === "diff" && state.split) { await applySplit(); }
document.title = j.doc.title;
if (push) history.pushState({ id, over: state.deskBehind }, "", `/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();
offDesk();
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();
offDesk();
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();
offDesk();
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 => (noteKnown(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();
offDesk();
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) deleteDoc(state.doc);
}
async function deleteDoc(d) {
const here = !!state.doc && state.doc.id === d.id;
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);
if (here) showInbox(true);
offerUndo(d, here);
} catch (e) { toast("Could not delete", String(e)); }
}
function offerUndo(d, open = true) {
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);
if (open) 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 });
}
let rendered = 0;
function afterRender() {
const turn = ++rendered;
markActive();
buildToc();
renderMeta(false);
prepareMermaid();
enhanceCode();
clearFind();
applyLineHash(true);
afterPaint(() => {
if (turn !== rendered) return;
renderTree();
markActive();
if (state.deskBehind == null) ensureWorkflow(state.doc);
renderHistory();
});
}
function afterRefresh() {
buildToc();
renderMeta(false);
enhanceCode();
prepareMermaid();
renderHistory();
if (!findBar.hidden && findIn.value) runFind(findIn.value); else clearFind();
applyLineHash(false); }
const noteEl = $("#note");
let noteLook = 0, notePeek = 0, noteShown = (state.notes[0] || {}).id || 0;
const markEl = $(".brand-mark");
const HEART = (x, y) => `<path class="nb-love" transform="translate(${x} ${y}) scale(.8)" d="M0 3.2c-3.4-2-4.3-4.4-2.6-5.6 1-.7 2.1-.1 2.6.8.5-.9 1.6-1.5 2.6-.8 1.7 1.2.8 3.6-2.6 5.6z"/>`;
const FEELINGS = [
`<path class="nb-line" d="M8.6 17.8q2.4-3 4.8 0M18.6 17.8q2.4-3 4.8 0"/><path class="nb-ink" d="M12.6 21.6q3.4 4.6 6.8 0z"/>`,
`<ellipse class="nb-ink" cx="11" cy="16.5" rx="2.6" ry="3.3"/><circle class="nb-shine" cx="11.9" cy="15.2" r="1"/><path class="nb-line" d="M18.6 17.6q2.4-2.8 4.8 0M13.5 22.6q3 2.8 6 0"/>`,
HEART(11, 16.5) + HEART(21, 16.5) + `<path class="nb-line" d="M13.5 22.6q2.5 2.4 5 0"/>`,
];
const noteBg = id => `<svg class="note-bg" viewBox="0 0 32 32" aria-hidden="true">` +
`<rect class="nb-nub" x="14" y="0.5" width="4" height="5" rx="2"/><rect class="nb-body" x="1" y="4" width="30" height="27" rx="9"/>` +
`<ellipse class="nb-cheek" cx="7.4" cy="21.8" rx="2.4" ry="1.5"/><ellipse class="nb-cheek" cx="24.6" cy="21.8" rx="2.4" ry="1.5"/>` +
FEELINGS[id % FEELINGS.length] + `</svg>`;
function noteBy(n) {
return [n.sender && `via ${esc(n.sender)}`, n.project && `on ${esc(n.project)}`].filter(Boolean).join(" ");
}
function renderNote() {
const [n, ...trail] = state.notes;
if (n && !n.seen) root.dataset.note = n.lit ? "lit" : "new";
else delete root.dataset.note;
if (!n) { noteEl.hidden = true; noteEl.innerHTML = ""; return; }
noteEl.hidden = false;
noteEl.dataset.lit = n.lit && !n.seen ? "1" : "";
noteEl.dataset.seen = n.seen ? "1" : "";
const arrived = n.id !== noteShown && !n.seen;
if (arrived && markEl) {
markEl.classList.remove("hop"); void markEl.offsetWidth; markEl.classList.add("hop");
if ("say" in root.dataset) closeSay();
}
noteShown = n.id;
const by = noteBy(n);
noteEl.innerHTML =
(trail.length ? `<ol class="note-trail">${trail.map(t => `<li${t.about ? ` data-about="${esc(t.about)}"` : ""}><p>${esc(t.text)}</p><span class="note-by"><b class="note-snyvi">snyvi</b> · ${relShort(t.at)}${by === noteBy(t) ? "" : " · " + noteBy(t)}</span></li>`).join("")}</ol>` : "") +
`<div class="note-now" tabindex="0" role="note"${n.about ? ` data-about="${esc(n.about)}" title="Open what this is about"` : ""}>` +
noteBg(n.id) + `<p>${esc(n.text)}</p><span class="note-by note-by-now"><span class="note-who" title="${by}"><b class="note-snyvi">snyvi</b> · ${relShort(n.at)}${by ? " · " + by : ""}</span>${trail.length ? `<span class="note-more">+${trail.length}</span>` : ""}</span></div>`;
if (arrived) { if (document.hidden) peekOwed = true; else peekNote(); }
}
let peekOwed = false;
function peekNote() {
peekOwed = false;
clearTimeout(notePeek);
void noteEl.offsetWidth; noteEl.classList.add("peek");
notePeek = setTimeout(() => noteEl.classList.remove("peek"), 4200);
}
document.addEventListener("visibilitychange", () => { if (!document.hidden && peekOwed) peekNote(); });
function seeNotes() {
if (!state.notes.some(n => !n.seen)) return;
state.notes = state.notes.map(n => ({ ...n, seen: true }));
renderNote();
fetch("/api/notes/seen", { method: "POST" }).catch(() => {});
}
const noteNear = on => { if (on) root.dataset.noteNear = "1"; else delete root.dataset.noteNear; };
noteEl.addEventListener("mouseenter", () => { noteNear(true); clearTimeout(noteLook); noteLook = setTimeout(seeNotes, 700); });
noteEl.addEventListener("mouseleave", () => { noteNear(false); clearTimeout(noteLook); });
noteEl.addEventListener("focusin", () => { noteNear(true); seeNotes(); });
noteEl.addEventListener("focusout", () => noteNear(false));
markEl?.addEventListener("animationend", e => { if (e.animationName === "bm-hop") markEl.classList.remove("hop"); });
noteEl.addEventListener("click", e => {
seeNotes();
const a = e.target.closest("[data-about]");
if (a) showDoc(a.dataset.about, true);
});
noteEl.addEventListener("keydown", e => {
const a = e.target.closest(".note-now[data-about]");
if (a && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); showDoc(a.dataset.about, true); }
});
setInterval(() => { if (state.notes.length && !noteEl.matches(":hover")) renderNote(); }, 60000);
renderNote();
const brandEl = $(".brand"), sayEl = $("#bm-say");
const SAYS = [
{ t: "hi", w: 5 },
{ t: "hey you", w: 3 },
{ t: "still here", w: 2 },
{ t: "hello again", w: 2, f: "glad" },
{ t: "good to see you", w: 2, f: "glad" },
{ t: "love you", w: 1, f: "love" },
{ t: "my favourite", w: 1, f: "love" },
{ t: () => state.waiting ? `${state.waiting} waiting` : "", w: 4, f: "glad" },
{ t: () => Object.keys(state.online).length ? "" : "no agents", w: 3 },
{ t: () => { const h = new Date().getHours(); return h < 5 || h >= 23 ? "late one?" : h < 10 ? "morning" : ""; }, w: 3, f: "wink" },
];
let saidLast = [], sayIn = 0, sayOut = 0;
function pickSay() {
if (root.dataset.link === "off") return { t: "not connected", f: "" };
const pool = [];
for (const s of SAYS) {
const t = typeof s.t === "function" ? s.t() : s.t;
if (!t || saidLast.includes(t)) continue;
for (let i = 0; i < s.w; i++) pool.push({ t, f: s.f || "" });
}
return pool.length ? pool[Math.floor(Math.random() * pool.length)] : { t: "hi", f: "" };
}
function openSay() {
if (root.dataset.note) return;
const s = pickSay();
saidLast = [s.t, ...saidLast].slice(0, 3);
sayEl.textContent = s.t;
sayEl.hidden = false;
void sayEl.offsetWidth; sayEl.classList.add("on");
root.dataset.say = s.f;
}
function closeSay() {
sayEl.classList.remove("on");
delete root.dataset.say;
clearTimeout(sayOut);
sayOut = setTimeout(() => { if (!sayEl.classList.contains("on")) sayEl.hidden = true; }, 340);
}
brandEl.addEventListener("pointerenter", e => {
if (e.pointerType === "touch") return;
clearTimeout(sayIn);
sayIn = setTimeout(openSay, 260);
});
brandEl.addEventListener("pointerleave", () => { clearTimeout(sayIn); closeSay(); });
brandEl.addEventListener("click", () => { clearTimeout(sayIn); closeSay(); });
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 (swapAnim && swapAnim.playState === "running") swapAnim.finished.then(put, () => {});
}
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 mmd = null, mmdLoading = null;
const mmdLoad = () => (mmdLoading ||= import(`/assets/mmd.js${boot.v ? `?v=${boot.v}` : ""}`)
.then(m => (mmd = m))
.catch(e => { mmdLoading = null; throw e; }));
function prepareMermaid() {
if (mmd) { mmd.prepare(); return; }
if (docEl.querySelector("pre.mermaid")) mmdLoad().then(m => m.prepare()).catch(() => {});
}
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");
bring(() => docEl.querySelector("mark.find.cur"), "center");
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) {
if (links.at !== at) {
links.at = at;
links.forEach((a, i) => {
const on = i === at;
a.classList.toggle("cur", on);
if (on) a.setAttribute("aria-current", "location"); else a.removeAttribute("aria-current");
});
}
if (links[at]) 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; }
if (outlineSpy) { outlineSpy.disconnect(); outlineSpy = null; }
if (state.deskBehind != null) { rail.classList.remove("empty"); return; }
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 bring(target, block = "start") {
let left = 20;
const put = () => {
const el = typeof target === "function" ? target() : target;
if (!el || !el.isConnected) return true; const box = el.getBoundingClientRect(), port = main.getBoundingClientRect();
const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
const d = block === "center" ? box.top + box.height / 2 - (port.top + main.clientHeight / 2)
: block === "nearest" ? (box.top < port.top + margin ? box.top - port.top - margin
: box.bottom > port.bottom ? box.bottom - port.bottom : 0)
: box.top - port.top - margin;
const off = Math.abs(d);
if (off < 2) return true;
main.scrollTo({ top: main.scrollTop + d, behavior: "instant" });
return false;
};
const step = () => { if (!put() && left-- > 0) requestAnimationFrame(step); };
step();
for (const ms of [150, 450]) setTimeout(() => { left = 8; step(); }, ms);
}
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 pre = docEl.querySelector("pre.code");
const lines = pre ? pre.getElementsByClassName("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;
bring(el, "start");
flash(el);
}));
outlineSpy = follow(() => {
const top = lineAt(pre, 140);
let lo = 0, hi = items.length - 1, cur = -1;
while (lo <= hi) { const m = (lo + hi) >> 1; if (items[m].line <= top) { cur = m; lo = m + 1; } else hi = m - 1; }
markCur(links, cur);
});
}
let outlineToken = 0;
function lastAbove(list, y) {
let lo = 0, hi = list.length - 1, at = -1;
while (lo <= hi) {
const m = (lo + hi) >> 1;
if (list[m].getBoundingClientRect().top < y) { at = m; lo = m + 1; } else hi = m - 1;
}
return at;
}
function lineAt(pre, y) {
const chunks = pre.getElementsByClassName("lc");
if (!chunks.length) return lastAbove(pre.getElementsByClassName("ln"), y) + 1;
const c = lastAbove(chunks, y);
if (c < 0) return 0;
const start = +(/ln (\d+)/.exec(chunks[c].getAttribute("style") || "") || [0, 0])[1];
return start + lastAbove(chunks[c].getElementsByClassName("ln"), y) + 1;
}
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) bring(first, "center");
}
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.deskBehind != null) return; 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("") +
(d.desk ? `<div class="row"><b>From</b><span><a href="/desk/${d.desk.id}" data-desk="${d.desk.id}" data-slot="${d.desk.slot}">${esc(d.desk.name)} [${d.desk.slot}] ▸</a></span></div>` : "") +
`<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(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", null, null, { face: pinned ? "glad" : "plain" });
} 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], a[data-desk], [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.nav === "desks") showDesk(null, true);
else if (a.dataset.browse !== undefined) showBrowse(a.dataset.browse, a.dataset.path, true);
else if (a.dataset.desk !== undefined) showDesk(+a.dataset.desk, true, +a.dataset.slot || 0);
else showDoc(a.dataset.id, true);
if (root.dataset.sheet === "side") closeSheet();
});
docEl.addEventListener("click", e => {
const a = e.target.closest(".prose a[href]");
if (!a || a.dataset.ext !== undefined || e.metaKey || e.ctrlKey || e.shiftKey || e.button) return;
const href = a.getAttribute("href") || "";
if (href.startsWith("#")) return;
let u;
try { u = new URL(a.href); } catch { return; }
if (u.origin !== location.origin) {
e.preventDefault();
window.open(a.href, "_blank", "noopener");
return;
}
const d = u.pathname.match(/^\/d\/([a-z0-9]+)$/);
if (d) { e.preventDefault(); showDoc(d[1], true); return; }
const b = u.pathname.match(/^\/b\/([a-z0-9]+)(?:\/(.*))?$/);
if (b) { e.preventDefault(); showBrowse(b[1], decodeURIComponent(b[2] || ""), true); return; }
if (u.pathname === "/") { e.preventDefault(); showInbox(true); return; }
if (u.pathname === "/connect") { e.preventDefault(); showConnect(true); return; }
e.preventDefault();
toast("Not a page in snyvi", href, null, {
label: "Open anyway",
run: () => window.open(a.href, "_blank", "noopener"),
});
});
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, !!(history.state && history.state.over != null));
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);
const k = location.pathname.match(/^\/desk\/(\d+)$/);
if (k || location.pathname === "/desks") return showDesk(k ? +k[1] : null, false);
showInbox(false);
});
const capability = (() => {
try {
const m = /^#cap=([0-9a-f]{64})(?:&(.*))?$/.exec(location.hash);
if (m) {
sessionStorage.setItem("snyvi.cap", m[1]);
const rest = m[2] ? "#" + m[2] : "";
history.replaceState(history.state, "", location.pathname + location.search + rest);
}
return sessionStorage.getItem("snyvi.cap") || "";
} catch { return ""; }
})();
async function deskSocket() {
if (!capability) return null;
const url = location.origin.replace(/^http/, "ws") + "/api/desk";
let sock;
try { sock = new WebSocket(url); } catch { return null; }
return new Promise(resolve => {
const give = v => { if (!v && sock.readyState <= 1) sock.close(); resolve(v); };
sock.onerror = () => give(null);
sock.onclose = () => give(null);
sock.onopen = () => sock.send(JSON.stringify({ capability }));
sock.onmessage = ev => {
let j; try { j = JSON.parse(ev.data); } catch { return give(null); }
give(j && j.ok ? sock : null);
};
});
}
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; }
})();
if (window.__TAURI_INTERNALS__) import(`/assets/frame.js${boot.v ? `?v=${boot.v}` : ""}`).then(m => m.frame(root, $), () => {});
const deskNav = $("#desk-nav");
let desk = null, deskLoading = null, lastDesk = null;
const plusDesk = () => capability ? `<button class="b-new" data-newdesk title="New desk here" aria-label="New desk here">+</button>` : "";
async function deskApi(path, body, type) {
const headers = { "content-type": type || "application/json", "x-snyvi-capability": capability };
const r = await fetch(path, body === undefined ? { headers } : { method: "POST", headers, body: type ? body : JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
return j;
}
let picking = false;
async function pickFolder() {
if (!capability) { toast("Folders open from the snyvi window", "Or from a terminal: snyvi browse <folder>"); return; }
if (picking) return;
picking = true;
browseEl.classList.add("picking");
try {
const r = await fetch("/api/browse/pick", { method: "POST", headers: { "x-snyvi-capability": capability } });
if (r.status === 204) return; const j = await r.json().catch(() => ({}));
if (!r.ok) { toast("Could not open a folder", j.error || `HTTP ${r.status}`); return; }
if (!state.browse.some(x => x.id === j.root.id)) state.browse = state.browse.concat(j.root);
renderBrowse();
const d = browseEl.querySelector(`.b-root[data-root="${j.root.id}"]`);
if (d) d.open = true;
showBrowse(j.root.id, "", true);
} catch (e) { toast("Could not open a folder", String(e)); }
finally { picking = false; browseEl.classList.remove("picking"); }
}
async function loadDesks() {
if (capability) { try { state.desks = await deskApi("/api/desks"); } catch {} }
renderDesks();
if (desk && (state.view === "desk" || state.deskBehind != null)) desk.update(state.desks);
}
const mark3 = ps => ps.some(p => p.status && p.status.blocked) ? "!" : ps.some(p => p.status && p.status.running) ? "●" : "○";
function renderDesks() {
const list = state.desks ? state.desks.desks : [];
let blocked = 0;
for (const d of list) for (const p of d.panes) if (p.status && p.status.blocked) blocked++;
const on = state.view === "desk" || state.deskBehind != null; deskNav.innerHTML = secHead("desks", "Desks", (blocked ? `<span class="s-blk" title="${plural(blocked, "pane")} waiting on you">!${blocked}</span>` : "") + (capability ? `<button type="button" class="s-add" data-newdesk title="New desk" aria-label="New desk">+</button>` : "")) +
`<ul class="t-desks s-body">` + (!capability ? `<li class="s-empty" title="Desks run in the desktop window">Open the snyvi window to run desks</li>`
: !list.length ? `<li><button type="button" class="b-empty" data-newdesk>Start a shell on a desk</button></li>` : "") + list.map(d => {
const m = mark3(d.panes), has = d.panes.length > 0;
const say = m === "!" ? `${plural(d.panes.filter(p => p.status && p.status.blocked).length, "pane")} waiting on you` : m === "●" ? "Running" : "Idle";
const end = `<span class="end"><span class="dot${m === "!" ? " blk" : m === "●" ? " on" : ""}" title="${say}">${m === "!" ? "!" : ""}</span><span class="k">${has ? d.panes.length : ""}</span></span>`;
return `<li class="t-desk"><a href="/desk/${d.id}" data-desk="${d.id}" class="${on && state.deskId === d.id ? "active" : ""}" title="${esc(d.root)}">` +
`${icon("desk")}<span class="title nm">${esc(d.name)}</span>${capability ? renameBtn("desk", d.id) : ""}${end}${capability ? `<button type="button" class="row-x" data-dropdesk="${d.id}" title="Close desk" aria-label="Close desk ${esc(d.name)}">✕</button>` : ""}</a></li>`;
}).join("") + `</ul>`;
}
async function dropDesk(b) {
if (!b.dataset.armed) {
b.dataset.armed = "1"; b.textContent = "Close?"; b.title = "Close the desk and its panes: click again";
const li = b.closest("li"); li.classList.add("arming");
setTimeout(() => { if (b.isConnected) { delete b.dataset.armed; b.textContent = "✕"; b.title = "Close desk"; li.classList.remove("arming"); } }, 3000);
return;
}
const id = +b.dataset.dropdesk;
try { await deskApi(`/api/desks/${id}/delete`, {}); }
catch (err) { toast(`Could not close the desk: ${err.message}`); return; }
if (lastDesk === id) lastDesk = null;
await loadDesks();
if (state.view === "desk" && state.deskId === id) showDesk(null, true);
}
async function showDesk(id, push = true, slot = 0) {
if (push) leave();
const was = state.view === "desk";
state.view = "desk"; state.deskId = id; state.deskBehind = null; state.doc = null; state.previous = null; state.comparing = null; state.browseRoot = null;
if (id != null) lastDesk = id;
root.dataset.view = "desk";
if (push) history.pushState({ desk: id }, "", id == null ? "/desks" : `/desk/${id}`);
renderTree(); markActive();
if (!capability) {
document.title = "Desks · snyvi";
docEl.innerHTML = `<div class="inbox-head"><h1>Desks</h1><p>Desks run in the snyvi desktop window. This is a browser tab, so it has no capability to start a process. Open the same address in the desktop app.</p></div><p><code>snyvi:/${esc(location.pathname)}</code></p>`;
tocEl.innerHTML = metaEl.innerHTML = ""; rail.classList.add("empty");
return;
}
try { desk = await (deskLoading ||= import(`/assets/desk.js${boot.v ? `?v=${boot.v}` : ""}`)); }
catch (e) { deskLoading = null; toast("Could not open the desk", String(e)); return; }
if (state.view !== "desk") return;
if (!state.desks) await loadDesks();
desk.open({ id, slot, was, desks: state.desks, api: deskApi, socket: deskSocket, toast, esc, plural, rel, relShort, fmt, read: id => showDoc(id, true, false, true), go: showDesk, swap: swapDesk, make: () => newDesk(null), refresh: loadDesks, main, docEl, tocEl, metaEl, rail, root });
}
function offDesk() {
if (state.view === "desk") { delete root.dataset.view; if (desk) desk.close(); }
else if (state.deskBehind != null && desk) desk.close();
state.deskBehind = null;
}
function behindDesk(id, over) {
if (!over) { offDesk(); return; }
if (state.view === "desk" && state.deskId != null && desk) { state.deskBehind = state.deskId; delete root.dataset.view; }
if (state.deskBehind == null) { offDesk(); return; }
desk.aside(id);
}
function swapDesk() {
if (state.view === "desk") { history.length > 1 ? history.back() : showInbox(true); return; }
const d = lastDesk != null ? lastDesk : state.desks && state.desks.desks[0] ? state.desks.desks[0].id : null;
showDesk(d, true);
}
const deskDocs = () => { if (desk && ((state.view === "desk" && state.deskId != null) || state.deskBehind != null)) desk.docs(); };
async function newDesk(f) {
try {
const j = await deskApi("/api/desks", f ? { root: f.root, path: f.path } : {});
try {
const p = await deskApi(`/api/desks/${j.desk.id}/panes`, {});
await deskApi(`/api/panes/${p.pane.id}/start`, { cmd: "" });
} catch (e) { toast("The desk is made, but its shell did not start", String(e)); }
await loadDesks();
showDesk(j.desk.id, true);
} catch (e) { toast("Could not make a desk", String(e)); }
}
function folderOf(el) {
const d = el.closest(".b-dir > details, .b-root");
const r = d && state.browse.find(x => x.id === d.dataset.root);
if (!r) return null;
const path = d.dataset.path || "";
return { root: r.id, path, abs: r.path + (path ? "/" + path : "") };
}
const menu = document.createElement("div");
menu.id = "ctx"; menu.hidden = true; menu.setAttribute("role", "menu");
document.body.append(menu);
let menuAt = null;
function openMenu(f, x, y) {
menuAt = f;
const here = state.desks ? state.desks.desks.filter(d => d.root === f.abs) : [];
menu.innerHTML = (capability ? `<button role="menuitem" data-m="new">New desk here</button>` +
here.map(d => `<button role="menuitem" data-m="show" data-id="${d.id}">Show desk ${esc(d.name)}</button>`).join("") + `<hr>` : "") +
`<button role="menuitem" data-m="copy">Copy path</button><button role="menuitem" data-m="term">Open terminal here</button>`;
menu.hidden = false;
menu.style.left = Math.max(4, Math.min(x, innerWidth - menu.offsetWidth - 8)) + "px";
menu.style.top = Math.max(4, Math.min(y, innerHeight - menu.offsetHeight - 8)) + "px";
menu.querySelector("button").focus();
}
const closeMenu = () => { menu.hidden = true; menuAt = null; };
treesEl.addEventListener("contextmenu", e => {
const s = e.target.closest(".b-dir > details > summary, .b-root > summary");
const f = s && folderOf(s);
if (!f) return;
e.preventDefault();
openMenu(f, e.clientX, e.clientY);
});
menu.addEventListener("click", e => {
const b = e.target.closest("[data-m]"), f = menuAt;
if (!b || !f) return;
closeMenu();
const m = b.dataset.m;
if (m === "new") newDesk(f);
else if (m === "show") showDesk(+b.dataset.id, true);
else if (m === "copy") { navigator.clipboard?.writeText(f.abs); toast("Copied", f.abs); }
else openTerminal({ root: f.root, path: f.path });
});
menu.addEventListener("keydown", e => {
const bs = [...menu.querySelectorAll("button")], at = bs.indexOf(document.activeElement);
if (e.key === "ArrowDown" || e.key === "ArrowUp") bs[(at + (e.key === "ArrowDown" ? 1 : -1) + bs.length) % bs.length].focus();
else if (e.key === "Escape") closeMenu();
else return;
e.preventDefault(); e.stopPropagation();
});
document.addEventListener("pointerdown", e => { if (!menu.hidden && !menu.contains(e.target)) closeMenu(); }, true);
addEventListener("blur", closeMenu);
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 {}
try {
const n = await (await fetch("/api/notes")).json();
if (Array.isArray(n.notes)) { state.notes = n.notes; renderNote(); }
} catch {}
await refreshTree();
if (state.view === "inbox") showInbox(false);
}
function linked(on) {
if (on) delete root.dataset.link;
else root.dataset.link = "off";
}
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("reload", () => location.reload());
es.addEventListener("agents", ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
setOnline(j.online);
});
es.addEventListener("notes", ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
if (Array.isArray(j.notes)) { state.notes = j.notes; renderNote(); }
});
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);
deskDocs();
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);
deskDocs();
if (opens) {
await showDoc(d.id, true);
toast(d.title, `${d.project} · just now`, null, null, { at: null, face: "whoa" });
}
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);
deskDocs();
});
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();
deskDocs();
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);
deskDocs();
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(); deskDocs(); });
es.addEventListener("desks", () => loadDesks());
es.addEventListener("panes", ev => {
let j; try { j = JSON.parse(ev.data); } catch { return; }
for (const d of state.desks ? state.desks.desks : []) for (const p of d.panes) if (p.id === j.id) p.status = { ...p.status, running: j.running, blocked: j.blocked };
renderDesks();
});
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);
};
}
const EYE = (x, r = 2.6) => `<ellipse class="mk-ink" cx="${x}" cy="16.5" rx="${r}" ry="${+(r * 1.27).toFixed(2)}"/>`;
const SHUT = `<path class="mk-line" d="M8.6 17q2.4 1.8 4.8 0M18.6 17q2.4 1.8 4.8 0"/>`;
const UP = (l = 1, r = 1) => `<path class="mk-line" d="${l ? "M8.6 17.8q2.4-3 4.8 0" : ""}${r ? "M18.6 17.8q2.4-3 4.8 0" : ""}"/>`;
const HEART_EYE = x => `<path class="mk-love" transform="translate(${x} 16.5) scale(.85)" d="M0 3.2c-3.4-2-4.3-4.4-2.6-5.6 1-.7 2.1-.1 2.6.8.5-.9 1.6-1.5 2.6-.8 1.7 1.2.8 3.6-2.6 5.6z"/>`;
const SMILE = `<path class="mk-line" d="M13.5 23q2.5 2.2 5 0"/>`;
const FACES = {
plain: EYE(11) + EYE(21) + SMILE,
glad: UP() + `<path class="mk-ink" d="M13 22.4q3 4 6 0z"/>`,
wink: UP(1, 0) + EYE(21) + SMILE,
love: HEART_EYE(11) + HEART_EYE(21) + SMILE +
`<g class="mk-puff"><path transform="translate(26 8) scale(.5)" d="M0 3.2c-3.4-2-4.3-4.4-2.6-5.6 1-.7 2.1-.1 2.6.8.5-.9 1.6-1.5 2.6-.8 1.7 1.2.8 3.6-2.6 5.6z"/></g>`,
whoa: EYE(11, 3.1) + EYE(21, 3.1) + `<ellipse class="mk-ink" cx="16" cy="23.4" rx="1.9" ry="2.2"/>`,
oops: SHUT + `<path class="mk-line" d="M13.8 23.4h4.4"/>`,
};
const mascotHead = feel => `<svg class="mk" viewBox="0 0 32 32" aria-hidden="true">` +
`<rect class="mk-nub" x="14" y="0.5" width="4" height="5" rx="2"/><rect class="mk-body" x="1" y="4" width="30" height="27" rx="9"/>` +
(FACES[feel] || FACES.plain) + `</svg>`;
const feelFor = t => /^(could not|no |not |nothing|too late|never|failed)\b/i.test(t) || /\bis gone\b/i.test(t) ? "oops" : "plain";
const ACTABLE = "button, a, summary, [role='button'], .ln, .anchor";
let actEl = null, actAt = -1e9;
const actNote = el => { if (el) { actEl = el; actAt = performance.now(); } };
addEventListener("pointerdown", e => actNote(e.target?.closest?.(ACTABLE)), true);
addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") actNote(document.activeElement?.closest?.(ACTABLE)); }, true);
function liveAct() {
if (!actEl || !actEl.isConnected || performance.now() - actAt > 3000) return null;
const r = actEl.getBoundingClientRect();
return r.width && r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth ? actEl : null;
}
const toastsEl = $("#toasts");
let toastAt = null;
function placeToasts(el) {
const s = toastsEl.style, box = 330, gap = 12;
const r = el && el.isConnected ? el.getBoundingClientRect() : null;
const shown = r && r.width && r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth;
toastAt = shown ? el : null;
if (!shown) { s.cssText = ""; delete toastsEl.dataset.side; return; }
const side = r.right + gap + box < innerWidth - 12 && r.left < innerWidth * 0.55 ? "right"
: r.left - gap - box > 12 ? "left" : "below";
s.right = s.left = "auto"; s.bottom = "auto";
if (side === "right") s.left = Math.round(r.right + gap) + "px";
else if (side === "left") s.right = Math.round(innerWidth - r.left + gap) + "px";
else s.left = Math.round(Math.max(12, Math.min(r.left, innerWidth - box - 12))) + "px";
const want = side === "below" ? r.bottom + gap : r.top + r.height / 2 - 21;
s.top = Math.round(Math.max(12, Math.min(want, innerHeight - 96))) + "px";
toastsEl.dataset.side = side;
}
const saidBy = el => el?.classList?.add("said");
let replaceFrame = 0;
const followAnchor = () => {
if (!toastAt || !said) return;
cancelAnimationFrame(replaceFrame);
replaceFrame = requestAnimationFrame(() => { if (toastAt && said) placeToasts(toastAt); });
};
addEventListener("resize", followAnchor);
addEventListener("scroll", followAnchor, true);
let said = null;
function hush() {
if (!said) return;
clearTimeout(said.timer);
said.el.remove();
said.anchor?.classList?.remove("said");
said = null;
}
function toast(title, sub, onClick, action, opts = {}) {
const at = "at" in opts ? opts.at : liveAct();
hush();
placeToasts(at);
const anchor = toastAt;
saidBy(anchor);
const el = document.createElement("div");
el.className = "toast";
el.dataset.feel = opts.face || feelFor(title);
el.innerHTML = `<span class="who">${mascotHead(el.dataset.feel)}</span>` +
`<span class="say"><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(); hush(); action.run(); });
el.appendChild(b);
} else {
el.addEventListener("click", () => { hush(); onClick && onClick(); });
}
toastsEl.appendChild(el);
const life = action ? UNDO_MS : onClick ? 8000 : 3500;
const mine = { el, anchor, timer: 0 };
mine.timer = setTimeout(() => {
el.classList.add("out");
mine.timer = setTimeout(() => { if (said === mine) { hush(); placeToasts(null); } }, 180);
}, life);
said = mine;
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); }
function deskItems(q) {
if (!capability || !state.desks) return [];
const l = q.trim().toLowerCase(), out = [];
if (browsing() && "new desk here".startsWith(l || "n")) {
const p = state.browsePath, dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
out.push({ newdesk: { root: state.browseRoot.id, path: dir }, t: "New desk here", s: state.browseRoot.path + (dir ? "/" + dir : "") });
} else if (l && "new desk".startsWith(l)) out.push({ newdesk: "home", t: "New desk", s: state.desks.home || "~" });
for (const d of state.desks.desks) if (!l || d.name.toLowerCase().includes(l.replace(/^desk\s*/, ""))) out.push({ desk: d.id, t: `Desk · ${d.name}`, s: d.root });
return out;
}
function folderItems(q) {
const l = q.trim().toLowerCase();
if (!capability || !l || !("open folder".startsWith(l) || "folder".startsWith(l) || "browse".startsWith(l))) return [];
return [{ pick: true, t: "Open folder…", s: "The desktop's folder dialog" }];
}
const palRow = (it, i) => `<li class="${i === 0 ? "sel" : ""}" data-i="${i}">` + (
it.t ? `<span class="t">${esc(it.t)}</span><span class="s">${esc(it.s)}</span>`
: it.file ? `<span class="t">${esc(it.file.split("/").pop())}</span><span class="s">${esc(it.file)}</span>`
: `<span class="t">${esc(it.title)}</span><span class="s">${esc(it.project)} · ${esc(it.workflow_title)} · ${rel(it.received_at)}</span>${it.snippet ? `<span class="snip">${it.snippet}</span>` : ""}`) + `</li>`;
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;
}
let items = [];
if (browsing()) {
try { items = (await (await fetch(`/api/browse/${state.browseRoot.id}/find?q=${encodeURIComponent(q)}`)).json()).map(p => ({ file: p })); } catch {}
} else 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 = deskItems(q).concat(folderItems(q), items); palSel = 0;
palList.innerHTML = palItems.map(palRow).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.pick ? pickFolder() : it.line ? gotoLine(it.line) : it.newdesk ? newDesk(it.newdesk === "home" ? null : it.newdesk) : it.desk ? showDesk(it.desk, true)
: 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);
const sysDark = matchMedia("(prefers-color-scheme: dark)");
const isDark = () => root.dataset.theme === "dark" || (!root.dataset.theme && sysDark.matches);
const SUN = '<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="10" cy="10" r="3.5"/><path d="M10 2.5v1.5M10 16v1.5M2.5 10H4M16 10h1.5M4.7 4.7l1.06 1.06M14.24 14.24l1.06 1.06M4.7 15.3l1.06-1.06M14.24 5.76l1.06-1.06"/></svg>';
const MOON = '<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M16.5 12.2A6.8 6.8 0 0 1 7.8 3.5a6.8 6.8 0 1 0 8.7 8.7z"/></svg>';
function paintThemeBtn() {
const b = $("#btn-theme"), dark = isDark();
b.innerHTML = dark ? SUN : MOON;
b.dataset.label = `${dark ? "Dark" : "Light"}${root.dataset.theme ? "" : " (system)"} · click for ${dark ? "light" : "dark"}`;
}
$("#btn-theme").addEventListener("click", () => {
const want = isDark() ? "light" : "dark";
const next = want === (sysDark.matches ? "dark" : "light") ? "" : want;
next ? (root.dataset.theme = next) : delete root.dataset.theme;
store.set("snyvi.theme", next);
toast("Theme", next ? want : `${want}, as the system`, null, null, { face: "wink", at: $("#btn-theme") });
paintThemeBtn();
if (mmd) mmd.retheme();
});
paintThemeBtn();
sysDark.addEventListener("change", () => {
paintThemeBtn();
if (mmd && !root.dataset.theme) mmd.retheme();
});
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", null, null, { at: $("#btn-wrap") });
}
$("#btn-wrap").addEventListener("click", toggleWrap);
$("#btn-wrap").classList.toggle("on", root.dataset.wrap === "1");
const FONTS = [["", "Inter"], ["serif", "Source Serif"], ["literata", "Literata"], ["atkinson", "Atkinson Hyperlegible"], ["mono", "JetBrains Mono"]];
function paintFontBtn() {
const f = FONTS.find(([k]) => k === (root.dataset.font || "")) || FONTS[0];
$("#btn-font").dataset.label = `Font: ${f[1]} · click for the next`;
}
$("#btn-font").addEventListener("click", () => {
const i = FONTS.findIndex(([k]) => k === (root.dataset.font || ""));
const [next, name] = FONTS[(i + 1) % FONTS.length];
next ? (root.dataset.font = next) : delete root.dataset.font;
store.set("snyvi.font", next);
paintFontBtn();
const prose = root.dataset.view !== "desk" && docEl.querySelector(".prose:not(.kind-code):not(.kind-text):not(.kind-diff)");
toast("Font", prose ? name : `${name}, for documents`, null, null, { face: "glad", at: $("#btn-font") });
});
paintFontBtn();
const ACCENTS = [["", "Maroon"], ["crimson", "Crimson"], ["rose", "Rose"], ["violet", "Violet"], ["blue", "Blue"], ["teal", "Teal"], ["green", "Green"], ["graphite", "Graphite"]];
const accBtn = $("#btn-accent");
const accName = k => (ACCENTS.find(([a]) => a === k) || ACCENTS[0])[1];
function paintAccent() {
const i = ACCENTS.findIndex(([k]) => k === (root.dataset.accent || ""));
accBtn.dataset.label = `Accent: ${accName(ACCENTS[i][0])} · click for ${accName(ACCENTS[(i + 1) % ACCENTS.length][0])}`;
}
function paintFavicon() {
const probe = document.createElement("i"), cs = getComputedStyle(probe);
probe.style.cssText = "position:absolute;visibility:hidden";
document.body.append(probe);
const col = v => { probe.style.color = `var(${v})`; const c = cs.color; if (!fitCtx) return c; fitCtx.fillStyle = "#000"; fitCtx.fillStyle = c; return fitCtx.fillStyle; };
const [body, nub, ink] = ["--mascot", "--mascot-nub", "--mascot-ink"].map(col);
probe.remove();
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"><rect x="14" y="0.5" width="4" height="5" rx="2" fill="${nub}"/><rect x="1" y="4" width="30" height="27" rx="9" fill="${body}"/><ellipse cx="11" cy="16.5" rx="2.6" ry="3.3" fill="${ink}"/><ellipse cx="21" cy="16.5" rx="2.6" ry="3.3" fill="${ink}"/><path d="M13.5 23Q16 25.2 18.5 23" fill="none" stroke="${ink}" stroke-width="2.2" stroke-linecap="round"/></svg>`;
const link = $("#favicon");
if (link) link.href = "data:image/svg+xml," + encodeURIComponent(svg);
}
function setAccent(k) {
k ? (root.dataset.accent = k) : delete root.dataset.accent;
store.set("snyvi.accent", k);
paintAccent();
paintFavicon();
if (mmd) mmd.retheme();
}
accBtn.addEventListener("click", () => {
const i = ACCENTS.findIndex(([k]) => k === (root.dataset.accent || ""));
const [next, name] = ACCENTS[(i + 1) % ACCENTS.length];
setAccent(next);
accBtn.classList.remove("flick"); void accBtn.offsetWidth; accBtn.classList.add("flick");
toast("Accent", name, null, null, { face: "glad", at: accBtn });
});
accBtn.addEventListener("animationend", () => accBtn.classList.remove("flick"));
paintAccent();
paintFavicon();
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);
}
const resetSentence = c => `This removes ${plural(c.documents, "document")} in ${plural(c.projects, "project")}, ${c.desks ? plural(c.desks, "desk") + " and their panes, " : ""}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.`;
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 = resetSentence(resetCensus);
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, desks: resetCensus.desks || 0, 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 = resetSentence(j.census); }
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);
const fold = which => { const off = root.dataset[which] !== "0"; root.dataset[which] = off ? "0" : "1"; store.set(`snyvi.${which}`, off ? "0" : "1"); };
$("#btn-rail").addEventListener("click", e => railNarrow.matches ? toggleSheet("rail", e.currentTarget) : fold("rail"));
$("#btn-side").addEventListener("click", e => sideNarrow.matches ? toggleSheet("side", e.currentTarget) : fold("side"));
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") {
if (mmd) mmd.escape();
closePalette(); closeDialog(help); closeDialog(aboutDlg); closeDialog(resetDlg); closeSheet(); closeMenu(); 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.ctrlKey && !e.metaKey && !e.altKey && e.key === "`" && capability) {
e.preventDefault();
swapDesk();
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": if (state.comparing) { state.cache.delete(state.doc.id); showDoc(state.doc.id, false); } else 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 fold("rail");
break;
case "0": if (mmd) mmd.key("0"); break;
case "f": if (mmd) mmd.key("f"); break;
case "\\":
if (sideNarrow.matches) toggleSheet("side");
else fold("side");
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 if (state.view === "desk") { history.replaceState({ desk: boot.desk }, "", location.pathname); showDesk(boot.desk, false); }
else { showInbox(false); history.replaceState({ inbox: true }, "", "/"); }
connect();
loadDesks();
})();