1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
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();
});
}
|