- Drop fields and functions nothing reads: job and proc tips, fetch time, PR url/author (and their GraphQL fields), spine summaries, diff::commit, unread data attributes, unused CSS and tokens. - One home each for state wording (naming main as the repo does), lane lookup, unseen, outdated, main's tip, the stop wait and the token header. - Narrow visibility; rename render::View to Filter; queue ranking moves to the CLI, its only user. - Token test covers every route; shared checkout snapshot in the fixture. - Client: errors outside Run/Send are toasts, history only after a good load, a drag stays in one file, the agent marker is a ring. - design.md, the skill and AGENTS.md describe what is built.
618 lines
25 KiB
JavaScript
618 lines
25 KiB
JavaScript
// The review screen: navigation without reloads, line comments, send, run,
|
||
// and live updates of the rail graph that never move what the user is reading.
|
||
"use strict";
|
||
|
||
const $ = (s, el = document) => el.querySelector(s);
|
||
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
|
||
// Lines of output kept on screen; mirrors the server's buffer (proc::KEEP).
|
||
const KEEP = 2000;
|
||
|
||
let paused = false, pending = null, source = null, logSource = null, logKind = "run", overRail = false;
|
||
// Text of comment boxes closed with esc, by anchor, so nothing typed is lost.
|
||
const kept = new Map();
|
||
|
||
const branch = () => $("#review")?.dataset.ref;
|
||
const params = () => new URLSearchParams(location.search);
|
||
|
||
async function api(path, body) {
|
||
const res = await fetch("/api/" + path, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ b: branch(), ...body }),
|
||
});
|
||
if (res.status === 428) {
|
||
const q = await res.json(), e = new Error(q.question);
|
||
e.status = 428;
|
||
e.commands = q.commands;
|
||
throw e;
|
||
}
|
||
if (!res.ok) { const e = new Error(await res.text()); e.status = res.status; throw e; }
|
||
return res;
|
||
}
|
||
|
||
function toast(text) {
|
||
let t = $("#toast");
|
||
if (!t) { t = document.createElement("div"); t.id = "toast"; document.body.append(t); }
|
||
t.textContent = text;
|
||
t.classList.add("on");
|
||
clearTimeout(t.timer); t.timer = setTimeout(() => t.classList.remove("on"), 3000);
|
||
}
|
||
|
||
// --- navigation ---------------------------------------------------------
|
||
|
||
// Leaving a branch records the tip the user saw, for "since last review".
|
||
// The server picks "since last review" by default; pin it in the address so
|
||
// a later refresh, after the tip is recorded as seen, keeps the same view.
|
||
function pinSince() {
|
||
const since = $("#review")?.dataset.since, u = new URL(location.href);
|
||
if (since && !u.searchParams.has("since") && !u.searchParams.has("whole") && !u.searchParams.has("c")) {
|
||
u.searchParams.set("since", since);
|
||
history.replaceState(null, "", u.search);
|
||
}
|
||
}
|
||
|
||
// fetch with keepalive, not api(): this runs on pagehide.
|
||
function seen() {
|
||
const r = $("#review");
|
||
if (r?.dataset.ref && r.dataset.tip) {
|
||
fetch("/api/seen", { method: "POST", keepalive: true, headers: { "content-type": "application/json" }, body: JSON.stringify({ b: r.dataset.ref, tip: r.dataset.tip }) });
|
||
}
|
||
}
|
||
|
||
async function navigate(href, push = true) {
|
||
const url = new URL(href, location.href);
|
||
if (!url.searchParams.get("b") && branch()) url.searchParams.set("b", branch());
|
||
// The base stays as the user set it while they move between branches.
|
||
if (!url.searchParams.has("base") && params().get("base")) url.searchParams.set("base", params().get("base"));
|
||
if (url.searchParams.get("b") !== branch()) seen();
|
||
// Same branch and range (a refresh, a saved comment): keep the reader's place.
|
||
const before = params();
|
||
const same = ["b", "since", "c", "whole", "base"].every(k => url.searchParams.get(k) === before.get(k));
|
||
const res = await fetch("/view" + url.search);
|
||
if (!res.ok) { toast(await res.text()); return; }
|
||
if (push) history.pushState(null, "", url.search);
|
||
const scroll = $("#review .diff")?.scrollTop ?? 0;
|
||
$("#review").outerHTML = await res.text();
|
||
if (same && $("#review .diff")) $("#review .diff").scrollTop = scroll;
|
||
else if (url.searchParams.get("f")) showFile(url.searchParams.get("f"));
|
||
watchDiff();
|
||
pinSince();
|
||
markRail();
|
||
markBase();
|
||
fixAppLinks();
|
||
if ($("#drawer") && !$("#drawer").hidden && !$("#drawer .panel").hidden) openLog(logKind);
|
||
}
|
||
const refresh = () => navigate(location.href, false);
|
||
|
||
// --- base: the branch the diff compares with ---------------------------
|
||
|
||
// The server may refuse a base (the branch itself, one gone): show its choice.
|
||
function markBase() {
|
||
const sel = $("#base"), used = $("#review")?.dataset.base;
|
||
if (!sel || !used) return;
|
||
sel.value = used;
|
||
sel.closest(".base").classList.toggle("away", used !== sel.dataset.main);
|
||
}
|
||
function setBase(name) {
|
||
const u = new URL(location.href);
|
||
if (name === $("#base")?.dataset.main) u.searchParams.delete("base"); else u.searchParams.set("base", name);
|
||
u.searchParams.delete("since");
|
||
u.searchParams.set("whole", "1");
|
||
history.replaceState(null, "", u.search);
|
||
navigate(location.href, false);
|
||
}
|
||
|
||
// --- a branch label's menu ----------------------------------------------
|
||
|
||
let menuLabel = null;
|
||
async function openMenu(label) {
|
||
const m = $("#refmenu"), q = new URLSearchParams({ ref: label.dataset.ref });
|
||
if (branch()) q.set("b", branch());
|
||
const res = await fetch("/menu?" + q);
|
||
if (!res.ok) { toast(await res.text()); return; }
|
||
m.innerHTML = await res.text();
|
||
m.hidden = false;
|
||
const r = label.getBoundingClientRect(), own = m.getBoundingClientRect();
|
||
m.style.left = `${Math.max(0, Math.min(r.left, innerWidth - own.width - 8))}px`;
|
||
m.style.top = `${Math.max(0, r.bottom + 4 + own.height > innerHeight ? r.top - own.height - 4 : r.bottom + 4)}px`;
|
||
menuLabel = label;
|
||
m.querySelector("[role=menuitem]:not(:disabled)")?.focus();
|
||
}
|
||
// Focus goes back to the label, so the keyboard user is where they were.
|
||
function closeMenu() {
|
||
const m = $("#refmenu");
|
||
if (m.hidden) return;
|
||
m.hidden = true;
|
||
// A graph redraw may have replaced the label: find it by name.
|
||
const name = menuLabel?.dataset.ref;
|
||
if (m.contains(document.activeElement)) $$("#rail .ref").find(r => r.dataset.ref === name)?.focus();
|
||
}
|
||
function stepMenu(delta) {
|
||
const items = $$("#refmenu [role=menuitem]:not(:disabled)");
|
||
const next = step(items, document.activeElement, delta);
|
||
next?.focus();
|
||
}
|
||
async function menuAct(item) {
|
||
const { ref: name, tip } = item.closest(".menu").dataset, act = item.dataset.act;
|
||
closeMenu();
|
||
if (act === "base") return setBase(name);
|
||
if (act === "preview") return openMerge("?" + new URLSearchParams({ merge: name, into: item.dataset.into }));
|
||
try {
|
||
const res = await api(act, { b: name, tip });
|
||
toast(await res.text());
|
||
} catch (err) { toast(err.message); }
|
||
}
|
||
|
||
// --- the diff pane: every file, one scroll -----------------------------
|
||
|
||
function section(path) { return $$("#review .fdiff").find(s => s.dataset.path === path); }
|
||
function showFile(path) {
|
||
const s = section(path);
|
||
if (s) $("#review .diff").scrollTop = s.offsetTop - $("#review .diff").offsetTop;
|
||
markFile(path);
|
||
const u = new URL(location.href);
|
||
u.searchParams.set("f", path);
|
||
history.replaceState(null, "", u.search);
|
||
}
|
||
function markFile(path) {
|
||
$$("#review .files a.file").forEach(a => a.classList.toggle("on", a.dataset.path === path));
|
||
}
|
||
// The stat follows the file at the top of the pane.
|
||
function watchDiff() {
|
||
const pane = $("#review .diff");
|
||
if (!pane) return;
|
||
let queued = false;
|
||
pane.addEventListener("scroll", () => {
|
||
if (queued) return;
|
||
queued = true;
|
||
requestAnimationFrame(() => {
|
||
queued = false;
|
||
const top = pane.scrollTop + pane.offsetTop + 8;
|
||
const current = $$(".fdiff", pane).filter(s => s.offsetTop <= top).pop();
|
||
if (current) markFile(current.dataset.path);
|
||
});
|
||
});
|
||
}
|
||
function stepFile(delta) {
|
||
const all = $$("#review .fdiff");
|
||
const current = all.find(s => s.dataset.path === $("#review .files a.file.on")?.dataset.path);
|
||
const next = step(all, current, delta);
|
||
if (next) showFile(next.dataset.path);
|
||
}
|
||
async function loadDiff(button) {
|
||
const q = new URLSearchParams({ b: branch(), from: button.dataset.from, to: button.dataset.to, f: button.dataset.path });
|
||
const res = await fetch("/file?" + q);
|
||
if (!res.ok) { toast(await res.text()); return; }
|
||
const fresh = new DOMParser().parseFromString(await res.text(), "text/html").querySelector(".fdiff");
|
||
if (fresh) section(button.dataset.path)?.replaceWith(document.importNode(fresh, true));
|
||
}
|
||
|
||
function connect() {
|
||
if (source) source.close();
|
||
const q = new URLSearchParams();
|
||
for (const k of ["all", "q"]) if (params().get(k)) q.set(k, params().get(k));
|
||
if (branch()) q.set("b", branch());
|
||
source = new EventSource("/events?" + q);
|
||
source.addEventListener("rail", e => { pending = e.data; flush(); });
|
||
}
|
||
// The graph waits while the pointer is on it, so rows never move under it.
|
||
function flush() {
|
||
if (pending === null || paused || overRail) return;
|
||
applyRail(pending);
|
||
pending = null;
|
||
}
|
||
// The app a run serves is on the host this page was opened on.
|
||
function fixAppLinks() {
|
||
$$("a.app[data-port]").forEach(a => { a.href = `${location.protocol}//${location.hostname}:${a.dataset.port}/`; });
|
||
}
|
||
// Selection marks follow the review, whatever the server last drew.
|
||
function markRail() {
|
||
$$("#rail .row").forEach(r => r.classList.toggle("on", (r.dataset.lanes || "").split(" ").includes(branch())));
|
||
$$("#rail .ref").forEach(r => r.classList.toggle("sel", r.dataset.ref === branch()));
|
||
const label = $$("#rail .ref").find(r => r.dataset.ref === branch());
|
||
label?.querySelector(".dot")?.remove();
|
||
const moved = $("#review .moved");
|
||
if (label && moved) moved.hidden = label.dataset.tip === $("#review").dataset.tip;
|
||
}
|
||
function applyRail(html) {
|
||
const next = new DOMParser().parseFromString(html, "text/html").querySelector("#rail");
|
||
if (next) $("#rail").replaceWith(document.importNode(next, true));
|
||
markRail();
|
||
}
|
||
|
||
let filterTimer;
|
||
function filterRail() {
|
||
clearTimeout(filterTimer);
|
||
filterTimer = setTimeout(() => {
|
||
const u = new URL(location.href), q = $("#filter").value.trim();
|
||
if (q) u.searchParams.set("q", q); else u.searchParams.delete("q");
|
||
history.replaceState(null, "", u.search);
|
||
connect();
|
||
}, 200);
|
||
}
|
||
|
||
// --- comments -----------------------------------------------------------
|
||
|
||
let drag = null;
|
||
function rowsBetween(a, b) {
|
||
// One file's lines, so a drag cannot span two files.
|
||
const rows = $$(".l[data-n]", a.closest(".lines"));
|
||
if (!rows.includes(b)) return [a];
|
||
const [i, j] = [rows.indexOf(a), rows.indexOf(b)].sort((x, y) => x - y);
|
||
return rows.slice(i, j + 1);
|
||
}
|
||
function select(rows) {
|
||
$$(".lines .l.sel").forEach(r => r.classList.remove("sel"));
|
||
rows.forEach(r => r.classList.add("sel"));
|
||
}
|
||
function openBox(after, anchor) {
|
||
$$(".cbox").forEach(b => b.remove());
|
||
const key = JSON.stringify(anchor);
|
||
const box = document.createElement("form");
|
||
box.className = "cbox";
|
||
const what = anchor.start ? (anchor.start === anchor.end ? `line ${anchor.start}` : `lines ${anchor.start}–${anchor.end}`) : "this file";
|
||
box.innerHTML = `<label><span class="muted small"></span><textarea rows="3"></textarea></label>
|
||
<div class="cfoot"><button type="button" data-cancel>Close · esc</button><button type="submit">Save draft · ⌘↵</button></div>`;
|
||
box.querySelector("span").textContent = "Comment on " + what;
|
||
const ta = box.querySelector("textarea");
|
||
ta.value = kept.get(key) || "";
|
||
box.addEventListener("submit", async e => {
|
||
e.preventDefault();
|
||
if (!ta.value.trim()) return;
|
||
try { await api("comment", { ...anchor, text: ta.value }); kept.delete(key); await refresh(); }
|
||
catch (err) { toast(err.message); }
|
||
});
|
||
box.querySelector("[data-cancel]").addEventListener("click", () => closeBox(box, key, ta));
|
||
ta.addEventListener("keydown", e => {
|
||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); box.requestSubmit(); }
|
||
if (e.key === "Escape") { e.preventDefault(); closeBox(box, key, ta); }
|
||
});
|
||
after.after(box);
|
||
ta.focus();
|
||
}
|
||
function closeBox(box, key, ta) {
|
||
if (ta.value.trim()) kept.set(key, ta.value); else kept.delete(key);
|
||
box.remove();
|
||
select([]);
|
||
}
|
||
|
||
document.addEventListener("mousedown", e => {
|
||
const ln = e.target.closest(".lines .l[data-n] .ln");
|
||
if (!ln) return;
|
||
e.preventDefault();
|
||
drag = { from: ln.parentElement, to: ln.parentElement };
|
||
select([drag.from]);
|
||
});
|
||
document.addEventListener("mouseover", e => {
|
||
if (!drag) return;
|
||
const row = e.target.closest(".lines .l[data-n]");
|
||
if (row) { drag.to = row; select(rowsBetween(drag.from, drag.to)); }
|
||
});
|
||
document.addEventListener("mouseup", () => {
|
||
if (!drag) return;
|
||
const rows = rowsBetween(drag.from, drag.to);
|
||
drag = null;
|
||
const nums = rows.map(r => +r.dataset.n);
|
||
openBox(rows[rows.length - 1], {
|
||
path: rows[0].closest(".lines").dataset.path,
|
||
start: Math.min(...nums), end: Math.max(...nums),
|
||
excerpt: rows.map(r => r.querySelector(".code").textContent).join("\n"),
|
||
});
|
||
});
|
||
|
||
// --- send, run, drawer --------------------------------------------------
|
||
|
||
// The send dialog: the review as it will go out, every text editable.
|
||
// Edits save as you type; copy and send save first, so nothing is lost.
|
||
const dirty = new Map();
|
||
function sheetOpen() { return $("#sheet")?.open; }
|
||
function openSheet() {
|
||
const sheet = $("#sheet");
|
||
if (!sheet) return;
|
||
const open = $$(".cbox textarea").some(t => t.value.trim());
|
||
showTab("review");
|
||
const copy = $("#copybrief");
|
||
delete copy.dataset.copied;
|
||
copy.textContent = "Copy brief";
|
||
$(".openwarn", sheet).hidden = !open;
|
||
$("#dosend").disabled = open;
|
||
copy.disabled = open;
|
||
sheet.showModal();
|
||
$("#instruction").focus();
|
||
}
|
||
// Closing the dialog (button, esc) saves what was typed and redraws the
|
||
// drafts. Started here, not in the async close event, so a caller can wait
|
||
// for the redraw before showing anything in the drawer.
|
||
let afterClose = Promise.resolve();
|
||
function closeSheet() {
|
||
const sheet = $("#sheet");
|
||
if (!sheet.open) return afterClose;
|
||
sheet.close();
|
||
afterClose = saveAll().then(refresh).catch(err => toast(err.message));
|
||
return afterClose;
|
||
}
|
||
function edited(el) {
|
||
clearTimeout(dirty.get(el));
|
||
dirty.set(el, setTimeout(() => saveOne(el), 500));
|
||
}
|
||
function saveOne(el) {
|
||
clearTimeout(dirty.get(el));
|
||
dirty.delete(el);
|
||
return el.id === "instruction" ? api("instruction", { instruction: el.value }) : api("comment/edit", { id: +el.dataset.id, text: el.value });
|
||
}
|
||
async function saveAll() {
|
||
await Promise.all([...dirty.keys()].map(saveOne));
|
||
}
|
||
// The brief as rendered, with the ids and instruction it holds.
|
||
async function fetchBrief() {
|
||
await saveAll();
|
||
const res = await fetch("/api/brief?b=" + encodeURIComponent(branch()));
|
||
if (!res.ok) throw new Error(await res.text());
|
||
return res.json();
|
||
}
|
||
let copied = null;
|
||
async function showTab(tab) {
|
||
$$("#sheet .stabs button").forEach(b => b.classList.toggle("on", b.dataset.tab === tab));
|
||
$$("#sheet [data-pane]").forEach(p => { p.hidden = p.dataset.pane !== tab; });
|
||
if (tab === "md") {
|
||
try { $("#briefmd").textContent = (await fetchBrief()).text; } catch (err) { toast(err.message); }
|
||
}
|
||
}
|
||
async function copyBrief() {
|
||
const b = $("#copybrief");
|
||
if (b.dataset.copied) return markSent();
|
||
try {
|
||
copied = await fetchBrief();
|
||
await navigator.clipboard.writeText(copied.text);
|
||
toast("Brief copied: paste it into your agent session");
|
||
b.dataset.copied = "1";
|
||
b.textContent = "Mark as sent";
|
||
} catch (err) { toast(err.message); }
|
||
}
|
||
async function markSent() {
|
||
try {
|
||
// Exactly what the copied brief held: later edits are still to send.
|
||
await api("sent", { sent: copied.sent, instruction: copied.instruction });
|
||
await closeSheet();
|
||
toast("Marked as sent");
|
||
} catch (err) { await closeSheet(); toast(err.message); }
|
||
}
|
||
|
||
// --- the branch's .branchgraph ----------------------------------------
|
||
|
||
async function openConfig() {
|
||
const res = await fetch("/api/config?b=" + encodeURIComponent(branch()));
|
||
if (!res.ok) return toast(await res.text());
|
||
const c = await res.json();
|
||
$("#config-branch").textContent = branch();
|
||
$("#config-from").textContent = "Read from " + c.from;
|
||
$("#config-path").textContent = c.path ? "Saves " + c.path + " · commit and push it yourself" : "";
|
||
$("#config-text").value = c.text;
|
||
$("#config-error").hidden = true;
|
||
$("#config-dialog").showModal();
|
||
$("#config-text").focus();
|
||
}
|
||
async function saveConfig() {
|
||
let res;
|
||
try { res = await api("config", { text: $("#config-text").value }); } catch (err) {
|
||
const e = $("#config-error"); e.textContent = err.message; e.hidden = false; return;
|
||
}
|
||
$("#config-dialog").close();
|
||
toast("Saved " + (await res.text()) + ": commit it when you are happy");
|
||
refresh();
|
||
}
|
||
async function deleteFromSheet(button) {
|
||
try {
|
||
await api("comment/delete", { id: +button.dataset.sdelete });
|
||
const c = button.closest(".scomment"), file = c.closest(".sfile");
|
||
// A pending save of the deleted text would only fail.
|
||
c.querySelectorAll("textarea").forEach(t => { clearTimeout(dirty.get(t)); dirty.delete(t); });
|
||
c.remove();
|
||
if (!file.querySelector(".scomment")) file.remove();
|
||
} catch (err) { toast(err.message); }
|
||
}
|
||
async function doSend(approve = []) {
|
||
try {
|
||
await saveAll();
|
||
await api("send", { approve });
|
||
await closeSheet();
|
||
logKind = "agent";
|
||
openDrawer();
|
||
toast("Sent to the agent");
|
||
} catch (err) {
|
||
await closeSheet();
|
||
if (err.status === 428) drawerMessage(err.message, "Send it", () => doSend(err.commands));
|
||
else drawerMessage(err.message);
|
||
}
|
||
}
|
||
async function runOrStop(approve = []) {
|
||
const b = $("#run");
|
||
if (!b) return;
|
||
try {
|
||
await (b.dataset.action === "stop" ? api("stop", {}) : api("run", { approve }));
|
||
logKind = "run";
|
||
await refresh();
|
||
openDrawer();
|
||
} catch (err) {
|
||
if (err.status === 428) drawerMessage(err.message, "Run it", () => runOrStop(err.commands));
|
||
else drawerMessage(err.message);
|
||
}
|
||
}
|
||
// Only a refused Run or Send lands here: the reason shows where the output
|
||
// would have been, and a question (a command main does not run) gets a button
|
||
// to go ahead. Other errors are toasts.
|
||
function drawerMessage(text, action, then) {
|
||
const d = $("#drawer");
|
||
if (!d) return toast(text);
|
||
if (logSource) { logSource.close(); logSource = null; }
|
||
d.hidden = false;
|
||
$(".panel", d).hidden = false;
|
||
const log = $("#log");
|
||
log.textContent = text;
|
||
if (action) {
|
||
const go = document.createElement("button");
|
||
go.type = "button";
|
||
go.className = "confirm";
|
||
go.textContent = action;
|
||
go.addEventListener("click", then, { once: true });
|
||
log.append("\n\n", go);
|
||
}
|
||
}
|
||
function openDrawer() {
|
||
const d = $("#drawer");
|
||
d.hidden = false;
|
||
$(".panel", d).hidden = false;
|
||
openLog(logKind);
|
||
}
|
||
function toggleDrawer() {
|
||
const d = $("#drawer");
|
||
if (d.hidden) return openDrawer();
|
||
const panel = $(".panel", d);
|
||
panel.hidden = !panel.hidden;
|
||
if (!panel.hidden) openLog(logKind);
|
||
}
|
||
function openLog(kind) {
|
||
logKind = kind;
|
||
$$("#drawer .tabs button").forEach(b => b.classList.toggle("on", b.dataset.log === kind));
|
||
if (logSource) logSource.close();
|
||
const log = $("#log");
|
||
if (!log) return;
|
||
logSource = new EventSource(`/api/log?b=${encodeURIComponent(branch())}&kind=${kind}`);
|
||
logSource.addEventListener("reset", e => { log.textContent = e.data; log.scrollTop = log.scrollHeight; });
|
||
logSource.addEventListener("line", e => {
|
||
const atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
|
||
log.textContent += (log.textContent ? "\n" : "") + e.data;
|
||
const lines = log.textContent.split("\n");
|
||
if (lines.length > KEEP) log.textContent = lines.slice(-KEEP).join("\n");
|
||
if (atEnd) log.scrollTop = log.scrollHeight;
|
||
// A job's last line is its status: redraw the buttons, unless the user is
|
||
// typing a comment or a send, which a redraw would wipe.
|
||
if (/^\[(done|stopped|exited|failed)/.test(e.data) && !busy()) refresh();
|
||
});
|
||
}
|
||
|
||
function busy() {
|
||
return $$(".cbox textarea").some(t => t.value.trim()) || sheetOpen();
|
||
}
|
||
|
||
async function openMerge(search) {
|
||
const res = await fetch("/panel" + search);
|
||
if (!res.ok) { toast(await res.text()); return; }
|
||
$("#merge-body").innerHTML = await res.text();
|
||
const d = $("#merge-dialog");
|
||
if (!d.open) d.showModal();
|
||
}
|
||
|
||
function togglePause() {
|
||
paused = !paused;
|
||
$("#live").classList.toggle("paused", paused);
|
||
$("#livetext").textContent = paused ? "paused" : "live";
|
||
if (!paused) flush();
|
||
}
|
||
|
||
// --- events -------------------------------------------------------------
|
||
|
||
document.addEventListener("click", async e => {
|
||
const t = e.target;
|
||
// Any click closes the menu, an item's after it is read.
|
||
if (!t.closest("#refmenu") || t.closest("#refmenu [role=menuitem]")) closeMenu();
|
||
const item = t.closest("#refmenu [data-act]");
|
||
if (item) return menuAct(item);
|
||
if (t.closest("#base-reset")) return setBase($("#base").dataset.main);
|
||
// A branch label opens its menu; with a modifier it stays a link.
|
||
const label = t.closest("#rail a.ref");
|
||
if (label && !e.metaKey && !e.ctrlKey && !e.shiftKey) { e.preventDefault(); return openMenu(label); }
|
||
const copy = t.closest("[data-copy]");
|
||
if (copy) { navigator.clipboard.writeText(copy.dataset.copy).then(() => toast("Copied")); return; }
|
||
if (t.closest("#run")) return runOrStop();
|
||
if (t.closest("#send")) return openSheet();
|
||
if (t.closest("#dosend")) return doSend();
|
||
if (t.closest("#sheet [data-close]")) return closeSheet();
|
||
if (t.closest("#config")) return openConfig();
|
||
if (t.closest("#config-save")) return saveConfig();
|
||
if (t.closest("#config-dialog [data-close]")) return $("#config-dialog").close();
|
||
const stab = t.closest("#sheet [data-tab]");
|
||
if (stab) return showTab(stab.dataset.tab);
|
||
if (t.closest("#copybrief")) return copyBrief();
|
||
const sdel = t.closest("[data-sdelete]");
|
||
if (sdel) return deleteFromSheet(sdel);
|
||
if (t.closest("[data-toggle-drawer]") && !t.closest("a")) return toggleDrawer();
|
||
const tab = t.closest("#drawer [data-log]");
|
||
if (tab) return openLog(tab.dataset.log);
|
||
const del = t.closest("[data-delete]");
|
||
if (del) {
|
||
try { await api("comment/delete", { id: +del.dataset.delete }); await refresh(); } catch (err) { toast(err.message); }
|
||
return;
|
||
}
|
||
const fc = t.closest(".filecomment");
|
||
if (fc) return openBox(fc.closest(".fhead"), { path: fc.dataset.path });
|
||
const load = t.closest(".loaddiff");
|
||
if (load) return loadDiff(load);
|
||
const file = t.closest("#review .files a.file");
|
||
if (file) { e.preventDefault(); return showFile(file.dataset.path); }
|
||
if (t.closest("#fetch")) { api("fetch", {}).catch(err => toast(err.message)); toast("Fetching"); return; }
|
||
if (t.closest("#live")) return togglePause();
|
||
if (t.closest("#hidden")) { const u = new URL(location.href); u.searchParams.set("all", "1"); location.href = u; return; }
|
||
const a = t.closest("a[href^='?']");
|
||
if (!a || e.metaKey || e.ctrlKey) return;
|
||
e.preventDefault();
|
||
if (t.closest("#merge-dialog")) return openMerge(a.getAttribute("href"));
|
||
navigate(a.getAttribute("href"));
|
||
});
|
||
|
||
function step(list, current, delta) {
|
||
const i = list.indexOf(current);
|
||
return list[Math.max(0, Math.min(list.length - 1, i < 0 ? 0 : i + delta))];
|
||
}
|
||
|
||
document.addEventListener("keydown", e => {
|
||
// Main's label has no link to follow: Enter opens its menu.
|
||
if (e.key === "Enter" && e.target.matches?.("#rail a.ref:not([href])")) { e.preventDefault(); return openMenu(e.target); }
|
||
if (!$("#refmenu").hidden) {
|
||
const keys = { Escape: closeMenu, ArrowDown: () => stepMenu(1), ArrowUp: () => stepMenu(-1) };
|
||
if (keys[e.key]) { e.preventDefault(); return keys[e.key](); }
|
||
}
|
||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && sheetOpen() && !$("#dosend").disabled) { e.preventDefault(); return doSend(); }
|
||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && $("#config-dialog").open) { e.preventDefault(); return saveConfig(); }
|
||
if (e.target.closest("input, textarea, select") || e.metaKey || e.ctrlKey || e.altKey || $("dialog[open]")) return;
|
||
const rows = $$("#rail .ref[href]");
|
||
const go = (list, cls, d) => { const n = step(list, list.find(x => x.classList.contains(cls)), d); if (n) navigate(n.getAttribute("href")); };
|
||
const act = {
|
||
j: () => go(rows, "sel", 1), k: () => go(rows, "sel", -1),
|
||
"]": () => stepFile(1), "[": () => stepFile(-1),
|
||
r: runOrStop, s: openSheet,
|
||
o: () => branch() && toggleDrawer(),
|
||
m: () => branch() && openMerge("?ref=" + encodeURIComponent(branch())),
|
||
p: togglePause,
|
||
"/": () => $("#filter").focus(),
|
||
"?": () => $("#keys").showModal(),
|
||
}[e.key];
|
||
if (act) { e.preventDefault(); act(); }
|
||
});
|
||
$("#filter").addEventListener("input", filterRail);
|
||
$("#base")?.addEventListener("change", e => setBase(e.target.value));
|
||
// The menu sits under its label; once the label moves away, so does the menu.
|
||
document.addEventListener("scroll", closeMenu, true);
|
||
// Tab out of the menu closes it, as a click elsewhere does.
|
||
$("#refmenu").addEventListener("focusout", e => { if (!$("#refmenu").contains(e.relatedTarget)) closeMenu(); });
|
||
document.addEventListener("input", e => {
|
||
if (e.target.closest("#sheet") && (e.target.id === "instruction" || e.target.classList.contains("stext"))) edited(e.target);
|
||
});
|
||
// Esc closes the dialog without closeSheet().
|
||
document.addEventListener("cancel", e => {
|
||
if (e.target.id === "sheet") { e.preventDefault(); closeSheet(); }
|
||
}, true);
|
||
window.addEventListener("popstate", () => navigate(location.href, false));
|
||
window.addEventListener("pagehide", seen);
|
||
|
||
$("nav").addEventListener("pointerenter", () => { overRail = true; });
|
||
$("nav").addEventListener("pointerleave", () => { overRail = false; flush(); });
|
||
$("#filter").value = params().get("q") || "";
|
||
markRail();
|
||
markBase();
|
||
fixAppLinks();
|
||
watchDiff();
|
||
if (params().get("f")) showFile(params().get("f"));
|
||
pinSince();
|
||
connect();
|