summaryrefslogtreecommitdiff
path: root/web/assets/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'web/assets/app.js')
-rw-r--r--web/assets/app.js461
1 files changed, 461 insertions, 0 deletions
diff --git a/web/assets/app.js b/web/assets/app.js
new file mode 100644
index 0000000..5dd6ca0
--- /dev/null
+++ b/web/assets/app.js
@@ -0,0 +1,461 @@
+// app.js — the client half of notevi web. 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("notevi-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;
+
+// The project hub is fully server-derived; filtering is deliberately local so
+// choosing a project does not create another piece of server state.
+const projectFind = document.getElementById("projectfind");
+if (projectFind) {
+ const apply = () => {
+ const q = projectFind.value.trim().toLowerCase();
+ for (const card of document.querySelectorAll(".project-card"))
+ card.hidden = !!q && !(card.dataset.project || "").toLowerCase().includes(q);
+ };
+ projectFind.addEventListener("input", apply);
+ projectFind.addEventListener("search", apply);
+ projectFind.addEventListener("keydown", e => {
+ if (e.key === "Escape") { projectFind.value = ""; apply(); projectFind.blur(); }
+ if (e.key === "Enter") {
+ const first = document.querySelector(".project-card:not([hidden]) a.project-action");
+ if (first) first.click();
+ }
+ });
+}
+
+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 ─────────────────────────────────────────────────────────────────
+// Every theme this machine has is a :root block in themes.css, so wearing one
+// is a single attribute — and the menu can therefore preview as it moves.
+// Arrow through it or hover and the page changes under you; enter keeps what
+// you are wearing, escape puts back the one you came with.
+//
+// themes.css and themes.js are fetched the first time the menu opens (the
+// stylesheet earlier than that, from the head, when a theme is already
+// chosen), so a reader who never switches pays nothing for them.
+const pick = document.getElementById("themepick");
+if (pick) {
+ const btn = document.getElementById("themebtn"),
+ menu = document.getElementById("thememenu"),
+ find = document.getElementById("themefind"),
+ list = document.getElementById("themelist");
+ const builtin = [
+ {id: "system", name: "system", src: "follows the browser"},
+ {id: "dark", name: pick.dataset.dark, src: "built with · dark"},
+ {id: "light", name: pick.dataset.light, src: "built with · light"},
+ ];
+ let cur = localStorage.getItem("notevi-theme") || "system";
+ let active = null, filled = false, pending = null;
+
+ const wear = id => {
+ if (id === "system") delete root.dataset.theme;
+ else root.dataset.theme = id;
+ };
+ // the name of a theme we may not have the menu for yet: kept beside the id
+ // so the button says something true before themes.js has loaded
+ const label = id => {
+ const b = builtin.find(t => t.id === id);
+ if (b) return b.name;
+ const t = (window.VRSITE_THEMES || []).find(t => t.id === id);
+ return t ? t.name : localStorage.getItem("notevi-theme-name") ||
+ id.replace(/^[^/]*\//, "").replace(/[-_]/g, " ");
+ };
+ btn.textContent = label(cur);
+
+ // themes.css is a link and themes.js a script, not a fetch: the static
+ // export is opened from the filesystem, where fetch() is not allowed
+ const ensure = () => {
+ if (!document.getElementById("themescss")) {
+ const l = document.createElement("link");
+ l.rel = "stylesheet"; l.id = "themescss"; l.href = pick.dataset.css;
+ document.head.append(l);
+ }
+ if (window.VRSITE_THEMES) return Promise.resolve();
+ if (!pending) pending = new Promise(done => {
+ const s = document.createElement("script");
+ s.src = pick.dataset.js;
+ s.onload = s.onerror = done;
+ document.head.append(s);
+ });
+ return pending;
+ };
+
+ const row = t => {
+ const li = document.createElement("li");
+ li.setAttribute("role", "option");
+ li.id = "themeopt-" + list.children.length;
+ li.dataset.id = t.id;
+ li.dataset.q = (t.name + " " + t.src + " " + t.id).toLowerCase();
+ const sw = document.createElement("span");
+ sw.className = t.sw ? "sw" : "sw none"; // empty, but the names still line up
+ for (const c of t.sw || []) {
+ const chip = document.createElement("i");
+ chip.style.background = c;
+ sw.append(chip);
+ }
+ li.append(sw);
+ const nm = document.createElement("span");
+ nm.className = "nm";
+ nm.textContent = t.name;
+ const src = document.createElement("span");
+ src.className = "src";
+ src.textContent = t.src;
+ li.append(nm, src);
+ return li;
+ };
+
+ const fill = () => {
+ if (filled) return;
+ filled = true;
+ // the two the site was built with are usually in the menu as well, so the
+ // rows for them can wear the same swatch — and "system", both at once
+ const all = window.VRSITE_THEMES || [];
+ const swOf = id => (all.find(t => t.id === id) || {}).sw;
+ const dsw = swOf(pick.dataset.darkid), lsw = swOf(pick.dataset.lightid);
+ builtin[0].sw = dsw && lsw && [...dsw.slice(0, 3), ...lsw.slice(0, 3)];
+ builtin[1].sw = dsw;
+ builtin[2].sw = lsw;
+ for (const t of builtin) list.append(row(t));
+ for (const t of window.VRSITE_THEMES || [])
+ list.append(row({...t, src: t.src + (t.dark ? " · dark" : " · light")}));
+ if (list.children.length === builtin.length) {
+ const p = document.createElement("li");
+ p.className = "meta";
+ p.textContent = "no zed or helix themes found on this machine";
+ list.append(p);
+ }
+ };
+
+ const setActive = (li, preview = true) => {
+ if (!li || li === active) return;
+ if (active) {
+ active.classList.remove("on");
+ active.removeAttribute("aria-selected");
+ }
+ active = li;
+ li.classList.add("on");
+ li.setAttribute("aria-selected", "true");
+ find.setAttribute("aria-activedescendant", li.id);
+ li.scrollIntoView({block: "nearest"});
+ if (preview) wear(li.dataset.id);
+ };
+
+ const rows = () => [...list.querySelectorAll("li[data-id]:not([hidden])")];
+ const rowFor = id => list.querySelector('li[data-id="' + CSS.escape(id) + '"]');
+
+ const filter = () => {
+ const q = find.value.trim().toLowerCase();
+ for (const li of list.children)
+ li.hidden = !!q && !(li.dataset.q || "").includes(q);
+ if (!active || active.hidden) setActive(rows()[0]);
+ };
+ find.addEventListener("input", filter);
+
+ const open = () => {
+ if (!menu.hidden) return;
+ menu.hidden = false;
+ btn.setAttribute("aria-expanded", "true");
+ find.value = "";
+ find.focus();
+ ensure().then(() => {
+ fill();
+ filter();
+ for (const li of list.querySelectorAll("li.cur")) li.classList.remove("cur");
+ const here = rowFor(cur);
+ if (here) here.classList.add("cur");
+ setActive(here || rows()[0], false);
+ });
+ };
+
+ // leaving the menu either keeps what is on screen or puts back what was
+ // there before it opened — nothing is stored until it is kept
+ const close = keep => {
+ if (menu.hidden) return;
+ if (keep && active) {
+ cur = active.dataset.id;
+ localStorage.setItem("notevi-theme", cur);
+ localStorage.setItem("notevi-theme-name", label(cur));
+ btn.textContent = label(cur);
+ for (const li of list.querySelectorAll("li.cur")) li.classList.remove("cur");
+ active.classList.add("cur");
+ }
+ wear(cur);
+ menu.hidden = true;
+ btn.setAttribute("aria-expanded", "false");
+ };
+
+ btn.addEventListener("click", () => (menu.hidden ? open() : close(false)));
+ // mousemove, not mouseover: arrowing scrolls rows under a still pointer,
+ // and the pointer must not then take the selection back
+ list.addEventListener("mousemove", e => {
+ const li = e.target.closest("li[data-id]");
+ if (li) setActive(li);
+ });
+ list.addEventListener("click", e => {
+ const li = e.target.closest("li[data-id]");
+ if (li) { setActive(li); close(true); }
+ });
+ find.addEventListener("keydown", e => {
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ e.preventDefault();
+ const all = rows();
+ if (!all.length) return;
+ const i = all.indexOf(active), step = e.key === "ArrowDown" ? 1 : -1;
+ setActive(all[(i + step + all.length) % all.length]);
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ close(true);
+ btn.focus();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ close(false);
+ btn.focus();
+ }
+ });
+ document.addEventListener("click", e => {
+ if (!menu.hidden && !pick.contains(e.target)) close(false);
+ });
+ document.addEventListener("keydown", e => {
+ if (e.key === "Escape") close(false);
+ if (e.key === "T" && !e.metaKey && !e.ctrlKey && !e.altKey && !typing(e)) {
+ e.preventDefault(); // or the T lands in the filter we just focused
+ open();
+ }
+ });
+}
+
+// ── nav tree ──────────────────────────────────────────────────────────────
+const nav = document.getElementById("nav");
+const tree = document.getElementById("tree");
+const state = JSON.parse(localStorage.getItem("notevi-tree") || "{}");
+
+// The file tree can get out of the way without losing repository context or
+// the navigation footer. Keep the choice across page loads in this browser.
+const fileList = document.getElementById("filelist");
+const fileToggle = document.getElementById("filetoggle");
+if (fileList && fileToggle) {
+ const setFilesHidden = hidden => {
+ root.dataset.files = hidden ? "hidden" : "shown";
+ fileToggle.setAttribute("aria-expanded", String(!hidden));
+ fileToggle.textContent = hidden ? "show files" : "hide files";
+ localStorage.setItem("notevi-files-hidden", hidden ? "1" : "0");
+ };
+ setFilesHidden(localStorage.getItem("notevi-files-hidden") === "1");
+ fileToggle.addEventListener("click", () => setFilesHidden(root.dataset.files !== "hidden"));
+}
+
+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("notevi-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();
+ });
+
+// ── conversation ──────────────────────────────────────────────────────────
+// A session page opened from a note lands on that note, flashed the same way
+// clicking a noted line flashes it.
+const conv = document.querySelector(".conv[data-anchor]");
+if (conv && conv.dataset.anchor) {
+ const here = document.getElementById(conv.dataset.anchor);
+ if (here) {
+ here.scrollIntoView({block: "start"});
+ here.classList.remove("flash"); void here.offsetWidth; here.classList.add("flash");
+ }
+}
+
+// The trip back: a note in a conversation opens the code it was written
+// about. The card is the target, so aim anywhere on it — except at the links
+// it already carries.
+const goTo = w => { location.href = w.dataset.href; };
+for (const w of document.querySelectorAll(".notewrap[data-href]")) {
+ w.addEventListener("click", e => { if (!e.target.closest("a")) goTo(w); });
+ w.addEventListener("keydown", e => {
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goTo(w); }
+ });
+}
+
+// ── arriving at a note ────────────────────────────────────────────────────
+// ?note=N means someone followed a note here rather than opening the file:
+// show what they came for — notes focus (coverage hides the note), the card
+// flashed in the pane, the lines it covers marked.
+// A permalink is the same arrival with nothing to scroll to: the page is the
+// note, so it only needs the focus that shows notes at all.
+if (document.body.dataset.kind === "note") setMode("notes");
+
+const want = new URLSearchParams(location.search).get("note");
+if (want && document.body.dataset.kind === "file" && /^\d+$/.test(want)) {
+ setMode("notes");
+ const card = document.getElementById("note-" + want);
+ if (card) {
+ // the card's own range, not the lines' data-note: a line belongs to the
+ // first note that claimed it, so overlapping notes would mark nothing
+ const from = +card.dataset.start, to = +card.dataset.end || +card.dataset.start;
+ let first = null;
+ for (let n = from; n > 0 && n <= to; n++) {
+ const li = document.getElementById("L" + n);
+ if (!li) continue;
+ li.classList.add("focus");
+ first = first || li;
+ }
+ // after the browser has had its own go at the #L anchor
+ if (first) requestAnimationFrame(() => first.scrollIntoView({block: "center"}));
+ card.scrollIntoView({block: "nearest"});
+ card.classList.remove("flash"); void card.offsetWidth; card.classList.add("flash");
+ }
+}
+
+// ── 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")) {
+ // the whole rendered body, not its first <p>: a note is markdown, so it
+ // may open with a list or a code fence and run to several blocks
+ const d = art.dataset, el = art.querySelector(".body");
+ const body = (el ? el.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();
+ });
+}