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