kaizen-agent 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,696 @@
1
+ // The notebook page. Bundled into web/notebook.js by `bun run build:web`; the page
2
+ // never sees the filesystem, only /vault. See design.md, "Notebook", for the look.
3
+ import { EditorView, Decoration, ViewPlugin, WidgetType, keymap, type DecorationSet, type ViewUpdate } from "@codemirror/view";
4
+ import { EditorState, StateEffect, StateField, type Range } from "@codemirror/state";
5
+ import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
6
+ import { syntaxTree, syntaxHighlighting, HighlightStyle } from "@codemirror/language";
7
+ import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
8
+ import { tags as t } from "@lezer/highlight";
9
+ import { autocompletion, startCompletion, type CompletionContext, type Completion } from "@codemirror/autocomplete";
10
+ import { wikilinks, tags, resolve, safeUrl, tree, freshName, parseTable, boardLink, resolveRun, anchorWords, TABS, type Tree, type BoardLink } from "./notes-lib.ts";
11
+
12
+ type Note = { name: string; mtime: number; text: string };
13
+
14
+ const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
15
+ const esc = (s: string) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
16
+ const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
17
+ const last = (name: string) => name.split("/").pop()!;
18
+ const folderOf = (name: string) => name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : "";
19
+
20
+ type Board = {
21
+ runs: { id: string; short: string; title: string; status: string; findings: { n: number; text: string; done: boolean }[]; backlog: { n: number; status: string; text: string }[] }[];
22
+ ideas: { text: string; status: string }[];
23
+ };
24
+ let board: Board = { runs: [], ideas: [] }; // what [[run:…]] and [[idea:…]] can name, from /links
25
+ let dir = ""; // the state dir whose notes/ is shown
26
+ let notes: Note[] = [];
27
+ let open: string | null = null; // the note in the editor
28
+ let base = 0; // the mtime it was loaded or last saved at
29
+ let dirty = false, saving: Promise<void> | null = null, timer: ReturnType<typeof setTimeout> | undefined;
30
+ let conflict: { text: string; mtime: number } | null = null;
31
+ let confirmDelete = false;
32
+ const shut = new Set<string>(); // folders folded in the tree, for this page load
33
+
34
+ function toast(msg: string) { const el = $("toast"); el.textContent = msg; el.classList.add("on"); setTimeout(() => el.classList.remove("on"), 2600); }
35
+ async function post(body: Record<string, unknown>) {
36
+ try {
37
+ const r = await fetch("/vault", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ dir, ...body }) });
38
+ return { status: r.status, ...(await r.json().catch(() => ({}))) };
39
+ } catch { return { status: 0, error: "The board is not running" }; }
40
+ }
41
+
42
+ // ---- live preview: markdown hidden and typeset on every line the cursor is not on
43
+
44
+ class Check extends WidgetType {
45
+ constructor(readonly on: boolean, readonly at: number) { super(); }
46
+ eq(o: Check) { return o.on === this.on && o.at === this.at; }
47
+ toDOM(view: EditorView) {
48
+ const b = document.createElement("input");
49
+ b.type = "checkbox"; b.checked = this.on; b.className = "cm-task";
50
+ b.setAttribute("aria-label", this.on ? "done" : "to do");
51
+ b.addEventListener("mousedown", (e) => {
52
+ e.preventDefault();
53
+ view.dispatch({ changes: { from: this.at, to: this.at + 3, insert: this.on ? "[ ]" : "[x]" } });
54
+ });
55
+ return b;
56
+ }
57
+ ignoreEvent() { return true; }
58
+ }
59
+ class Bullet extends WidgetType {
60
+ eq() { return true; }
61
+ toDOM() { const s = document.createElement("span"); s.className = "cm-bullet"; s.textContent = "•"; return s; }
62
+ }
63
+ class Rule extends WidgetType {
64
+ eq() { return true; }
65
+ toDOM() { const s = document.createElement("span"); s.className = "cm-rule"; return s; }
66
+ }
67
+ class Img extends WidgetType {
68
+ constructor(readonly src: string, readonly alt: string) { super(); }
69
+ eq(o: Img) { return o.src === this.src && o.alt === this.alt; }
70
+ toDOM() { const i = document.createElement("img"); i.src = this.src; i.alt = this.alt; i.className = "cm-img"; return i; }
71
+ }
72
+
73
+ // A table the cursor is not in is drawn as one; clicking it puts the cursor in its
74
+ // source. Cells are text nodes: nothing in a note becomes markup.
75
+ class TableView extends WidgetType {
76
+ constructor(readonly src: string, readonly at: number) { super(); }
77
+ eq(o: TableView) { return o.src === this.src && o.at === this.at; }
78
+ toDOM(view: EditorView) {
79
+ const t = parseTable(this.src), wrap = document.createElement("div"), table = document.createElement("table");
80
+ wrap.className = "cm-table";
81
+ const row = (cells: string[], tag: "th" | "td") => {
82
+ const tr = document.createElement("tr");
83
+ cells.forEach((c, i) => { const el = document.createElement(tag); el.textContent = c; if (t.align[i]) el.style.textAlign = t.align[i]!; tr.append(el); });
84
+ return tr;
85
+ };
86
+ const head = document.createElement("thead"), body = document.createElement("tbody");
87
+ head.append(row(t.head, "th")); t.rows.forEach((r) => body.append(row(r, "td")));
88
+ table.append(head, body); wrap.append(table);
89
+ wrap.addEventListener("mousedown", (e) => { e.preventDefault(); view.dispatch({ selection: { anchor: this.at } }); view.focus(); });
90
+ return wrap;
91
+ }
92
+ ignoreEvent() { return true; }
93
+ }
94
+ // Whether the editor has focus, as state: an unfocused editor shows every table drawn.
95
+ const focusSet = StateEffect.define<boolean>();
96
+ const focused = StateField.define<boolean>({
97
+ create: () => false,
98
+ update: (v, tr) => tr.effects.reduce((f, e) => e.is(focusSet) ? e.value : f, v),
99
+ });
100
+ // Block widgets may span lines, which a view plugin may not do: tables live in a field.
101
+ function tables(state: EditorState) {
102
+ const out: Range<Decoration>[] = [], sel = state.selection.main, live = state.field(focused, false);
103
+ syntaxTree(state).iterate({
104
+ enter(n) {
105
+ if (n.name !== "Table") return;
106
+ const from = state.doc.lineAt(n.from).from, to = state.doc.lineAt(n.to).to;
107
+ if (live && sel.to >= from && sel.from <= to) return false;
108
+ out.push(Decoration.replace({ widget: new TableView(state.sliceDoc(from, to), from), block: true }).range(from, to));
109
+ return false;
110
+ },
111
+ });
112
+ return Decoration.set(out);
113
+ }
114
+ const tableField = StateField.define<DecorationSet>({
115
+ create: tables,
116
+ update: (v, tr) => tr.docChanged || tr.selection || tr.effects.some((e) => e.is(focusSet)) || syntaxTree(tr.startState) !== syntaxTree(tr.state) ? tables(tr.state) : v,
117
+ provide: (f) => EditorView.decorations.from(f),
118
+ });
119
+
120
+ // A link to the board: where it goes, what the chip says, and whether it is there.
121
+ const ICON: Record<string, string> = { idea: "idea", starting: "idea", running: "run", stalled: "stall", waiting: "wait", done: "done", abandoned: "aband" };
122
+ function boardTarget(l: BoardLink) {
123
+ const q = (o: Record<string, string>) => "/?" + new URLSearchParams({ all: "1", dir, ...o }).toString();
124
+ if (l.kind === "idea") {
125
+ const t = l.text.toLowerCase();
126
+ const idea = board.ideas.find((i) => i.text.toLowerCase() === t) ?? board.ideas.find((i) => i.text.toLowerCase().startsWith(t));
127
+ return { name: idea?.text ?? l.text, sub: "idea", status: idea?.status ?? "", href: idea ? q({ idea: idea.text }) : null };
128
+ }
129
+ const id = resolveRun(l.id, board.runs.map((r) => r.id)), run = board.runs.find((r) => r.id === id);
130
+ const item = l.item && run && (l.item.type === "finding" ? run.findings : run.backlog).some((x) => x.n === l.item!.n);
131
+ const ok = !!run && (!l.item || item);
132
+ return {
133
+ name: run?.short ?? l.id, sub: anchorWords(l), status: run?.status ?? "",
134
+ href: ok ? q({ run: run!.id, ...(l.tab ? { tab: l.tab } : {}), ...(l.item ? { item: `${l.item.type}-${l.item.n}` } : {}) }) : null,
135
+ };
136
+ }
137
+ // Leave for the board only once the note is on disk.
138
+ async function go(href: string) {
139
+ await flush();
140
+ if (dirty) { toast("This note is not saved yet"); return; }
141
+ location.href = href;
142
+ }
143
+ class BoardChip extends WidgetType {
144
+ constructor(readonly name: string, readonly sub: string, readonly status: string, readonly href: string | null) { super(); }
145
+ eq(o: BoardChip) { return o.name === this.name && o.sub === this.sub && o.status === this.status && o.href === this.href; }
146
+ toDOM() {
147
+ const c = document.createElement("span");
148
+ c.className = "cm-board" + (this.href ? "" : " cm-board-missing");
149
+ c.title = this.href ? "Open on the board" : "Not on the board";
150
+ if (this.status) {
151
+ const ic = document.createElement("span");
152
+ ic.className = "st d-" + (ICON[this.status] ?? "idea");
153
+ ic.innerHTML = `<svg><use href="#s-${ICON[this.status] ?? "idea"}"/></svg>`;
154
+ c.append(ic);
155
+ }
156
+ c.append(this.name);
157
+ if (this.sub) { const s = document.createElement("span"); s.className = "sub"; s.textContent = "› " + this.sub; c.append(s); }
158
+ c.addEventListener("mousedown", (e) => { e.preventDefault(); if (this.href) void go(this.href); else toast("That is not on the board"); });
159
+ return c;
160
+ }
161
+ ignoreEvent() { return true; }
162
+ }
163
+
164
+ const hide = Decoration.replace({});
165
+ const refresh = StateEffect.define<null>(); // the list of notes changed: redraw missing links
166
+
167
+ function build(view: EditorView): DecorationSet {
168
+ const { state } = view, doc = state.doc, out: Range<Decoration>[] = [];
169
+ const live = new Set<number>();
170
+ // Only a focused editor has a cursor line: a note just opened reads fully typeset.
171
+ if (view.hasFocus) for (const r of state.selection.ranges) for (let l = doc.lineAt(r.from).number; l <= doc.lineAt(r.to).number; l++) live.add(l);
172
+ const on = (pos: number) => live.has(doc.lineAt(pos).number);
173
+ // A plugin may not replace a line break: anything that would is left as written.
174
+ const put = (d: Decoration, from: number, to: number) => { if (doc.lineAt(from).number === doc.lineAt(to).number) out.push(d.range(from, to)); };
175
+ const lines = (from: number, to: number, cls: string) => {
176
+ for (let l = doc.lineAt(from).number; l <= doc.lineAt(to).number; l++) out.push(Decoration.line({ class: cls }).range(doc.line(l).from));
177
+ };
178
+ for (const { from, to } of view.visibleRanges) {
179
+ syntaxTree(state).iterate({
180
+ from, to,
181
+ enter(n) {
182
+ const name = n.name;
183
+ const h = /^ATXHeading(\d)$/.exec(name);
184
+ if (h) { lines(n.from, n.from, `cm-h cm-h${Math.min(+h[1]!, 4)}`); return; }
185
+ if (name === "HeaderMark" && n.node.parent?.name.startsWith("ATX")) {
186
+ if (!on(n.from)) put(hide, n.from, Math.min(n.to + (doc.sliceString(n.to, n.to + 1) === " " ? 1 : 0), doc.lineAt(n.from).to));
187
+ return;
188
+ }
189
+ if (name === "EmphasisMark" || name === "StrikethroughMark" || (name === "CodeMark" && n.node.parent?.name === "InlineCode")) {
190
+ if (!on(n.from)) put(hide, n.from, n.to);
191
+ return;
192
+ }
193
+ if (name === "Blockquote") { lines(n.from, n.to, "cm-quote"); return; }
194
+ if (name === "QuoteMark") {
195
+ if (!on(n.from)) put(hide, n.from, Math.min(n.to + (doc.sliceString(n.to, n.to + 1) === " " ? 1 : 0), doc.lineAt(n.from).to));
196
+ return;
197
+ }
198
+ if (name === "FencedCode") {
199
+ lines(n.from, n.to, "cm-fence");
200
+ out.push(Decoration.line({ class: "cm-fence-a" }).range(doc.lineAt(n.from).from), Decoration.line({ class: "cm-fence-z" }).range(doc.lineAt(n.to).from));
201
+ return false;
202
+ }
203
+ if (name === "HorizontalRule") { if (!on(n.from)) put(Decoration.replace({ widget: new Rule() }), n.from, n.to); return; }
204
+ if (name === "ListMark" && n.node.parent?.parent?.name === "BulletList") {
205
+ if (on(n.from)) return;
206
+ const task = n.node.nextSibling?.name === "Task";
207
+ put(task ? hide : Decoration.replace({ widget: new Bullet() }), n.from, task ? Math.min(n.to + 1, doc.lineAt(n.from).to) : n.to);
208
+ return;
209
+ }
210
+ if (name === "TaskMarker") {
211
+ if (!on(n.from)) put(Decoration.replace({ widget: new Check(/x/i.test(doc.sliceString(n.from, n.to)), n.from) }), n.from, n.to);
212
+ return;
213
+ }
214
+ if (name === "Image") {
215
+ const url = n.node.getChild("URL"), marks = n.node.getChildren("LinkMark");
216
+ if (on(n.from) || !url || marks.length < 2) return false;
217
+ const src = safeUrl(doc.sliceString(url.from, url.to));
218
+ if (src && /^https?:/i.test(src)) put(Decoration.replace({ widget: new Img(src, doc.sliceString(marks[0]!.to, marks[1]!.from)) }), n.from, n.to);
219
+ return false;
220
+ }
221
+ if (name === "Link") {
222
+ const url = n.node.getChild("URL"), marks = n.node.getChildren("LinkMark");
223
+ // Only the inline form `[text](url)`; a reference link stays as written.
224
+ if (!url || marks.length < 4) return;
225
+ const href = safeUrl(doc.sliceString(url.from, url.to));
226
+ const text = { from: marks[0]!.to, to: marks[1]!.from };
227
+ // A link to somewhere safeUrl refuses stays as written, so what it points at is visible.
228
+ if (on(n.from) || !href) { out.push(Decoration.mark({ class: "cm-link-src" }).range(n.from, n.to)); return; }
229
+ if (text.to > text.from) out.push(Decoration.mark({ class: "cm-link", attributes: { "data-href": href } }).range(text.from, text.to));
230
+ put(hide, marks[0]!.from, marks[0]!.to); put(hide, marks[1]!.from, n.to);
231
+ return;
232
+ }
233
+ if (name === "URL" && n.node.parent?.name !== "Link" && n.node.parent?.name !== "Image") {
234
+ const href = safeUrl(doc.sliceString(n.from, n.to));
235
+ out.push(Decoration.mark({ class: "cm-link", attributes: href && !on(n.from) ? { "data-href": href } : {} }).range(n.from, n.to));
236
+ }
237
+ },
238
+ });
239
+ }
240
+ // Wikilinks and tags are not markdown: found by notes-lib over the text.
241
+ // ponytail: the whole note is scanned on each redraw; limit to visible ranges if notes reach megabytes.
242
+ const text = doc.toString(), names = notes.map((x) => x.name);
243
+ for (const w of wikilinks(text)) {
244
+ const bl = boardLink(w);
245
+ if (bl) {
246
+ if (on(w.from)) { out.push(Decoration.mark({ class: "cm-wiki" }).range(w.from, w.to)); continue; }
247
+ const t = boardTarget(bl);
248
+ put(Decoration.replace({ widget: new BoardChip(w.label !== w.target ? w.label : t.name, t.sub, t.status, t.href) }), w.from, w.to);
249
+ continue;
250
+ }
251
+ const target = resolve(w.name, names);
252
+ const cls = "cm-wiki" + (target ? "" : " cm-wiki-missing");
253
+ if (on(w.from)) { out.push(Decoration.mark({ class: cls }).range(w.from, w.to)); continue; }
254
+ const labelAt = text.lastIndexOf(w.label, w.to - 2);
255
+ const lf = labelAt > w.from ? labelAt : w.from + 2, lt = lf + w.label.length;
256
+ put(hide, w.from, lf); out.push(Decoration.mark({ class: cls, attributes: { "data-note": target ?? w.name } }).range(lf, lt)); put(hide, lt, w.to);
257
+ }
258
+ for (const g of tags(text)) out.push(Decoration.mark({ class: "cm-tag", attributes: on(g.from) ? {} : { "data-tag": g.tag } }).range(g.from, g.to));
259
+ return Decoration.set(out, true);
260
+ }
261
+
262
+ const preview = ViewPlugin.fromClass(class {
263
+ decorations: DecorationSet;
264
+ constructor(view: EditorView) { this.decorations = build(view); }
265
+ update(u: ViewUpdate) {
266
+ if (u.docChanged || u.viewportChanged || u.selectionSet || u.focusChanged || syntaxTree(u.startState) !== syntaxTree(u.state)
267
+ || u.transactions.some((tr) => tr.effects.some((e) => e.is(refresh)))) this.decorations = build(u.view);
268
+ }
269
+ }, { decorations: (v) => v.decorations });
270
+
271
+ const look = HighlightStyle.define([
272
+ { tag: t.emphasis, fontStyle: "italic" },
273
+ { tag: t.strong, fontWeight: "600" },
274
+ { tag: t.strikethrough, textDecoration: "line-through" },
275
+ { tag: t.monospace, fontFamily: "var(--mono)", fontSize: "0.86em", background: "var(--bg)", borderRadius: "var(--r-s)", padding: "1px 4px" },
276
+ { tag: [t.processingInstruction, t.meta, t.url, t.labelName, t.contentSeparator], color: "var(--dim)" },
277
+ { tag: t.link, color: "var(--accent)" },
278
+ { tag: t.quote, color: "var(--dim)" },
279
+ ]);
280
+
281
+ // Clicks on what the preview drew: a link opens, a wikilink opens its note, a tag filters.
282
+ const clicks = EditorView.domEventHandlers({
283
+ mousedown(e) {
284
+ const el = (e.target as HTMLElement).closest?.("[data-href],[data-note],[data-tag]") as HTMLElement | null;
285
+ if (!el || e.button !== 0) return false;
286
+ e.preventDefault();
287
+ const href = el.dataset.href;
288
+ // A relative link is a note (`[x](Other%20note.md)`), as in Obsidian; the rest open in a tab.
289
+ if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href)) {
290
+ let name = href; try { name = decodeURIComponent(href); } catch { /* as written */ }
291
+ const hit = resolve(name.split("#")[0]!, notes.map((x) => x.name));
292
+ hit ? void openNote(hit) : toast(`No note named ${name}`);
293
+ } else if (href) window.open(href, "_blank", "noopener");
294
+ else if (el.dataset.note) openOrCreate(el.dataset.note);
295
+ else if (el.dataset.tag) { $<HTMLInputElement>("q").value = "#" + el.dataset.tag; drawSide(); }
296
+ return true;
297
+ },
298
+ });
299
+
300
+ // The `/` menu: at the start of a line, `/` offers the blocks, so nobody has to
301
+ // remember markdown. Picking one replaces the `/word` with the block's source; `|`
302
+ // in the text marks where the cursor lands.
303
+ const BLOCKS: [string, string, string][] = [
304
+ ["Checkbox", "- [ ] |", "to do"],
305
+ ["Divider", "---\n|", "line across"],
306
+ ["Heading 1", "# |", "big"],
307
+ ["Heading 2", "## |", "medium"],
308
+ ["Heading 3", "### |", "small"],
309
+ ["Bullet list", "- |", "•"],
310
+ ["Numbered list", "1. |", "1."],
311
+ ["Quote", "> |", "aside"],
312
+ ["Code block", "```\n|\n```", "monospace"],
313
+ ["Table", "| Column | Column |\n| --- | --- |\n| | |", "rows"],
314
+ ["Link to note", "[[|]]", "[[ ]]"],
315
+ ["Link to run", "[[run:|]]", "board"],
316
+ ["Tag", "#|", "#"],
317
+ ];
318
+ function slash(cx: CompletionContext) {
319
+ const m = cx.matchBefore(/^[ \t]*\/[\w ]*$/);
320
+ if (!m) return null;
321
+ const at = m.from + m.text.indexOf("/");
322
+ return {
323
+ from: at,
324
+ filter: true,
325
+ validFor: /^\/[\w ]*$/,
326
+ // Boosted in list order, so an unfiltered menu reads as written above.
327
+ options: BLOCKS.map(([label, src, detail], i): Completion => ({
328
+ label: "/" + label.toLowerCase(), displayLabel: label, detail, boost: 50 - i,
329
+ apply: (view, _c, from, to) => {
330
+ // Straight under a line of text, `---` would underline it into a heading and a
331
+ // table would join its paragraph: both need a blank line above.
332
+ const line = view.state.doc.lineAt(from);
333
+ const gap = (label === "Divider" || label === "Table") && line.number > 1 && view.state.doc.line(line.number - 1).text.trim() ? "\n" : "";
334
+ const text = gap + src.replace("|", ""), caret = from + gap.length + src.indexOf("|");
335
+ view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: caret }, userEvent: "input.complete" });
336
+ // A link is only begun: the next list offers what it can point at.
337
+ if (label.startsWith("Link to")) setTimeout(() => startCompletion(view));
338
+ },
339
+ })),
340
+ };
341
+ }
342
+
343
+ // `[[` offers notes, runs and ideas; `#` after a run offers its tabs, findings and
344
+ // backlog items. Picking one closes the link unless it is a run, which leaves the
345
+ // cursor before `]]` so a `#` can follow.
346
+ function links(cx: CompletionContext) {
347
+ const m = cx.matchBefore(/\[\[[^\]\n|]*$/);
348
+ if (!m) return null;
349
+ const q = m.text.slice(2), close = cx.state.sliceDoc(cx.pos, cx.pos + 2) === "]]";
350
+ const put = (text: string, stay: boolean) => (view: EditorView, _c: Completion, from: number, to: number) => {
351
+ view.dispatch({ changes: { from, to, insert: text + (close ? "" : "]]") }, selection: { anchor: from + text.length + (stay ? 0 : 2) }, userEvent: "input.complete" });
352
+ };
353
+ const anchor = /^run:\s*([\w.-]+)#([\w-]*)$/i.exec(q);
354
+ if (anchor) {
355
+ const id = resolveRun(anchor[1]!, board.runs.map((r) => r.id)), run = board.runs.find((r) => r.id === id);
356
+ if (!run) return null;
357
+ const cut = (t: string) => t.length > 60 ? t.slice(0, 59) + "…" : t;
358
+ return {
359
+ from: cx.pos - anchor[2]!.length, validFor: /^[\w-]*$/,
360
+ options: [
361
+ ...TABS.map((t, i): Completion => ({ label: t, detail: "tab", boost: 20 - i, apply: put(t, false) })),
362
+ ...run.findings.map((f): Completion => ({ label: `finding-${f.n}`, detail: cut(f.text) + (f.done ? " (closed)" : ""), apply: put(`finding-${f.n}`, false) })),
363
+ ...run.backlog.map((b): Completion => ({ label: `backlog-${b.n}`, detail: `${b.status}: ${cut(b.text)}`, apply: put(`backlog-${b.n}`, false) })),
364
+ ],
365
+ };
366
+ }
367
+ return {
368
+ from: m.from + 2, validFor: /^[^\]\n|#]*$/,
369
+ options: [
370
+ ...notes.map((n): Completion => ({ label: n.name, detail: "note", apply: put(n.name, false) })),
371
+ // Newest first, by the name the board shows; the full id is what gets written.
372
+ ...[...board.runs].sort((a, b) => b.id.localeCompare(a.id)).map((r, i): Completion => ({
373
+ label: "run:" + r.id, displayLabel: r.short, detail: r.id.slice(0, 10) + " · " + r.title, boost: Math.max(-99, 60 - i), apply: put("run:" + r.id, true),
374
+ })),
375
+ ...board.ideas.map((i): Completion => ({ label: "idea:" + i.text, detail: "idea", apply: put("idea:" + i.text, false) })),
376
+ ],
377
+ };
378
+ }
379
+
380
+ const exts = (crlf: boolean) => [
381
+ autocompletion({ override: [slash, links], icons: false }),
382
+ history(),
383
+ keymap.of([...markdownKeymap, ...defaultKeymap, ...historyKeymap, indentWithTab, { key: "Mod-s", run: () => { void flush(); return true; } }]),
384
+ markdown({ base: markdownLanguage }),
385
+ syntaxHighlighting(look),
386
+ EditorView.lineWrapping,
387
+ EditorView.contentAttributes.of({ "aria-label": "Note", spellcheck: "true" }),
388
+ focused, EditorView.focusChangeEffect.of((_s, on) => focusSet.of(on)), tableField,
389
+ preview, clicks,
390
+ EditorView.updateListener.of((u) => { if (u.docChanged && !loading) { dirty = true; conflict = null; drawMeta(); clearTimeout(timer); timer = setTimeout(() => void flush(), 600); } }),
391
+ crlf ? EditorState.lineSeparator.of("\r\n") : [],
392
+ ];
393
+
394
+ let loading = false;
395
+ const view = new EditorView({ parent: $("editor"), state: EditorState.create({ doc: "", extensions: exts(false) }) });
396
+ // Replace the text without it counting as the user's edit.
397
+ function setText(text: string, fresh: boolean) {
398
+ loading = true;
399
+ if (fresh) view.setState(EditorState.create({ doc: text, extensions: exts(text.includes("\r\n")) }));
400
+ else view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, selection: { anchor: Math.min(view.state.selection.main.head, text.length) } });
401
+ loading = false;
402
+ }
403
+
404
+ // ---- saving
405
+
406
+ // Write the open note if it has unsaved text. Never called for a note only opened.
407
+ async function flush(): Promise<void> {
408
+ clearTimeout(timer);
409
+ if (saving) await saving;
410
+ if (!open || !dirty || conflict) return;
411
+ const name = open, text = view.state.sliceDoc();
412
+ let saved = false;
413
+ saving = (async () => {
414
+ drawMeta("Saving…");
415
+ const r = await post({ op: "save", name, text, base });
416
+ if (open !== name) return;
417
+ if (r.ok) { saved = true; base = r.mtime; if (view.state.sliceDoc() === text) dirty = false; upsert({ name, mtime: r.mtime, text }); }
418
+ else if (r.status === 409) conflict = { text: r.text ?? "", mtime: r.mtime ?? 0 };
419
+ else toast(r.error || "Not saved");
420
+ })();
421
+ await saving; saving = null;
422
+ drawMeta();
423
+ // Typed on while that save was out: save again. A failed save waits for the next keystroke.
424
+ if (saved && dirty && !conflict && open === name) { clearTimeout(timer); timer = setTimeout(() => void flush(), 600); }
425
+ }
426
+ function upsert(n: Note) { notes = [n, ...notes.filter((x) => x.name !== n.name)]; drawSide(); drawLinks(); }
427
+
428
+ // ---- the vault
429
+
430
+ async function load() {
431
+ let r: { notes?: Note[]; error?: string };
432
+ try { r = await (await fetch("/vault?dir=" + encodeURIComponent(dir))).json(); } catch { toast("The board is not running"); return; }
433
+ if (!r.notes) { toast(r.error || "Could not read notes"); return; }
434
+ notes = r.notes;
435
+ // The board's items, for chips and completion; a failure keeps the last ones.
436
+ try { const b = await (await fetch("/links?dir=" + encodeURIComponent(dir))).json(); if (Array.isArray(b.runs)) board = b; } catch { /* keep */ }
437
+ const mine = open && notes.find((n) => n.name === open);
438
+ // Someone else changed the open note: take it only when nothing typed here is unsaved.
439
+ if (mine && mine.mtime !== base && !dirty && !saving) { base = mine.mtime; setText(mine.text, false); }
440
+ view.dispatch({ effects: refresh.of(null) });
441
+ drawSide(); drawLinks(); drawPane();
442
+ }
443
+
444
+ // Laying down a page: the note being left is copied onto a sheet under the paper
445
+ // side, and the next note slides a short way in over it from the right, fading up,
446
+ // like a fresh sheet dealt onto a stack, while the one beneath eases left into its
447
+ // shadow. The sidebar sits above both, so nothing moves across it. The copy
448
+ // is pixels only: never focused, never read. Transform and opacity only, so it stays
449
+ // smooth. Every open does it, the first one too. Nothing moves under reduced motion.
450
+ const DEAL = { duration: 260, easing: "cubic-bezier(.22,.61,.36,1)" };
451
+ function lift(): HTMLElement | null {
452
+ const main = document.querySelector<HTMLElement>(".main")!;
453
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) return null;
454
+ const r = main.getBoundingClientRect(), top = main.scrollTop;
455
+ const sheet = main.cloneNode(true) as HTMLElement;
456
+ sheet.classList.add("sheet");
457
+ sheet.setAttribute("aria-hidden", "true");
458
+ sheet.inert = true;
459
+ Object.assign(sheet.style, { left: r.left + "px", top: r.top + "px", width: r.width + "px", height: r.height + "px" });
460
+ sheet.append(Object.assign(document.createElement("i"), { className: "shade" }));
461
+ // After the real page in document order, so every id still finds the real one first.
462
+ document.body.append(sheet);
463
+ sheet.scrollTop = top;
464
+ return sheet;
465
+ }
466
+ function turn(sheet: HTMLElement | null) {
467
+ if (!sheet) return;
468
+ const main = document.querySelector<HTMLElement>(".main")!;
469
+ main.classList.add("dealt");
470
+ const done = () => { main.classList.remove("dealt"); sheet.remove(); };
471
+ main.animate([{ transform: "translateX(56px)", opacity: 0 }, { transform: "none", opacity: 1 }], DEAL).finished.then(done, done);
472
+ // The old page is gone before the new one is fully up, so the two never read as one.
473
+ sheet.animate([{ transform: "none", opacity: 1 }, { transform: "translateX(-24px)", opacity: 0, offset: 0.6 }, { transform: "translateX(-24px)", opacity: 0 }], { ...DEAL, fill: "forwards" });
474
+ sheet.querySelector<HTMLElement>(".shade")!.animate([{ opacity: 0 }, { opacity: 1 }], { ...DEAL, fill: "forwards" });
475
+ }
476
+
477
+ // False when the open note still holds text that is not on disk: it stays open.
478
+ async function openNote(name: string | null): Promise<boolean> {
479
+ if (name === open) return true;
480
+ await flush();
481
+ if (dirty) { toast(conflict ? "Choose Keep mine or Load theirs first" : "This note is not saved yet"); return false; }
482
+ const sheet = lift();
483
+ open = name; conflict = null; confirmDelete = false; dirty = false;
484
+ const n = name ? notes.find((x) => x.name === name) : null;
485
+ base = n?.mtime ?? 0;
486
+ setText(n?.text ?? "", true);
487
+ try { name ? sessionStorage.setItem("kz-note:" + dir, name) : sessionStorage.removeItem("kz-note:" + dir); } catch { /* private window */ }
488
+ document.body.classList.remove("side-open");
489
+ drawSide(); drawPane(); drawLinks();
490
+ turn(sheet);
491
+ return true;
492
+ }
493
+
494
+ async function create(name: string) {
495
+ const r = await post({ op: "save", name, text: "", base: 0 });
496
+ if (!r.ok) { toast(r.status === 409 ? "A note with that name exists" : r.error || "Could not create the note"); return false; }
497
+ upsert({ name: r.name, mtime: r.mtime, text: "" });
498
+ return openNote(r.name);
499
+ }
500
+ async function openOrCreate(name: string) {
501
+ const hit = resolve(name, notes.map((n) => n.name));
502
+ if (hit) return void openNote(hit);
503
+ if (await create(name)) view.focus();
504
+ }
505
+ async function newNote() {
506
+ if (await create(freshName(notes.map((n) => n.name), open ? folderOf(open) : ""))) { const t = $<HTMLInputElement>("title"); t.focus(); t.select(); }
507
+ }
508
+
509
+ async function rename(to: string) {
510
+ if (!open || !to.trim() || to.trim() === open) { drawPane(); return; }
511
+ const from = open;
512
+ await flush();
513
+ const r = await post({ op: "rename", name: from, to });
514
+ if (!r.ok) { toast(r.error || "Could not rename"); drawPane(); return; }
515
+ // Another note was opened meanwhile: the rename stands, the editor stays where it is.
516
+ if (open !== from) { await load(); return; }
517
+ open = to.trim().replace(/\.md$/i, "").split("/").map((p) => p.trim()).join("/");
518
+ await load();
519
+ base = notes.find((n) => n.name === open)?.mtime ?? base;
520
+ toast(folderOf(from) === folderOf(open) ? "Renamed" : `Moved to ${folderOf(open) || "the top"}`);
521
+ }
522
+
523
+ async function remove() {
524
+ if (!open) return;
525
+ clearTimeout(timer);
526
+ const r = await post({ op: "delete", name: open });
527
+ if (!r.ok) { toast(r.error || "Could not delete"); return; }
528
+ toast("Moved to notes/.trash");
529
+ dirty = false; notes = notes.filter((n) => n.name !== open);
530
+ await openNote(null);
531
+ }
532
+
533
+ // ---- drawing
534
+
535
+ function drawSide() {
536
+ const q = $<HTMLInputElement>("q").value.trim().toLowerCase();
537
+ // Tags, most used first.
538
+ const count = new Map<string, number>();
539
+ for (const n of notes) for (const g of new Set(tags(n.text).map((x) => x.tag))) count.set(g, (count.get(g) ?? 0) + 1);
540
+ const tg = q.startsWith("#") ? q.slice(1) : null;
541
+ $("tags").innerHTML = [...count].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
542
+ .map(([g, c]) => `<button class="chip${g === tg ? " on" : ""}" data-tag="${esc(g)}" aria-pressed="${g === tg}">#${esc(g)} <span>${c}</span></button>`).join("");
543
+ $("tagsh").hidden = !count.size;
544
+ const row = (n: string, withFolder = false) => {
545
+ const note = notes.find((x) => x.name === n);
546
+ return `<li><button class="nt${n === open ? " cur" : ""}" data-open="${esc(n)}"${n === open ? ' aria-current="true"' : ""}><span class="nm">${esc(last(n))}</span>${withFolder && folderOf(n) ? `<span class="fd">${esc(folderOf(n))}</span>` : ""}<span class="age">${note ? age(note.mtime) : ""}</span></button></li>`;
547
+ };
548
+ if (q) {
549
+ const hits = notes.filter((n) => tg !== null
550
+ ? tags(n.text).some((x) => x.tag === tg || x.tag.startsWith(tg + "/"))
551
+ : n.name.toLowerCase().includes(q) || n.text.toLowerCase().includes(q));
552
+ $("tree").innerHTML = hits.length ? `<ul>${hits.map((n) => row(n.name, true)).join("")}</ul>` : `<p class="none">No note matches.</p>`;
553
+ return;
554
+ }
555
+ const draw = (tr: Tree, path: string): string => `<ul>${[...tr.folders].map(([f, sub]) => {
556
+ const p = path + f;
557
+ return `<li><details data-folder="${esc(p)}"${shut.has(p) ? "" : " open"}><summary><i class="chev">${ico("chev")}</i>${ico("folder")}<span>${esc(f)}</span></summary>${draw(sub, p + "/")}</details></li>`;
558
+ }).join("")}${tr.notes.map((n) => row(n)).join("")}</ul>`;
559
+ $("tree").innerHTML = notes.length ? draw(tree(notes.map((n) => n.name)), "") : "";
560
+ }
561
+
562
+ // Full screen: the open note alone, no header or sidebar. Off until the icon turns it
563
+ // on; it then holds from note to note until the icon or Esc turns it off. With no note
564
+ // open the page is never full screen: picking one needs the sidebar.
565
+ let full = false;
566
+ function setFull(on: boolean) { full = on; drawPane(); }
567
+ // Width: the reading column (boxed, 68ch) or the whole paper side. Kept per browser.
568
+ let wide = false;
569
+ try { wide = localStorage.getItem("kz-note-wide") === "1"; } catch { /* no storage */ }
570
+ function setWide(on: boolean) {
571
+ wide = on;
572
+ try { localStorage.setItem("kz-note-wide", on ? "1" : "0"); } catch { /* no storage */ }
573
+ drawPane();
574
+ }
575
+
576
+ function drawPane() {
577
+ const has = !!open;
578
+ document.body.classList.toggle("full", has && full);
579
+ document.body.classList.toggle("wide", wide);
580
+ $("pane").hidden = !has;
581
+ $("empty").hidden = has || notes.length > 0;
582
+ $("pick").hidden = has || !notes.length;
583
+ if (!has) return;
584
+ const t = $<HTMLInputElement>("title");
585
+ if (document.activeElement !== t) t.value = open!;
586
+ drawMeta();
587
+ }
588
+
589
+ function drawMeta(state?: string) {
590
+ const m = $("meta");
591
+ if (!open) return;
592
+ if (conflict) {
593
+ m.innerHTML = `<span class="warnw">This note changed somewhere else since you opened it.</span><span class="acts"><button class="btn ghost" id="theirs">Load theirs</button><button class="btn pri" id="mine">Keep mine</button></span>`;
594
+ $("theirs").onclick = () => { const c = conflict!; conflict = null; dirty = false; base = c.mtime; setText(c.text, false); drawMeta(); };
595
+ $("mine").onclick = () => { base = conflict!.mtime; conflict = null; void flush(); };
596
+ return;
597
+ }
598
+ if (confirmDelete) {
599
+ m.innerHTML = `<span>Move this note to <code>notes/.trash</code>?</span><span class="acts"><button class="btn ghost" id="cancel">Cancel</button><button class="btn danger" id="del">Delete</button></span>`;
600
+ $("cancel").onclick = () => { confirmDelete = false; drawMeta(); };
601
+ $("del").onclick = () => { confirmDelete = false; void remove(); };
602
+ return;
603
+ }
604
+ const where = folderOf(open);
605
+ m.innerHTML = `<span>${where ? esc(where) : "Notes"}</span><span>·</span><span id="saved">${state ?? (dirty ? "Not saved yet" : "Saved")}</span><span class="sp"></span><button class="ib" id="wide" title="${wide ? "Boxed width" : "Full width"}" aria-label="${wide ? "Boxed width" : "Full width"}" aria-pressed="${wide}">${ico(wide ? "box" : "wide")}</button><button class="ib" id="full" title="${full ? "Exit full screen (Esc)" : "Full screen"}" aria-label="${full ? "Exit full screen" : "Full screen"}">${ico(full ? "min" : "max")}</button><button class="ib" id="trash" title="Delete note" aria-label="Delete note">${ico("trash")}</button>`;
606
+ $("trash").onclick = () => { confirmDelete = true; drawMeta(); };
607
+ $("full").onclick = () => setFull(!full);
608
+ $("wide").onclick = () => setWide(!wide);
609
+ }
610
+
611
+ function drawLinks() {
612
+ const box = $("links");
613
+ if (!open) { box.hidden = true; return; }
614
+ const names = notes.map((n) => n.name);
615
+ const from = notes.filter((n) => n.name !== open && wikilinks(n.text).some((l) => resolve(l.name, names) === open));
616
+ box.hidden = !from.length;
617
+ box.innerHTML = `<h3>Linked from</h3><ul>${from.map((n) => `<li><button class="nt" data-open="${esc(n.name)}"><span class="nm">${esc(last(n.name))}</span>${folderOf(n.name) ? `<span class="fd">${esc(folderOf(n.name))}</span>` : ""}</button></li>`).join("")}</ul>`;
618
+ }
619
+
620
+ function age(ms: number) {
621
+ const s = (Date.now() - ms) / 1000;
622
+ return s < 60 ? "now" : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`;
623
+ }
624
+
625
+ // ---- wiring
626
+
627
+ document.addEventListener("click", (e) => {
628
+ // The narrow layout's drawer closes on any click outside it.
629
+ const tg = e.target as HTMLElement;
630
+ if (document.body.classList.contains("side-open") && !tg.closest?.(".side,#side")) document.body.classList.remove("side-open");
631
+ const el = (e.target as HTMLElement).closest?.("[data-open],[data-tag]") as HTMLElement | null;
632
+ if (!el || el.closest(".cm-editor")) return;
633
+ if (el.dataset.open) void openNote(el.dataset.open);
634
+ else if (el.dataset.tag) { const q = $<HTMLInputElement>("q"); q.value = q.value === "#" + el.dataset.tag ? "" : "#" + el.dataset.tag; drawSide(); }
635
+ });
636
+ $("tree").addEventListener("toggle", (e) => {
637
+ const d = e.target as HTMLDetailsElement;
638
+ if (d.dataset.folder) d.open ? shut.delete(d.dataset.folder) : shut.add(d.dataset.folder);
639
+ }, true);
640
+ $("q").addEventListener("input", drawSide);
641
+ $("new").onclick = () => void newNote();
642
+ $("new2").onclick = () => void newNote();
643
+ $("side").onclick = () => document.body.classList.toggle("side-open");
644
+ const title = $<HTMLInputElement>("title");
645
+ title.addEventListener("keydown", (e) => {
646
+ if (e.key === "Enter") { e.preventDefault(); title.blur(); }
647
+ if (e.key === "Escape") { title.value = open ?? ""; title.blur(); }
648
+ });
649
+ title.addEventListener("blur", () => void rename(title.value));
650
+ document.addEventListener("keydown", (e) => {
651
+ // Esc in the name or search field is theirs (it undoes a rename there).
652
+ if (e.key !== "Escape" || (e.target as HTMLElement).matches?.("input")) return;
653
+ document.body.classList.remove("side-open");
654
+ if (document.body.classList.contains("full")) setFull(false);
655
+ });
656
+ // Closing with text not on disk: the browser asks first.
657
+ addEventListener("beforeunload", (e) => { if (dirty) e.preventDefault(); });
658
+ // Leaving with unsaved text: a keepalive request outlives the page.
659
+ addEventListener("pagehide", () => {
660
+ if (open && dirty && !conflict) fetch("/vault", { method: "POST", keepalive: true, headers: { "content-type": "application/json" }, body: JSON.stringify({ dir, op: "save", name: open, text: view.state.sliceDoc(), base }) });
661
+ });
662
+
663
+ // The project: ?dir= in the URL, else the one the board picked last, else where it started.
664
+ (async () => {
665
+ try { const th = localStorage.getItem("kz-theme"); if (th) document.documentElement.dataset.theme = th; } catch { /* no storage */ }
666
+ let S: { dir: string | null; projects: { dir: string; label: string }[] };
667
+ try { S = await (await fetch("/state")).json(); } catch { toast("The board is not running"); return; }
668
+ let kept: string | null = null;
669
+ try { kept = localStorage.getItem("kz-proj"); } catch { /* no storage */ }
670
+ const want = new URLSearchParams(location.search).get("dir");
671
+ const known = (d: string | null) => !!d && S.projects.some((p) => p.dir === d);
672
+ dir = [want, kept, S.dir].find(known) ?? S.projects[0]?.dir ?? "";
673
+ const sel = $<HTMLSelectElement>("proj");
674
+ sel.innerHTML = S.projects.map((p) => `<option value="${esc(p.dir)}"${p.dir === dir ? " selected" : ""}>${esc(p.label)}</option>`).join("");
675
+ sel.onchange = async () => {
676
+ if (!(await openNote(null))) { sel.value = dir; return; }
677
+ dir = sel.value;
678
+ // window.: `history` here is CodeMirror's undo extension.
679
+ const u = new URL(location.href); u.searchParams.set("dir", dir); window.history.replaceState(null, "", u);
680
+ await load();
681
+ };
682
+ await load();
683
+ let again: string | null = null;
684
+ try { again = sessionStorage.getItem("kz-note:" + dir); } catch { /* no storage */ }
685
+ // ?note= is the board's "Linked from notes"; otherwise the note open last in this tab.
686
+ const asked = new URLSearchParams(location.search).get("note");
687
+ if (asked && notes.some((n) => n.name === asked)) again = asked;
688
+ if (again && notes.some((n) => n.name === again)) await openNote(again);
689
+ // Every write under a state dir arrives as `changed`, this page's own saves included.
690
+ const listen = () => {
691
+ const es = new EventSource("/events");
692
+ es.onmessage = (ev) => { if (ev.data === "changed") void load(); };
693
+ es.onerror = () => { es.close(); setTimeout(listen, 3000); };
694
+ };
695
+ listen();
696
+ })();