diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 23:43:08 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-03 09:54:39 -0300 |
| commit | 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch) | |
| tree | f12f4b700fff4ff6f7e818d2a542672ba0b8d76e /vrsite/assets/app.js | |
| parent | a9263daee9413c5eff3c1f1bebcd224442fd8685 (diff) | |
| download | notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip | |
rebrand to notevi: one CLI over the jj sidecar
vr and vrsite become a single binary. vrsite/ folds into a web package in
one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly
what vrsite did, and "notevi read/grep/note/query" is unchanged.
The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the
description "private: notevi log". The pre-rebrand names are still
recognized, so an old repository opens and reads; it is renamed in place
on the first write, or up front with "notevi migrate DIR...".
That rename cannot be a single mv inside jj run. jj only auto-tracks a
*new* file in the run working copy below a size limit it does not take
from the command line, so writing a whole log under a name the change has
never held is silently dropped while jj reports success. ensureLogFile
creates the file empty first and lets every later byte be a modification
of a tracked file, which snapshots at any size; that also fixes the same
latent bug when importing a large legacy vr-log.jsonl.
Adds a bem-te-vi mark (favicon and nav brand) and a README.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'vrsite/assets/app.js')
| -rw-r--r-- | vrsite/assets/app.js | 461 |
1 files changed, 0 insertions, 461 deletions
diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js deleted file mode 100644 index 854959a..0000000 --- a/vrsite/assets/app.js +++ /dev/null @@ -1,461 +0,0 @@ -// 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; - -// 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("vrsite-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("vrsite-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("vrsite-theme", cur); - localStorage.setItem("vrsite-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("vrsite-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("vrsite-files-hidden", hidden ? "1" : "0"); - }; - setFilesHidden(localStorage.getItem("vrsite-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("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(); - }); - -// ── 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(); - }); -} |
