// 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 = '
'; }); } // ── 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(); }); }