diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-24 11:39:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-07-30 15:18:55 -0300 |
| commit | cb05c363045bf0e7af5858f6d7bc26f026fa9d70 (patch) | |
| tree | c3e89426ff044ffe339dd3a77e7f3b7115cba636 /vrsite/assets/app.js | |
| download | notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.tar.gz notevi-cb05c363045bf0e7af5858f6d7bc26f026fa9d70.zip | |
Diffstat (limited to 'vrsite/assets/app.js')
| -rw-r--r-- | vrsite/assets/app.js | 181 |
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(); + }); +} |
