let menu = null;
let picking = false;
export async function pick(ctx, forDesk = false) {
const { capability, state, toast, browseEl } = ctx;
if (!capability) { toast("Folders open from the snyvi window", { sub: "or from a terminal · snyvi browse <folder>", face: null }); return; }
if (picking) { alreadyOpen(ctx); return; }
picking = true;
browseEl.classList.add("picking");
const ac = new AbortController(), gone = () => ac.abort();
addEventListener("pagehide", gone);
try {
await window.__TAURI_INTERNALS__?.invoke("allow_foreground").catch(() => {});
const r = await fetch("/api/browse/pick", { method: "POST", headers: { "x-snyvi-capability": capability }, signal: ac.signal });
if (r.status === 204) return; if (r.status === 409) { alreadyOpen(ctx); return; }
const j = await r.json().catch(() => ({}));
if (!r.ok) { toast("Could not open a folder", { sub: new Error(j.error || `${r.status}`) }); return; }
if (!state.browse.some(x => x.id === j.root.id)) state.browse = state.browse.concat(j.root);
ctx.drawBrowse();
if (forDesk) {
const trim = p => p && p.replace(/(.)\/+$/, "$1");
const had = state.desks && state.desks.desks.find(d => trim(d.root) === trim(j.root.path));
if (had) ctx.show(had.id, true);
else await make(ctx, { root: j.root.id, path: "" }, forDesk === "welcome");
return;
}
const d = browseEl.querySelector(`.b-root[data-root="${j.root.id}"]`);
if (d) d.open = true;
ctx.browse(j.root.id, "", true);
} catch (e) { if (!ac.signal.aborted) toast("Could not open a folder", { sub: e }); }
finally { picking = false; browseEl.classList.remove("picking"); removeEventListener("pagehide", gone); }
}
function alreadyOpen({ capability, toast }) {
toast("A folder dialog is already open", { sub: "It may be behind this window · Alt+Tab to it", face: null,
action: { label: "Cancel it", run: () => fetch("/api/browse/pick/cancel", { method: "POST", headers: { "x-snyvi-capability": capability } }).catch(() => {}) } });
}
export async function make(ctx, f, welcome) {
const { toast, api } = ctx;
try {
const j = await api("/api/desks", !f ? {} : f.project != null ? { project: f.project, name: f.name } : { root: f.root, path: f.path });
const claude = !!f && await hasClaude();
try {
const p = await api(`/api/desks/${j.desk.id}/panes`, claude ? { cmd: "claude", held: true } : {});
if (!claude) await api(`/api/panes/${p.pane.id}/start`, { cmd: "" });
} catch (e) { toast(claude ? "Could not open Claude's panel" : "Could not start the new desk's shell", { sub: e }); }
await ctx.load();
if (welcome && ctx.welcomed) ctx.welcomed(j.desk.id);
else ctx.show(j.desk.id, true);
} catch (e) { toast("Could not make a desk", { sub: e }); }
}
async function hasClaude() {
try {
const a = await (await fetch("/api/agents")).json();
return !!a.claude_on_path || a.rows.some(r => r.id === "claude" && r.state === "connected");
} catch { return false; }
}
export async function projectDesk(ctx, pid) {
const p = ctx.state.tree.find(x => x.id === pid);
if (!p) return;
const trim = s => s && s.replace(/(.)\/+$/, "$1");
const d = ctx.state.desks && ctx.state.desks.desks.find(x => trim(x.root) === trim(p.root));
if (d) ctx.show(d.id, true);
else await make(ctx, { project: pid, name: p.name });
}
export async function drop(ctx, b) {
if (ctx.armed(b, { label: "Close desk?", sub: ends(ctx, +b.dataset.dropdesk) })) await dropDesk(ctx, +b.dataset.dropdesk);
}
function ends(ctx, id) {
const n = ctx.state.desks?.desks.find(d => d.id === id)?.panes.length || 0;
return n ? `Ends ${n} panel${n === 1 ? "" : "s"}` : "Nothing is running on it";
}
function fieldErr(input, why) {
const p = Object.assign(document.createElement("span"), { className: "field-err", textContent: why });
p.setAttribute("role", "alert");
input.after(p);
input.addEventListener("input", () => p.remove(), { once: true });
input.addEventListener("blur", () => p.remove(), { once: true });
}
export function rename(ctx, holder, what, id, typed, why) {
const { toast } = ctx;
const label = holder.querySelector(":scope > .nm");
if (!label || holder.querySelector("input.ren-in")) return;
const before = label.textContent, cls = label.className;
const input = document.createElement("input");
input.className = "ren-in";
input.value = typed ?? before;
input.spellcheck = false; input.autocomplete = "off";
input.setAttribute("aria-label", `Name of this ${what}`);
label.replaceWith(input);
holder.classList.add("renaming");
input.focus(); input.select();
if (why) fieldErr(input, why);
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 ctx.api(`/api/desks/${id}/rename`, { name: next }); await ctx.load(); }
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 ctx.applyRename(what, id);
}
} catch (e) {
label.textContent = before;
if (holder.isConnected) rename(ctx, holder, what, id, next, `Could not rename · ${ctx.sayErr(e).why}`);
else toast(`Could not rename ${before}`, { sub: 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(); });
}
export async function reveal(ctx, body) {
const { toast } = ctx;
try {
await (body.desk != null ? ctx.api("/api/reveal", body) : (async () => {
const r = await fetch("/api/reveal", {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `${r.status}`);
})());
} catch (e) { toast("Could not open the folder", { sub: e }); }
}
function docById(ctx, id) {
const { state } = ctx;
if (state.doc && state.doc.id === id) return state.doc;
const q = state.queue.find(d => d.id === id);
if (q) return q;
for (const wfs of state.sub.values()) for (const w of wfs) for (const d of w.docs) if (d.id === id) return d;
return null;
}
async function pin(ctx, id, at) {
const { state } = ctx;
const d = (state.doc?.id === id && state.doc) || docById(ctx, id) || await fetch(`/api/docs/${id}`).then(r => r.json()).then(j => j.doc).catch(() => null);
if (d) ctx.pin(d, at);
}
function remove(ctx, id, el) {
const x = el && el.querySelector("[data-deldoc]");
if (x) x.click(); else ctx.deleteDoc(docById(ctx, id) || { id, title: ctx.knownDocs.get(id)?.title || "Document" });
}
async function newPanel(ctx, id) {
try {
const p = await ctx.api(`/api/desks/${id}/panes`, {});
await ctx.api(`/api/panes/${p.pane.id}/start`, { cmd: "" });
await ctx.load();
ctx.show(id, true);
} catch (e) { ctx.toast("Could not open a panel", { sub: e }); }
}
async function dropDesk(ctx, id) {
const { state } = ctx;
const name = state.desks?.desks.find(d => d.id === id)?.name || "the desk";
try { await ctx.api(`/api/desks/${id}/delete`, {}); }
catch (e) { ctx.toast("Could not close the desk", { sub: e }); return; }
ctx.forget(id);
await ctx.load();
if (state.view === "desk" && state.deskId === id) ctx.show(null, true);
ctx.toast(`Closed ${name}`, { sub: "Its notes are kept", action: { label: "Undo", run: async () => {
try { await ctx.api(`/api/desks/${id}/reopen`, {}); } catch (e) { ctx.toast("Could not reopen the desk", { sub: e }); return; }
await ctx.load(); ctx.show(id, true);
} } });
}
const RULE = "rule";
async function keepAll(ctx, pid, name) {
let ids = [];
try {
const r = await fetch(`/api/projects/${pid}/tree?workflows=0&docs=0`);
ids = (await r.json()).flatMap(w => w.docs.map(d => d.id));
} catch { ctx.toast("Could not read the documents"); return; }
if (!ids.length) return;
ctx.peerCtx.peer().then(m => m.keepOn(ctx.peerCtx, ids, `everything from ${name.replace(/^From /, "")}`), () => ctx.toast("Could not open that"));
}
const booted = (() => { try { return +JSON.parse(document.getElementById("boot").textContent).friends || 0; } catch { return 0; } })();
const friendsOf = ctx => ctx.state.friends ?? booted;
const ownerOf = sec => sec?.dataset.wsrc === "own" ? +sec.dataset.wdesk : 0;
function widgetItems(ctx, sec) {
if (!sec) return [];
const name = sec.dataset.w, desk = +sec.dataset.wdesk, owner = ownerOf(sec), file = sec.dataset.wsrc === "file";
const offKey = sec.dataset.wsrc === "push" ? desk : owner;
const set = (body, what, then) => async () => {
try { await ctx.api(`/api/widgets/${name}/prefs`, { owner, ...body }); then?.(); }
catch (e) { ctx.toast(`Could not ${what}`, { sub: e }); }
};
return [
owner && { label: "Share with other desks…", run: set({ share: true }, "share it", () => ctx.showSidebars(true)) },
file && desk && { label: "Make it this desk's own", run: set({ keep_here: desk }, "make it this desk's") },
file && desk && { label: "Not on this desk", run: set({ not_desk: desk }, "take it off this desk") },
(file || owner) && { label: "Open its folder", run: () => reveal(ctx, { widget: name, owner }) },
{ label: "Turn off", run: set({ hidden: true, owner: offKey }, "turn it off") },
RULE,
].filter(Boolean);
}
function removeItem(ctx, sec) {
const name = sec?.dataset.w, desk = +sec?.dataset.wdesk;
if (!desk || !ctx.desk?.seatOf?.(desk, name)?.agent) return [];
return [RULE, { label: "Remove", danger: true, run: () => removeWidget(ctx, name, desk, ownerOf(sec)) }];
}
async function removeWidget(ctx, name, desk, owner) {
ctx.desk.widgetGone(desk, name, owner);
try { const j = await ctx.api(`/api/widgets/${name}/remove`, { desk, owner }); ctx.desk.widgetKept(name, j.from); }
catch (e) { ctx.desk.widgetBack(name); ctx.toast(`Could not remove ${name}`, { sub: e }); }
}
function entries(ctx, el, byKey = false) {
const { capability } = ctx, copyIt = (text, what) => ({ label: what, run: at => ctx.copied(text, at) });
const files = body => ({ label: "Open in file manager", run: () => reveal(ctx, body) });
if (el.matches(".nv-b")) return { head: "Where you've been", items: ctx.navList() };
if (el.matches(".sec-head")) return { head: el.querySelector(".sec-nm")?.textContent || "Sidebars", items: [
...widgetItems(ctx, el.closest("section.wg")),
{ label: "Arrange sidebars…", moves: 1, run: () => ctx.showSidebars(true) },
...removeItem(ctx, el.closest("section.wg")),
] };
if (el.matches(".brand-mark")) return { head: "snyvi", items: [
{ label: "Check for updates", run: at => ctx.checkUpdates(at) },
{ label: document.documentElement.dataset.mascot === "quiet" ? "Lively mascot" : "Quiet mascot", run: () => ctx.toggleQuiet() },
] };
if (el.matches(".b-root > summary, .b-dir > details > summary")) {
const f = ctx.folderOf(el);
if (!f) return null;
const here = ctx.state.desks ? ctx.state.desks.desks.filter(d => d.root === f.abs) : [];
const root = el.matches(".b-root > summary");
return { head: f.abs.split("/").pop() || f.abs, items: [
capability && { label: "New desk here", run: () => make(ctx, f) },
...(capability ? here.map(d => ({ label: `Show desk ${d.name}`, moves: 1, run: () => ctx.show(d.id, true) })) : []),
capability && RULE,
files({ root: f.root, path: f.path }), copyIt(f.abs, "Copy path"),
root && RULE, root && { label: "Close folder", danger: true, run: () => ctx.closeRoot(f.root) },
] };
}
if (el.matches("a[data-browse]")) {
const root = el.dataset.browse, path = el.dataset.path || "", r = ctx.state.browse.find(x => x.id === root), abs = r ? r.path + (path ? "/" + path : "") : "";
return { head: path.split("/").pop() || abs, items: [
{ label: "Open", moves: 1, run: () => ctx.browse(root, path, true) }, RULE,
files({ root, path }), abs && copyIt(abs, "Copy path"),
] };
}
if (el.matches(".t-proj > summary")) {
const pid = +el.parentElement.dataset.pid, p = ctx.state.tree.find(x => x.id === pid);
if (!p) return null;
if (p.friend) return { head: p.name, items: [
{ label: "Rename…", key: "F2", moves: 1, run: () => rename(ctx, el, "project", pid) },
capability && ctx.peerCtx && { label: "Keep all on a desk…", run: () => keepAll(ctx, pid, p.name) }, RULE,
{ label: "Remove from the sidebar", danger: true, run: () => ctx.putAway(pid) },
] };
const here = capability && p.root && ctx.state.desks ? ctx.state.desks.desks.filter(d => d.root === p.root) : [];
return { head: p.name, items: [
capability && p.root && { label: "New desk here", run: () => make(ctx, { project: pid, name: p.name }) },
...here.map(d => ({ label: `Show desk ${d.name}`, moves: 1, run: () => ctx.show(d.id, true) })),
{ label: "Rename…", key: "F2", moves: 1, run: () => rename(ctx, el, "project", pid) }, RULE,
files({ project: pid }), p.root && copyIt(p.root, "Copy path"), RULE,
{ label: "Remove from the sidebar", danger: true, run: () => ctx.putAway(pid) },
] };
}
if (el.matches("a[data-id]")) {
const id = el.dataset.id, d = docById(ctx, id);
const pid = +el.closest(".t-proj")?.dataset.pid, theirs = !!ctx.state.tree.find(x => x.id === pid)?.friend || d?.origin === "peer";
const path = d && (d.local_path || (!theirs && d.source_path));
const friends = friendsOf(ctx) >= (theirs ? 2 : 1);
return { head: d ? d.title : el.querySelector(".title")?.textContent || "Document", items: [
{ label: "Open", moves: 1, run: () => ctx.open(id) },
{ label: d && d.pinned ? "Unpin" : "Pin", key: "p", run: at => pin(ctx, id, at) },
theirs && capability && ctx.peerCtx && (d?.desk
? { label: "Save into the folder", run: () => ctx.peerCtx.peer().then(m => m.save(ctx.peerCtx, id), () => ctx.toast("Could not save it")) }
: { label: "Keep on a desk…", run: () => ctx.peerCtx.peer().then(m => m.keepOn(ctx.peerCtx, [id], d ? d.title : ""), () => ctx.toast("Could not open that")) }),
ctx.peerCtx && friends && { label: "Send to a friend…", run: () => ctx.peerCtx.peer().then(m => m.send(ctx.peerCtx, id, d ? d.title : ""), () => ctx.toast("Could not open Send to…")) }, RULE,
path && copyIt(path, "Copy path"), copyIt(`${location.origin}/d/${id}`, "Copy link"),
!theirs && files({ doc: id }), RULE,
{ label: "Remove", key: "Del", danger: true, run: () => remove(ctx, id, el) },
] };
}
if (el.matches("a[data-desk], [data-desk-menu]")) {
const here = el.matches("[data-desk-menu]"), dk = here && ctx.desk;
const id = +(el.dataset.desk || el.dataset.deskMenu), d = ctx.state.desks && ctx.state.desks.desks.find(x => x.id === id);
if (!d || !capability) return null;
return { head: d.name, items: [
!here && { label: "Show", run: () => ctx.show(id, true) },
d.panes.length < ctx.state.desks.per_desk && { label: "New panel", key: here ? ctx.keyHint("ctrl+alt+n") : "", run: () => newPanel(ctx, id) },
dk && d.panes.some(p => !(p.status && p.status.running)) && { label: "Start all", run: () => dk.startAll() },
dk && d.panes.length && { label: dk.isFull() ? "Back to the grid" : "Full view", key: ctx.keyHint("ctrl+alt+z"), run: () => dk.zoomOn() },
{ label: "Rename…", key: here ? "" : "F2", moves: 1, run: () => dk ? dk.renameHere() : rename(ctx, el, "desk", id) },
dk && { label: "Keys…", run: () => dk.keysHere() },
RULE,
...(here ? [] : moves(ctx, id, byKey)),
files({ desk: id }), copyIt(d.root, "Copy path"), RULE,
{ label: "Close desk", danger: true, sure: ends(ctx, id), run: () => dropDesk(ctx, id) },
] };
}
if (el.matches("[data-newdesk]:not(.b-new), [data-a=make]")) {
if (!capability) return null;
const places = recent(ctx), list = everyPlace ? places : places.slice(0, FEW);
everyPlace = false;
return { head: "New desk in…", items: [
...list.map(f => ({ label: f.name, hint: f.hint, moves: 1, run: () => make(ctx, f) })),
list.length < places.length && { label: `More folders… (${places.length - FEW})`, moves: 1, run: () => { everyPlace = true; reopen(ctx, FEW); } },
places.length && RULE,
{ label: "Another folder…", run: () => pick(ctx, true) },
{ label: "A shell in your home folder", moves: 1, run: () => make(ctx, null) },
] };
}
return ctx.desk && ctx.desk.actions ? ctx.desk.actions(el) : null;
}
const FEW = 6;
let everyPlace = false;
function recent(ctx) {
const { tree, browse, desks } = ctx.state;
const when = f => f.project != null ? tree.find(p => p.id === f.project)?.latest || 0 : browse.find(r => r.id === f.root)?.opened_at || 0;
const out = ctx.places().map(f => ({ ...f, at: when(f) })).sort((a, b) => b.at - a.at);
const slash = p => (p || "").replace(/\\/g, "/").replace(/(.)\/+$/, "$1");
const home = slash(desks && desks.home), seen = {};
for (const f of out) seen[f.name] = (seen[f.name] || 0) + 1;
for (const f of out) if (seen[f.name] > 1) {
const up = slash(f.abs).split("/").slice(0, -1).join("/");
const rel = home && up === home ? "~" : home && up.startsWith(home + "/") ? up.slice(home.length + 1) : up;
f.hint = rel.split("/").slice(-2).join("/");
}
return out;
}
async function order(ctx, ids, moved, byKey) {
const ds = ctx.state.desks;
if (!ds) return;
const was = ds.desks, by = new Map(was.map(d => [d.id, d]));
if (ids.join() === was.map(d => d.id).join() || ids.some(id => !by.has(id))) return;
ds.desks = ids.map(id => by.get(id));
ctx.drawDesks();
if (byKey) ctx.focusDesk(moved);
try { await ctx.api("/api/desks/order", { ids }); }
catch (e) { ds.desks = was; ctx.deskSaid(moved, "Couldn't move it", e); if (byKey) ctx.focusDesk(moved); }
}
export async function allowWidget(ctx, b) {
b.disabled = true;
try { await ctx.api(`/api/widgets/${b.dataset.wallow}/allow`, { owner: ownerOf(b.closest("section.wg")) }); b.textContent = "Allowed"; }
catch (e) {
b.disabled = false;
if (e?.status === 403) ctx.toast("Allow works in the snyvi window", { sub: "A tab cannot let a command run" });
else ctx.toast(`Not allowed: ${e?.message || e}`);
}
}
export async function seatAct(ctx, b) {
if (b.dataset.gal) return (await import(`/assets/gallery.js${new URL(import.meta.url).search}`)).show(ctx, b);
const name = b.dataset.wretry || b.dataset.wnot || b.dataset.wtell || b.dataset.wundo, desk = +b.dataset.wd, owner = ownerOf(b.closest("section.wg"));
b.disabled = true;
if (b.dataset.wundo) {
try { await ctx.api(`/api/widgets/${name}/restore`, { desk, owner: +b.dataset.wown, from: b.dataset.wfrom || "" }); ctx.desk?.widgetBack?.(name, +b.dataset.wown !== 0); }
catch (e) { b.disabled = false; ctx.toast(`Could not bring ${name} back`, { sub: e }); }
return;
}
try {
if (b.dataset.wtell) {
const j = await ctx.api(`/api/widgets/${name}/tell`, { desk });
b.textContent = j.told ? "Told · on its next prompt" : "No agent proposed it";
return;
}
if (b.dataset.wretry) await ctx.api(`/api/widgets/${name}/retry`, { desk, owner });
else await ctx.api(`/api/widgets/${name}/prefs`, { not_desk: desk, owner });
b.textContent = b.dataset.wretry ? "Running…" : "Turned off";
} catch (e) { b.disabled = false; b.textContent = "Could not · again"; b.dataset.tip = e?.message || ""; }
}
export function seatOpen(ctx, x) {
const o = x.classList.toggle("open"), b = x.lastElementChild;
if (b?.matches(".wg-more")) { b.ariaExpanded = o; b.textContent = o ? "less" : "more"; }
}
export function moveDesk(ctx, id, by, byKey = false) {
const ids = (ctx.state.desks ? ctx.state.desks.desks : []).map(d => d.id), i = ids.indexOf(id);
const to = by === "top" ? 0 : Math.max(0, Math.min(ids.length - 1, i + by));
if (i < 0 || to === i) return;
ids.splice(i, 1); ids.splice(to, 0, id);
return order(ctx, ids, id, byKey);
}
function moves(ctx, id, byKey) {
const ids = ctx.state.desks.desks.map(d => d.id), i = ids.indexOf(id), last = ids.length - 1;
return [
i > 1 && { label: "Move to top", run: () => moveDesk(ctx, id, "top", byKey) },
i > 0 && { label: "Move up", key: ctx.keyHint("alt+↑"), run: () => moveDesk(ctx, id, -1, byKey) },
i < last && { label: "Move down", key: ctx.keyHint("alt+↓"), run: () => moveDesk(ctx, id, 1, byKey) },
ids.length > 1 && RULE,
];
}
export function dragDesk(ctx, a, e) {
const rows = [...a.closest(".t-desks").querySelectorAll(".t-desk > a[data-desk]")];
if (rows.length < 2 || e.button !== 0) return;
const id = +a.dataset.desk, x0 = e.clientX, y0 = e.clientY;
let line = null, at = -1;
const move = ev => {
if (!line) {
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) < 6) return;
line = document.createElement("div");
line.className = "t-drop";
document.body.append(line);
a.classList.add("dragging");
document.documentElement.classList.add("desk-drag");
}
at = rows.findIndex(r => { const b = r.getBoundingClientRect(); return ev.clientY < b.top + b.height / 2; });
if (at < 0) at = rows.length;
const b = rows[Math.min(at, rows.length - 1)].getBoundingClientRect();
line.style.cssText = `left:${b.left}px;width:${b.width}px;top:${(at < rows.length ? b.top : b.bottom) - 1}px`;
};
const end = go => {
removeEventListener("pointermove", move);
removeEventListener("pointerup", up);
removeEventListener("pointercancel", off);
removeEventListener("keydown", key, true);
if (!line) return;
line.remove();
a.classList.remove("dragging");
document.documentElement.classList.remove("desk-drag");
const eat = c => { c.preventDefault(); c.stopPropagation(); };
addEventListener("click", eat, { capture: true, once: true });
setTimeout(() => removeEventListener("click", eat, true), 0);
if (!go) return;
const ids = rows.map(r => +r.dataset.desk), from = ids.indexOf(id);
ids.splice(from, 1);
ids.splice(at > from ? at - 1 : at, 0, id);
order(ctx, ids, id, false);
};
const up = () => end(true), off = () => end(false);
const key = k => { if (k.key === "Escape" && line) { k.preventDefault(); k.stopPropagation(); end(false); } };
addEventListener("pointermove", move);
addEventListener("pointerup", up);
addEventListener("pointercancel", off);
addEventListener("keydown", key, true);
}
export function open(ctx, el, x, y, byKey = false) {
const m = entries(ctx, el, byKey);
const items = m ? m.items.filter(Boolean).filter((e, i, a) => e !== RULE || (i > 0 && a[i - 1] !== RULE && a.slice(i + 1).some(x => x !== RULE))) : [];
if (!items.some(e => e !== RULE)) return false;
if (!menu) install(ctx);
shown = items;
opener = byKey ? document.activeElement : el.closest("a[href], button, summary, [tabindex]") || el;
asked = { el, x, y, byKey, opener };
menu.style.maxHeight = "";
menu.innerHTML = `<div class="ctx-head">${ctx.esc(m.head)}</div>` + items.map((e, i) => e === RULE ? `<hr role="separator">`
: `<button type="button" role="menuitem" data-i="${i}"${e.danger ? ' class="danger"' : ""}><span>${ctx.esc(e.label)}</span>${e.hint ? `<small>${ctx.esc(e.hint)}</small>` : ""}${e.key ? `<kbd>${ctx.esc(e.key)}</kbd>` : ""}</button>`).join("");
menu.hidden = false;
const w = menu.offsetWidth, h = menu.offsetHeight;
menu.style.left = Math.max(4, Math.min(x, innerWidth - w - 8)) + "px";
menu.style.top = (y + h <= innerHeight - 8 ? y : Math.max(4, y - h)) + "px";
menu.querySelector("button").focus({ preventScroll: true });
return true;
}
let shown = [], opener = null;
let asked = null;
function reopen(ctx, at) {
const was = asked, top = menu.style.top;
if (!was || !open(ctx, was.el, was.x, was.y, was.byKey)) return;
opener = was.opener; asked.opener = was.opener;
menu.style.top = top;
menu.style.maxHeight = `${innerHeight - 8 - parseFloat(top)}px`;
const b = menu.querySelector(`[data-i="${at}"]`);
if (b) { b.focus({ preventScroll: true }); b.scrollIntoView({ block: "nearest" }); }
}
const close = (back = false) => {
if (!menu || menu.hidden) return;
menu.hidden = true; shown = [];
if (back && opener && opener.isConnected) opener.focus({ preventScroll: true });
opener = null;
};
const CSS = `
/* The folded sidebar's popover: it only ever opens through pop() below. */
#pop { position: fixed; left: 50px; top: 8px; z-index: var(--z-pop); width: var(--side-w); max-height: calc(100vh - 16px); overflow: auto; overscroll-behavior: contain;
padding: 2px 8px 8px; background: var(--bg-raise); border: 1px solid var(--rule); border-radius: var(--r-sm); box-shadow: var(--shadow);
animation: sheet-l var(--dur-quick) var(--ease-out); }
#pop > :first-child .sec-head { margin-top: 4px; }
/* Lately is read, not navigated: it opens wider than the sections, as On
you's tray does once it is opened, so a title is one line more often than
two; and shorter than the window, so the page shows above it and its end
is a row or a fold, never a row the window's edge cut through. */
#pop:has(> #lately-nav) { width: 360px; max-height: min(70vh, 560px, calc(100vh - var(--foot-h) - 20px)); padding: 0 0 6px; }
@media (max-width: 760px) { #pop { inset: 0 auto 0 44px; top: 0 !important; width: min(88vw, var(--side-w)); max-height: none; border-radius: 0; border-width: 0 1px 0 0; } #pop:has(> #lately-nav) { width: min(88vw, 360px); max-height: none; } }
#ctx { position: fixed; z-index: var(--z-pop); min-width: 200px; max-width: 320px; max-height: calc(100vh - 16px); overflow-y: auto; padding: 4px; background: var(--bg-raise); border: 1px solid var(--rule); border-radius: var(--r-sm); box-shadow: var(--shadow); font-size: var(--fs-ui); transform-origin: 0 0; animation: ctx-in var(--dur-instant) ease-out; }
@keyframes ctx-in { from { opacity: 0; transform: scale(.97); } }
@media (prefers-reduced-motion: reduce) { #ctx { animation: none; } }
/* What the menu acts on, named at its top: a right-click in a busy grid
says which panel it meant. */
#ctx .ctx-head { padding: 4px 10px 5px; font-size: var(--fs-small); color: var(--fg-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-bottom: 1px solid var(--rule); margin-bottom: 4px; }
#ctx button { display: flex; align-items: baseline; gap: 16px; width: 100%; text-align: left; padding: 5px 10px; border-radius: var(--r-sm); color: var(--fg-2); }
#ctx button > span { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Where a place is, when its name alone does not say: dim, and the first
to give way when the row is narrow. */
#ctx button small { flex: 0 1 auto; min-width: 0; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-small); color: var(--fg-3); }
#ctx button kbd { flex: none; min-width: 0; color: var(--fg-3); background: none; border: 0; padding: 0; }
#ctx button:hover { background: var(--rule); }
#ctx button:focus { background: var(--accent-bg); color: var(--accent); outline: none; }
#ctx button.danger { color: var(--danger); }
#ctx button.danger:hover, #ctx button.danger:focus, #ctx button[data-armed] { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); }
#ctx hr { border: 0; border-top: 1px solid var(--rule); margin: 4px 2px; }
`;
let styled = false;
function sheet() {
if (styled) return;
styled = true;
const st = document.createElement("style");
st.textContent = CSS;
document.head.append(st);
}
function install(ctx) {
sheet();
menu = document.createElement("div");
menu.id = "ctx"; menu.hidden = true; menu.setAttribute("role", "menu");
document.body.append(menu);
menu.addEventListener("click", e => {
const b = e.target.closest("[data-i]"), it = b && shown[+b.dataset.i];
if (!it) return;
if (it.sure && !ctx.armed(b, { label: `${it.label}?`, sub: it.sure, text: b.firstElementChild })) return;
const at = b.getBoundingClientRect();
close(!it.moves);
Promise.resolve().then(() => it.run(at)).catch(err => ctx.toast(`Could not ${it.label.replace(/…$/, "").replace(/^./, c => c.toLowerCase())}`, { sub: err, at }));
});
menu.addEventListener("pointermove", e => { const b = e.target.closest("button"); if (b && b !== document.activeElement) b.focus({ preventScroll: true }); });
menu.addEventListener("keydown", e => {
const bs = [...menu.querySelectorAll("button")], at = bs.indexOf(document.activeElement);
const go = i => bs[(i + bs.length) % bs.length].focus();
if (e.key === "ArrowDown") go(at + 1);
else if (e.key === "ArrowUp") go(at - 1);
else if (e.key === "Home") go(0);
else if (e.key === "End") go(-1);
else if (e.key === "Escape") close(true);
else if (e.key === "Tab") close(true);
else if (e.key.length === 1 && /\S/.test(e.key)) {
const k = e.key.toLowerCase(), n = bs.length;
for (let j = 1; j <= n; j++) { const b = bs[(at + j) % n]; if (b.textContent.trim().toLowerCase().startsWith(k)) { b.focus(); break; } }
} else return;
e.preventDefault(); e.stopPropagation();
});
document.addEventListener("pointerdown", e => { if (!menu.hidden && !menu.contains(e.target)) close(); }, true);
addEventListener("blur", () => close());
addEventListener("resize", () => close());
}
const $ = s => document.querySelector(s), root = document.documentElement;
const POPS = { inbox: ["#inbox-row"], tree: ["#tree"], desks: ["#desk-nav"], browse: ["#browse-nav"], widgets: ["#widgets-nav"], lately: ["#lately-nav"] };
const home = () => ($("#trees").dataset.order || "#inbox-row #tree #desk-nav #browse-nav #widgets-nav").split(" ");
let popBtn = null, popWired = false;
let popKey = false;
addEventListener("keydown", () => { popKey = true; }, true);
addEventListener("pointerdown", () => { popKey = false; }, true);
export function pop(ctx, sec, btn) {
sheet();
const popEl = $("#pop");
if (!popWired) wirePop(popEl);
if (root.dataset.pop === sec) { unpop(); return; }
unpop(false);
root.dataset.pop = sec; popBtn = btn;
btn.classList.add("on"); btn.setAttribute("aria-expanded", "true");
popEl.setAttribute("aria-label", btn.getAttribute("aria-label"));
popEl.append(...POPS[sec].map(id => $(id)));
popEl.hidden = false;
if (sec === "tree") ctx.drawTree();
const side = root.dataset.side === "0" ? null : $("#side").getBoundingClientRect();
const fromFoot = sec === "lately" && side && btn.closest?.(".side-foot");
popEl.style.left = side ? Math.round(fromFoot ? side.left + 8 : side.right + 6) + "px" : "";
const r = btn.getBoundingClientRect(), h = popEl.offsetHeight;
const foot = sec === "lately" && $(".side-foot")?.getBoundingClientRect();
const floor = foot && foot.height ? foot.top - 6 : innerHeight - 8;
popEl.style.top = Math.round(Math.max(8, Math.min(r.top - 8, floor - h))) + "px";
popEl.classList.toggle("ring-quiet", !popKey);
((sec === "lately" && popEl.querySelector(".cn-list button.cn-t")) || popEl.querySelector("a[aria-current], a[href], button, summary, [tabindex]") || popEl).focus({ preventScroll: true });
}
export function unpop(back = true) {
const sec = root.dataset.pop, popEl = $("#pop"), treesEl = $("#trees");
if (!sec) return false;
delete root.dataset.pop;
popEl.hidden = true;
for (const id of POPS[sec]) {
const order = home(), after = order.slice(order.indexOf(id) + 1).map(s => $(s)).find(el => el.parentElement === treesEl);
treesEl.insertBefore($(id), after || popEl);
}
const b = popBtn; popBtn = null;
b?.classList.remove("on"); b?.setAttribute("aria-expanded", "false");
if (back && b?.isConnected) b.focus({ preventScroll: true });
return true;
}
let sheetBack = null;
export function openSheet(ctx, which, opener) {
if (root.dataset.sheet === which) return;
sheetBack = opener;
root.dataset.sheet = which;
ctx.keepCurInView(true);
const toc = $("#toc");
(toc.querySelector("a.cur") || toc.querySelector("a") || $("#meta").querySelector("button, a") || $("#rail")).focus({ preventScroll: true });
}
export function closeSheet() {
if (!root.dataset.sheet) return false;
delete root.dataset.sheet;
const back = sheetBack; sheetBack = null;
if (back && back.isConnected && back !== document.body) back.focus({ preventScroll: true });
return true;
}
function wirePop(popEl) {
popWired = true;
const railNav = $("#rail-nav");
popEl.addEventListener("keydown", e => {
popEl.classList.remove("ring-quiet");
if (e.target.closest("input") || e.ctrlKey || e.metaKey || e.altKey) return;
const bs = [...popEl.querySelectorAll("a[href], button, summary")].filter(x => x.offsetParent), at = bs.indexOf(document.activeElement), n = bs.length;
const go = i => bs[(i + n) % n]?.focus();
if (e.key === "ArrowDown") go(at + 1);
else if (e.key === "ArrowUp") go(at - 1);
else if (e.key === "Home") go(0);
else if (e.key === "End") go(-1);
else if (/^[a-z0-9]$/i.test(e.key)) {
const k = e.key.toLowerCase();
for (let j = 1; j <= n; j++) { const b = bs[(at + j) % n]; if (b.textContent.trim().toLowerCase().startsWith(k)) { b.focus(); break; } }
} else return;
e.preventDefault(); e.stopPropagation();
});
popEl.addEventListener("focusout", e => { const t = e.relatedTarget; if (t && !popEl.contains(t) && !t.closest("#ctx")) unpop(false); });
popEl.addEventListener("click", e => { if (e.target.closest("a[href]")) queueMicrotask(() => unpop()); });
document.addEventListener("pointerdown", e => { if (root.dataset.pop && !popEl.contains(e.target) && !railNav.contains(e.target) && !e.target.closest?.("#ctx, #btn-lately")) unpop(false); }, true);
}
export { close as shut };