pi-weave 0.1.7 → 0.1.9

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.
Files changed (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The SSE socket, wired to {@link ./live.model} (weave-workspace §6).
3
+ *
4
+ * Everything that decides anything lives in `live.model.ts`. This file owns
5
+ * exactly one thing the model cannot: a real `EventSource`, its three
6
+ * listeners, and the fact that it must be closed. Keeping that separation
7
+ * sharp is what lets the whole liveness layer be unit-tested with no DOM —
8
+ * §10's constraint, and the reason `.model.ts` files exist at all.
9
+ *
10
+ * ## The socket is injected
11
+ *
12
+ * {@link startLive} takes a factory rather than calling `new EventSource`.
13
+ * The DOM one is the default at the call site in the shell, and a test passes
14
+ * a fake with the same four members. This is the same port-shaped injection
15
+ * `api.ts` uses for `fetch` and for the same reason: without it, the only way
16
+ * to reach the reconnect path would be a browser.
17
+ *
18
+ * ## Why the connection state is written here and not in the model
19
+ *
20
+ * The model is pure and returns a next state; something has to publish it to
21
+ * the `connection` signal that the status bar reads. That publication is this
22
+ * file's other job, and it is one line — which is the correct amount of logic
23
+ * for a file with no test harness behind it.
24
+ */
25
+
26
+ import type { ChangeScope } from "../shared/wire";
27
+ import { CHANGE_EVENT_NAME } from "../shared/wire";
28
+ import type { LiveEvent, LiveState, RefetchPlan } from "./live.model";
29
+ import { EVENTS_PATH, initialLiveState, isNoop, parseFrame, reduceLive, withStamp } from "./live.model";
30
+ import { connection } from "./state";
31
+
32
+ /**
33
+ * The slice of `EventSource` this module uses.
34
+ *
35
+ * Structural, so the platform's satisfies it without a cast and a fake is an
36
+ * object literal. Two details are load-bearing:
37
+ *
38
+ * **`addEventListener` for all three events, not `onopen`/`onerror`.** The
39
+ * handler properties are the more obvious spelling and they do not typecheck:
40
+ * a property of function type is checked *contravariantly* under
41
+ * `strictFunctionTypes`, so a port declaring `onopen: ((e: unknown) => void)`
42
+ * rejects the platform's `(ev: Event) => any` — `unknown` is not assignable
43
+ * to `Event`. Method declarations are compared bivariantly, so the single
44
+ * `addEventListener` form accepts the real `EventSource` without a cast and
45
+ * without this module ever naming a DOM type.
46
+ *
47
+ * **`{data: string}` rather than `MessageEvent`.** This file is compiled by
48
+ * `tsconfig.web.json` for the bundle *and* pulled into the root project when
49
+ * a test imports it, and the root project has no `DOM` lib. The narrow shape
50
+ * is the only one both projects can resolve. `open` and `error` carry no
51
+ * payload this module reads, so they receive it and ignore it.
52
+ */
53
+ export interface EventSourceLike {
54
+ /** `0` connecting, `1` open, `2` closed. See `live.model.ts`'s constants. */
55
+ readonly readyState: number;
56
+ addEventListener(type: string, listener: (event: { data: string }) => void): void;
57
+ close(): void;
58
+ }
59
+
60
+ /** Creates a stream for a URL. The DOM's `EventSource` constructor fits. */
61
+ export type EventSourceFactory = (url: string) => EventSourceLike;
62
+
63
+ /** What {@link startLive} needs from its host. */
64
+ export interface LiveOptions {
65
+ /** Injected socket constructor. */
66
+ open: EventSourceFactory;
67
+ /**
68
+ * Run a refetch plan. Async and awaited nowhere — a fetch that outlives the
69
+ * socket is the caller's problem to make idempotent, and the shell's is.
70
+ */
71
+ refetch: (plan: RefetchPlan) => void;
72
+ /** Whether a note is open, read fresh per event. See `reduceLive`. */
73
+ hasSelection: () => boolean;
74
+ /** The stream URL. Defaults to {@link EVENTS_PATH}. */
75
+ path?: string;
76
+ }
77
+
78
+ /** A running stream. */
79
+ export interface LiveHandle {
80
+ /** Close the socket and mark the connection offline. Idempotent. */
81
+ stop(): void;
82
+ /** Force a full refetch — the header's `⟳`. */
83
+ refresh(): void;
84
+ /** Record a stamp the client now holds, so the next frame can dedupe it. */
85
+ seen(stamp: string): void;
86
+ /** Current state. For tests and the status bar's tooltip. */
87
+ state(): LiveState;
88
+ }
89
+
90
+ /**
91
+ * Attach to the event stream.
92
+ *
93
+ * Note what this does *not* do: reconnect. `EventSource` reconnects natively
94
+ * with its own backoff, which is most of why §6 chose it over a WebSocket, so
95
+ * a retry loop here would be a second one racing the browser's.
96
+ */
97
+ export function startLive(opts: LiveOptions): LiveHandle {
98
+ let state = initialLiveState();
99
+ let stopped = false;
100
+
101
+ const source = opts.open(opts.path ?? EVENTS_PATH);
102
+
103
+ /** The single path from a socket event to a state change and a refetch. */
104
+ const dispatch = (event: LiveEvent): void => {
105
+ const next = reduceLive(state, event, opts.hasSelection());
106
+ state = next.state;
107
+ connection.value = state.connection;
108
+ if (!isNoop(next.plan)) opts.refetch(next.plan);
109
+ };
110
+
111
+ source.addEventListener("open", () => dispatch({ type: "open" }));
112
+ source.addEventListener("error", () => dispatch({ type: "error", readyState: source.readyState }));
113
+ source.addEventListener(CHANGE_EVENT_NAME, (event) => {
114
+ const frame = parseFrame(event.data);
115
+ // A frame we cannot read is dropped rather than escalated: the next one
116
+ // carries the same "something moved" meaning, and the heartbeat proves
117
+ // the socket is still alive meanwhile.
118
+ if (frame !== null) dispatch({ type: "frame", event: frame });
119
+ });
120
+
121
+ return {
122
+ stop() {
123
+ if (stopped) return;
124
+ stopped = true;
125
+ source.close();
126
+ dispatch({ type: "closed" });
127
+ },
128
+ refresh() {
129
+ dispatch({ type: "refresh" });
130
+ },
131
+ seen(stamp: string) {
132
+ state = withStamp(state, stamp);
133
+ },
134
+ state() {
135
+ return state;
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * `new EventSource(url)`, as an {@link EventSourceFactory}.
142
+ *
143
+ * The one place in the liveness layer that names a DOM global, isolated here
144
+ * so that everything else in it compiles and runs under Node.
145
+ */
146
+ export function domEventSource(url: string): EventSourceLike {
147
+ return new EventSource(url);
148
+ }
149
+
150
+ /** Re-exported so the shell imports its liveness vocabulary from one module. */
151
+ export type { ChangeScope, LiveState, RefetchPlan };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Client entry point (weave-workspace §2, §9).
3
+ *
4
+ * Reads the bootstrap block the HTML shell embedded, then mounts
5
+ * {@link Shell}. Everything else — layout, liveness, fetching — is reached
6
+ * from there.
7
+ *
8
+ * The three DOM reads that cannot be injected any further up (`getElementById`,
9
+ * `innerWidth`, `navigator.platform`) happen here and are passed down as
10
+ * props, so `Shell` and everything below it takes its world as parameters.
11
+ */
12
+
13
+ import { render } from "preact";
14
+ import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
15
+ import { readBootstrap } from "./bootstrap";
16
+ import { Shell } from "./shell/Shell";
17
+ import { installTheme } from "./shell/theme";
18
+
19
+ const host = document.getElementById("app");
20
+ if (host !== null) {
21
+ // Before the first render: the sheet carries the grid and the palette, and
22
+ // installing it after would show one unstyled frame. It copies the
23
+ // per-response CSP nonce off the server's own `<style>` — see `theme.ts`.
24
+ installTheme(document);
25
+ const boot = readBootstrap(document.getElementById(BOOTSTRAP_ELEMENT_ID)?.textContent ?? null);
26
+ render(<Shell cwd={boot.cwd} initialWidth={window.innerWidth} platform={navigator.platform} />, host);
27
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The `<textarea>` editor and its toolbar (weave-workspace §0 V10, §11 P5.4).
3
+ *
4
+ * Props in, JSX out. Dirty tracking, the save lifecycle, conflict resolution
5
+ * and every string are `editor.model.ts`; the requests are `editor.ts`. What
6
+ * is left here is three elements and three handlers.
7
+ *
8
+ * A `<textarea>`, not CodeMirror 6, and §0 V10 settles it with a number:
9
+ * CM6 is 118 KB gzip — more than the entire rest of the client — against a
10
+ * 150 KB budget already 62 % spent. The textarea is what proves the *save
11
+ * path* is correct, which is the part P5 is actually gated on. Syntax
12
+ * highlighting can arrive later against a round trip that is already known
13
+ * to be lossless; the reverse order would be building an editor over a bug.
14
+ */
15
+
16
+ import type { EditorEvent, EditorPrompt, EditorToolbar } from "./editor.model";
17
+ import { OPEN_HINT, OPEN_LABEL } from "./editor.model";
18
+
19
+ export interface EditorProps {
20
+ toolbar: EditorToolbar;
21
+ /** The `<textarea>`'s content — `state.draft`. */
22
+ draft: string;
23
+ /** The one prompt to show, or `null`. Ordered by `editorPrompt`. */
24
+ prompt: EditorPrompt | null;
25
+ /** Dispatch into the controller. The only way anything here changes state. */
26
+ send: (event: EditorEvent) => void;
27
+ }
28
+
29
+ /** The conflict / discard / external-change prompt. */
30
+ function Prompt({ prompt, send }: { prompt: EditorPrompt; send: (event: EditorEvent) => void }) {
31
+ return (
32
+ <div class={`weave-note-prompt weave-note-prompt-${prompt.kind}`} role="alert">
33
+ <p class="weave-note-prompt-text">{prompt.message}</p>
34
+ <p class="weave-note-prompt-actions">
35
+ {prompt.actions.map((action) => (
36
+ <button key={action.label} type="button" class="weave-note-action" onClick={() => send(action.event)}>
37
+ {action.label}
38
+ </button>
39
+ ))}
40
+ </p>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ /** The toolbar: edit/done, save, open-in-$EDITOR, and the status word. */
46
+ export function EditorBar(props: EditorProps) {
47
+ const { toolbar, send } = props;
48
+ return (
49
+ <div class="weave-note-bar">
50
+ <button
51
+ type="button"
52
+ class="weave-note-toggle"
53
+ aria-pressed={toolbar.editing}
54
+ onClick={() => send({ type: "toggle" })}
55
+ >
56
+ {toolbar.toggleLabel}
57
+ </button>
58
+ {toolbar.editing ? (
59
+ <button type="button" class="weave-note-save" disabled={!toolbar.canSave} onClick={() => send({ type: "save" })}>
60
+ {toolbar.saveLabel}
61
+ </button>
62
+ ) : null}
63
+ <button type="button" class="weave-note-open" title={OPEN_HINT} onClick={() => send({ type: "open" })}>
64
+ {OPEN_LABEL}
65
+ </button>
66
+ {toolbar.dirty ? (
67
+ <span class="weave-note-dirty" title="unsaved changes" aria-hidden="true">
68
+ •
69
+ </span>
70
+ ) : null}
71
+ {toolbar.message === null ? null : <span class={`weave-note-status weave-note-status-${toolbar.tone}`}>{toolbar.message}</span>}
72
+ {props.prompt === null ? null : <Prompt prompt={props.prompt} send={send} />}
73
+ </div>
74
+ );
75
+ }
76
+
77
+ /**
78
+ * The editing surface.
79
+ *
80
+ * `⌘S` is handled here as well as globally, and deliberately: the global
81
+ * `keydown` listener sees every keystroke in the workspace, so claiming a
82
+ * modifier combination there is a workspace-wide claim. Handling it on the
83
+ * textarea too means the save fires from the element that owns the text even
84
+ * if the global map is later narrowed — and `keys.model.ts` returns `null`
85
+ * for anything it does not claim, so the two never fight over one event.
86
+ */
87
+ export function Editor(props: EditorProps) {
88
+ return (
89
+ <textarea
90
+ class="weave-note-editor"
91
+ aria-label="Note body"
92
+ spellcheck
93
+ value={props.draft}
94
+ onInput={(event) => props.send({ type: "draft", text: (event.target as HTMLTextAreaElement).value })}
95
+ onKeyDown={(event) => {
96
+ if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
97
+ event.preventDefault();
98
+ props.send({ type: "save" });
99
+ }}
100
+ />
101
+ );
102
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The note column (weave-workspace §1.2, P2.4, P5).
3
+ *
4
+ * Props in, JSX out. The markdown pipeline, the sanitiser config, the
5
+ * wikilink resolution and every string are `note.model.ts`; the editor's
6
+ * dirty tracking, save lifecycle and conflict resolution are
7
+ * `editor.model.ts`. This file wires a click handler, sets `innerHTML` with
8
+ * content that has already been through all three of that module's layers,
9
+ * and swaps the rendered body for a `<textarea>` when the editor is open.
10
+ *
11
+ * `dangerouslySetInnerHTML` is used deliberately and exactly once. The
12
+ * alternative is parsing marked's output into a Preact tree, which means a
13
+ * second HTML parser in the bundle and a second place for a sanitisation
14
+ * mistake to hide. One clearly-marked line whose input is
15
+ * `renderNote(DOMPurify, …)` is easier to audit than a hundred lines that
16
+ * avoid the word "dangerously".
17
+ *
18
+ * The read view is rendered from the **note**, not from the draft: what the
19
+ * `<textarea>` holds is unsaved and un-sanitised, and piping it through
20
+ * `renderNote` on every keystroke would be a live preview paid for with a
21
+ * markdown parse and a DOMPurify pass per character. §11 P5.4 mentions a live
22
+ * preview; the honest version of it is `⌘E`, which is instant and shows the
23
+ * text that actually exists.
24
+ */
25
+
26
+ import DOMPurify from "dompurify";
27
+ import type { GraphPayload, NotePayload } from "../../shared/wire";
28
+ import type { EditorEvent, EditorPrompt, EditorToolbar } from "./editor.model";
29
+ import { Editor, EditorBar } from "./Editor";
30
+ import type { NoteHeaderView } from "./note.model";
31
+ import { noteEmptyMessage, noteHeader, renderNote, tagLabel, wikiIndex, wikilinkTargetOf } from "./note.model";
32
+
33
+ export interface NoteProps {
34
+ note: NotePayload | null;
35
+ graph: GraphPayload | null;
36
+ selectedId: string | null;
37
+ onSelect: (id: string) => void;
38
+ /** Epoch ms for relative times. Injected so the render is deterministic. */
39
+ now: number;
40
+ /** The editor's view model, or `null` when the shell wired no editor. */
41
+ toolbar: EditorToolbar | null;
42
+ prompt: EditorPrompt | null;
43
+ /** The `<textarea>`'s content. Rendered only while `toolbar.editing`. */
44
+ draft: string;
45
+ send: (event: EditorEvent) => void;
46
+ }
47
+
48
+ function Header({ view }: { view: NoteHeaderView }) {
49
+ return (
50
+ <header class="weave-note-head">
51
+ <h3 class="weave-note-title">{view.title}</h3>
52
+ <p class="weave-note-meta">
53
+ <span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
54
+ {view.provenanceGlyph} {view.provenance}
55
+ </span>
56
+ <span class="weave-note-time" title={view.updatedIso}>
57
+ updated {view.updated}
58
+ </span>
59
+ <span class="weave-note-time" title={view.createdIso}>
60
+ created {view.created}
61
+ </span>
62
+ </p>
63
+ <p class="weave-note-tags">
64
+ {view.tags.map((tag) => (
65
+ <span key={tag} class="weave-tag">
66
+ {tagLabel(tag)}
67
+ </span>
68
+ ))}
69
+ </p>
70
+ </header>
71
+ );
72
+ }
73
+
74
+ export function Note(props: NoteProps) {
75
+ const note = props.note?.note ?? null;
76
+ const empty = noteEmptyMessage(props.selectedId, note);
77
+ if (note === null) return <p class="weave-note-empty">{empty}</p>;
78
+
79
+ const index = wikiIndex(props.graph, note.slug);
80
+ // Bound once so TypeScript narrows it for both uses below: `toolbar.editing`
81
+ // is what decides whether the textarea renders, and reading it twice off
82
+ // `props` would need a non-null assertion at the second read.
83
+ const toolbar = props.toolbar;
84
+ const shell = { draft: props.draft, prompt: props.prompt, send: props.send };
85
+ return (
86
+ <article class="weave-note">
87
+ <Header view={noteHeader(note, props.now)} />
88
+ {toolbar === null ? null : <EditorBar toolbar={toolbar} {...shell} />}
89
+ {toolbar !== null && toolbar.editing ? (
90
+ <Editor toolbar={toolbar} {...shell} />
91
+ ) : (
92
+ <div
93
+ class="weave-note-body"
94
+ onClick={(event) => {
95
+ // A wikilink carries no href, so nothing is navigating; this only
96
+ // has to route the click onto the §1.3 context bus.
97
+ const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
98
+ if (target !== null) props.onSelect(target);
99
+ }}
100
+ onKeyDown={(event) => {
101
+ if (event.key !== "Enter" && event.key !== " ") return;
102
+ const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
103
+ if (target === null) return;
104
+ event.preventDefault();
105
+ props.onSelect(target);
106
+ }}
107
+ // Sanitised by `renderNote`'s three layers — see note.model.ts.
108
+ dangerouslySetInnerHTML={{ __html: renderNote(DOMPurify, note.body, index) }}
109
+ />
110
+ )}
111
+ </article>
112
+ );
113
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The editor's effects: three requests and an unload listener
3
+ * (weave-workspace §10, §11 P5).
4
+ *
5
+ * `editor.model.ts` decides *whether* to save, *what* to send and *what a
6
+ * response means*; it cannot issue a request or subscribe to `beforeunload`
7
+ * without ceasing to be pure. This is the other half — a controller whose
8
+ * every capability is injected, in the shape `search.ts` established.
9
+ *
10
+ * ## Why `editor.controller.ts` and not `editor.ts`
11
+ *
12
+ * The pairing everywhere else in the client is `x.model.ts` + `x.ts`
13
+ * (`search.model.ts`/`search.ts`, `live.model.ts`/`live.ts`), and that is the
14
+ * name this file wanted. It cannot have it: the component beside it is
15
+ * `Editor.tsx`, and on a case-insensitive filesystem — which is the macOS
16
+ * default — `editor.ts` and `Editor.tsx` are the same path prefix. TypeScript
17
+ * says so directly (`TS1149: File name … differs from already included file
18
+ * name … only in casing`) and the build is a hard error rather than a
19
+ * warning. Renaming the *component* instead would break the `.tsx` = view
20
+ * convention the whole client is read by, so the controller takes the longer
21
+ * name. The capabilities it needs are:
22
+ *
23
+ * - a **`FetchLike`**, the same port `api.ts` takes, so no DOM is involved;
24
+ * - a **`select`**, so completing a parked navigation goes through §1.3's
25
+ * context bus rather than this module knowing what a selection is.
26
+ *
27
+ * The result is that the editor's whole asynchronous behaviour — the save
28
+ * round trip, the `409`, the stale-response guard, the unload block — is
29
+ * covered by ordinary unit tests, and `Editor.tsx` is a `<textarea>` and
30
+ * three handlers.
31
+ *
32
+ * ## Why the controller owns the dispatch loop
33
+ *
34
+ * Every event goes through {@link EditorHandle.send}, which reduces,
35
+ * publishes, and then performs whichever effect the transition asked for.
36
+ * There is no other path that mutates state. That is what makes "a save
37
+ * cannot start while a conflict is unresolved" and "a stale response cannot
38
+ * overwrite the draft" enforceable: the checks live in the reducer, and every
39
+ * response arrives back through here.
40
+ */
41
+
42
+ import type { FetchLike } from "../api";
43
+ import { openNote, saveNote } from "../api";
44
+ import type { EditorEffect, EditorEvent, EditorState } from "./editor.model";
45
+ import { initialEditorState, reduceEditor, shouldBlockUnload } from "./editor.model";
46
+
47
+ /**
48
+ * The slice of `beforeunload` this module touches.
49
+ *
50
+ * Structural, and `returnValue` is included because that is the only form
51
+ * every browser still honours: `preventDefault()` alone is the spec's answer
52
+ * and Chrome ignored it for years. Setting both is the portable spelling.
53
+ */
54
+ export interface BeforeUnloadEventLike {
55
+ preventDefault(): void;
56
+ returnValue?: unknown;
57
+ }
58
+
59
+ /** The slice of `window` the unload guard subscribes to. */
60
+ export interface UnloadHost {
61
+ addEventListener(type: "beforeunload", listener: (event: BeforeUnloadEventLike) => void): void;
62
+ removeEventListener(type: "beforeunload", listener: (event: BeforeUnloadEventLike) => void): void;
63
+ }
64
+
65
+ /** What {@link createEditor} needs. Every capability injected. */
66
+ export interface EditorOptions {
67
+ fetch: FetchLike;
68
+ /** The §1.3 context bus. Called to complete a navigation the editor held. */
69
+ select: (id: string | null) => void;
70
+ /** Called after every state change, so the component can re-render. */
71
+ onChange: (state: EditorState) => void;
72
+ }
73
+
74
+ /** A running editor. */
75
+ export interface EditorHandle {
76
+ /** Current state. Read at render. */
77
+ state(): EditorState;
78
+ /** Dispatch an event. The only way state moves. */
79
+ send(event: EditorEvent): void;
80
+ }
81
+
82
+ export function createEditor(opts: EditorOptions): EditorHandle {
83
+ let state = initialEditorState();
84
+
85
+ const send = (event: EditorEvent): void => {
86
+ const next = reduceEditor(state, event);
87
+ state = next.state;
88
+ opts.onChange(state);
89
+ if (next.effect !== null) perform(next.effect);
90
+ };
91
+
92
+ const perform = (effect: EditorEffect): void => {
93
+ if (effect.type === "select") {
94
+ opts.select(effect.id);
95
+ return;
96
+ }
97
+ // Fire and forget: `api.ts` returns failures as values, so there is
98
+ // nothing to reject and nothing to catch. `void` documents the floating
99
+ // promise rather than hiding it — the same shape `workspace.ts` and
100
+ // `search.ts` use.
101
+ if (effect.type === "open") {
102
+ void (async () => {
103
+ const result = await openNote(opts.fetch, effect.slug);
104
+ send({ type: "opened", ok: result.ok && result.data.opened });
105
+ })();
106
+ return;
107
+ }
108
+ void (async () => {
109
+ const result = await saveNote(opts.fetch, effect.slug, effect.input);
110
+ if (result.ok) {
111
+ send({ type: "saved", payload: result.data });
112
+ return;
113
+ }
114
+ // The `409` is not an error in the sense the other five kinds are —
115
+ // it is the server handing back the information the user needs in
116
+ // order to choose. `api.ts` gives it its own arm for exactly this
117
+ // branch.
118
+ send(result.kind === "conflict" ? { type: "conflicted", conflict: result.conflict } : { type: "failed", message: result.message });
119
+ })();
120
+ };
121
+
122
+ return { state: () => state, send };
123
+ }
124
+
125
+ /**
126
+ * Block `beforeunload` while the draft is dirty. Returns an unsubscribe.
127
+ *
128
+ * The predicate is a thunk, not a value: a listener registered at mount
129
+ * outlives every render, so a captured state would answer with the editor as
130
+ * it was when the tab opened — which is always clean, making the guard a
131
+ * no-op that looks installed.
132
+ *
133
+ * No custom message: every browser has ignored the string since 2017 and
134
+ * shows its own wording. Returning one would be writing code whose only
135
+ * effect is to suggest, to the next reader, that it does something.
136
+ */
137
+ export function watchUnload(host: UnloadHost, dirty: () => boolean): () => void {
138
+ const listener = (event: BeforeUnloadEventLike): void => {
139
+ if (!dirty()) return;
140
+ event.preventDefault();
141
+ // The legacy half. Chrome required a truthy `returnValue` long after the
142
+ // spec settled on `preventDefault`, and setting both is the only
143
+ // spelling that works everywhere.
144
+ event.returnValue = "";
145
+ };
146
+ host.addEventListener("beforeunload", listener);
147
+ return () => host.removeEventListener("beforeunload", listener);
148
+ }
149
+
150
+ /** {@link shouldBlockUnload}, re-exported so the shell imports one module. */
151
+ export { shouldBlockUnload };