summaryrefslogtreecommitdiff
path: root/vrsite/assets/app.js
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-24 11:39:36 -0300
committerGabriel Schneider <[email protected]>2026-07-30 15:18:55 -0300
commitcb05c363045bf0e7af5858f6d7bc26f026fa9d70 (patch)
treec3e89426ff044ffe339dd3a77e7f3b7115cba636 /vrsite/assets/app.js
downloadnotevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.tar.gz
notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.zip
Diffstat (limited to 'vrsite/assets/app.js')
-rw-r--r--vrsite/assets/app.js181
1 files changed, 181 insertions, 0 deletions
diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js
new file mode 100644
index 0000000..79dc190
--- /dev/null
+++ b/vrsite/assets/app.js
@@ -0,0 +1,181 @@
+// app.js — the client half of vrsite. Everything here works in the static
+// export too; htmx only takes over the parts that need a server (filters,
+// comments, live activity, jj queries).
+"use strict";
+
+const root = document.documentElement;
+const live = root.dataset.live === "1";
+
+// ── focus: trace or notes, never both ─────────────────────────────────────
+// Coverage and notes want the same pixels (line background, gutter color,
+// tree markers), so the whole UI switches between them instead of layering.
+function setMode(m) {
+ root.dataset.mode = m;
+ localStorage.setItem("vrsite-mode", m);
+ for (const b of document.querySelectorAll(".mode"))
+ b.setAttribute("aria-pressed", String(b.dataset.mode === m));
+}
+for (const b of document.querySelectorAll(".mode"))
+ b.addEventListener("click", () => setMode(b.dataset.mode));
+setMode(root.dataset.mode || "trace");
+
+const typing = e =>
+ /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
+
+document.addEventListener("keydown", e => {
+ if (e.metaKey || e.ctrlKey || e.altKey || typing(e)) return;
+ if (e.key === "t") setMode(root.dataset.mode === "notes" ? "trace" : "notes");
+ if (e.key === "/") {
+ const f = document.getElementById("filefind");
+ if (f) { e.preventDefault(); f.focus(); f.select(); }
+ }
+});
+
+// ── theme ─────────────────────────────────────────────────────────────────
+const themesel = document.getElementById("themesel");
+if (themesel) {
+ themesel.value = localStorage.getItem("vrsite-theme") || "system";
+ themesel.addEventListener("change", () => {
+ localStorage.setItem("vrsite-theme", themesel.value);
+ if (themesel.value === "system") delete root.dataset.theme;
+ else root.dataset.theme = themesel.value;
+ });
+}
+
+// ── nav tree ──────────────────────────────────────────────────────────────
+const nav = document.getElementById("nav");
+const tree = document.getElementById("tree");
+const state = JSON.parse(localStorage.getItem("vrsite-tree") || "{}");
+
+if (nav && tree) {
+ const base = nav.dataset.root, cur = nav.dataset.current;
+ fetch(nav.dataset.tree).then(r => r.text()).then(t => {
+ tree.innerHTML = t.replaceAll('href="@@', 'href="' + base);
+ for (const d of tree.querySelectorAll("details"))
+ if (d.dataset.path in state) d.open = !!state[d.dataset.path];
+ if (cur)
+ for (const a of tree.querySelectorAll("a.file"))
+ if (a.dataset.path === cur) {
+ a.setAttribute("aria-current", "page");
+ for (let e = a.parentElement; e && e !== tree; e = e.parentElement)
+ if (e.tagName === "DETAILS") e.open = true;
+ a.scrollIntoView({block: "center"});
+ break;
+ }
+ tree.addEventListener("toggle", e => {
+ if (tree.classList.contains("filtering")) return; // filter drives open state
+ state[e.target.dataset.path] = e.target.open ? 1 : 0;
+ localStorage.setItem("vrsite-tree", JSON.stringify(state));
+ }, true);
+ }).catch(() => {
+ tree.innerHTML = '<p class="meta">tree needs http — use the <a href="' +
+ base + 'files.html">file index</a></p>';
+ });
+}
+
+// ── find a file ───────────────────────────────────────────────────────────
+const find = document.getElementById("filefind");
+if (find && tree) {
+ const apply = () => {
+ const q = find.value.trim().toLowerCase();
+ tree.classList.toggle("filtering", q !== "");
+ for (const a of tree.querySelectorAll("a.file")) {
+ const hit = !q || a.dataset.path.toLowerCase().includes(q);
+ a.closest("li").hidden = !hit;
+ a.classList.toggle("hit", !!q && hit);
+ }
+ for (const d of tree.querySelectorAll("details")) {
+ const any = d.querySelector("li:not([hidden])");
+ d.hidden = !!q && !any;
+ d.open = q ? !!any : !!state[d.dataset.path];
+ }
+ };
+ find.addEventListener("input", apply);
+ find.addEventListener("search", apply);
+ find.addEventListener("keydown", e => {
+ if (e.key === "Escape") { find.value = ""; apply(); find.blur(); }
+ if (e.key === "Enter") {
+ const first = tree.querySelector("li:not([hidden]) a.file");
+ if (first) first.click();
+ }
+ });
+}
+
+// ── code lines: jump to a note, pick a range to comment on ────────────────
+const codeLines = document.querySelectorAll("ol.code li");
+const nstart = document.getElementById("nstart"), nend = document.getElementById("nend");
+let anchor = 0;
+
+function lineNo(li) { return parseInt(li.id.slice(1), 10) || 0; }
+
+function markSelection(a, b) {
+ const lo = Math.min(a, b), hi = Math.max(a, b);
+ for (const li of codeLines) {
+ const n = lineNo(li);
+ li.classList.toggle("sel", n >= lo && n <= hi);
+ }
+ if (nstart) nstart.value = lo;
+ if (nend) nend.value = hi > lo ? hi : "";
+}
+
+for (const li of codeLines) {
+ li.addEventListener("click", e => {
+ const note = li.dataset.note && document.getElementById(li.dataset.note);
+ if (note && root.dataset.mode === "notes") {
+ note.scrollIntoView({behavior: "smooth", block: "center"});
+ note.classList.remove("flash"); void note.offsetWidth; note.classList.add("flash");
+ }
+ if (!live || root.dataset.mode !== "notes") return;
+ const n = lineNo(li);
+ if (e.shiftKey && anchor) markSelection(anchor, n);
+ else { anchor = n; markSelection(n, n); }
+ });
+}
+
+// ⌘/ctrl+enter posts the note form from inside the textarea
+for (const ta of document.querySelectorAll(".notform textarea"))
+ ta.addEventListener("keydown", e => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) ta.form.requestSubmit();
+ });
+
+// ── note filters ──────────────────────────────────────────────────────────
+// Live pages let the server filter (htmx); the static export filters the
+// rendered list in place, so the same form works either way.
+const filters = document.getElementById("notefilters");
+if (filters) {
+ const count = document.getElementById("notecount");
+ const val = n => (filters.elements[n] ? filters.elements[n].value.trim().toLowerCase() : "");
+ const applyLocal = () => {
+ const text = val("text"), file = val("file"), agent = val("agent"),
+ model = val("model"), session = val("session"), kind = val("kind"),
+ from = val("from"), to = val("to");
+ let shown = 0;
+ for (const art of document.querySelectorAll("#notelist .note")) {
+ const d = art.dataset, body = art.querySelector("p").textContent.toLowerCase();
+ const ok =
+ (!text || body.includes(text)) &&
+ (!file || (d.file || "").toLowerCase().includes(file)) &&
+ (!agent || d.agent === filters.elements.agent.value) &&
+ (!model || d.model === filters.elements.model.value) &&
+ (!session || d.session === filters.elements.session.value) &&
+ (!kind || d.kind === filters.elements.kind.value) &&
+ (!from || (d.date && d.date >= from)) &&
+ (!to || (d.date && d.date <= to));
+ art.hidden = !ok;
+ if (ok) shown++;
+ }
+ if (count) count.textContent = shown;
+ };
+ if (!live) {
+ filters.addEventListener("input", applyLocal);
+ filters.addEventListener("change", applyLocal);
+ filters.addEventListener("submit", e => { e.preventDefault(); applyLocal(); });
+ }
+ const clear = document.getElementById("clearfilters");
+ if (clear) clear.addEventListener("click", () => {
+ filters.reset();
+ for (const el of filters.elements) if (el.name) el.value = "";
+ if (live) window.htmx && htmx.trigger(filters, "change");
+ else applyLocal();
+ });
+}