pi-weave 0.1.12 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,275 +0,0 @@
1
- /**
2
- * The liveness state machine, as pure data (weave-workspace §6).
3
- *
4
- * `src/web/server/sse.ts` pushes `{scope, stamp}` frames at the browser; this
5
- * module decides two things about them, and nothing else:
6
- *
7
- * 1. **What must be refetched** — a {@link RefetchPlan}.
8
- * 2. **What the status bar should say** — a `ConnectionState`.
9
- *
10
- * It is a `.model.ts` for the reason §10 gives: there is no DOM test
11
- * environment in this repository and we may not add one, so every decision
12
- * worth getting right lives in a pure function over plain objects and
13
- * {@link ./live} is left with nothing but socket plumbing. Everything here
14
- * runs under the root `tsconfig.json`, which has no `DOM` lib — hence no
15
- * `EventSource`, no `MessageEvent`, and `readyState` arriving as a plain
16
- * number.
17
- *
18
- * ## Frames are hints, not deltas
19
- *
20
- * §6 is explicit that macOS `fs.watch` coalesces and can drop events, so a
21
- * frame means "something in this scope moved, re-read it" and never "here is
22
- * what changed". That single sentence is why {@link planFor} returns *which
23
- * endpoints to re-request* rather than a patch, and why there is no code here
24
- * that tries to apply a frame to a graph in place. A client that treated
25
- * frames as deltas would diverge silently the first time the OS dropped one,
26
- * and the divergence would be invisible until someone noticed a stale node an
27
- * hour later.
28
- *
29
- * ## Reconnect refetches everything
30
- *
31
- * The server keeps **no replay buffer** and ignores `Last-Event-ID` (see the
32
- * `sse.ts` header). So a reopened stream carries no information about the gap,
33
- * and the only correct response to "I was away for an unknown interval" is to
34
- * re-request everything. That is what {@link reduceLive} produces for a
35
- * reopen, and it is cheap rather than wasteful: the graph request carries
36
- * `If-None-Match`, so an unchanged workspace costs one `304` with an empty
37
- * body. Buffering would add retention, eviction and a resume path to arrive
38
- * at the same fetch.
39
- */
40
-
41
- import type { ChangeEvent, ChangeScope } from "../shared/wire";
42
- import { isChangeEvent } from "../shared/wire";
43
- import type { ConnectionState } from "./state";
44
-
45
- /** The SSE endpoint (§5.3). */
46
- export const EVENTS_PATH = "/events";
47
-
48
- // --- `EventSource.readyState`, without the DOM lib ------------------------------
49
-
50
- /**
51
- * The three `readyState` values, restated as local constants.
52
- *
53
- * `EventSource.CONNECTING` and friends are DOM globals and this module is
54
- * compiled by a project with no `DOM` lib, so the numbers are written down
55
- * here. They are fixed by the HTML specification and cannot drift — unlike,
56
- * say, an HTTP status, there is no version of the standard in which `CLOSED`
57
- * stops being `2`.
58
- */
59
- export const SOCKET_CONNECTING = 0;
60
- export const SOCKET_OPEN = 1;
61
- export const SOCKET_CLOSED = 2;
62
-
63
- // --- refetch plans ---------------------------------------------------------------
64
-
65
- /**
66
- * Which endpoints a transition invalidates.
67
- *
68
- * Two booleans rather than a set of route names, because at P1 there are
69
- * exactly two things the shell holds: the graph payload and the selected
70
- * note's body. This will grow — P2 adds the `.okf` file view and the tag
71
- * index — and the honest thing is to let it grow then rather than to invent a
72
- * registry now for endpoints that do not exist.
73
- */
74
- export interface RefetchPlan {
75
- /** Re-request `GET /api/graph`, conditionally on the held stamp. */
76
- readonly graph: boolean;
77
- /** Re-request `GET /api/note/:slug` for the current selection. */
78
- readonly note: boolean;
79
- }
80
-
81
- /** Nothing to do. Returned for a deduped frame and for an idle transition. */
82
- export const NO_REFETCH: RefetchPlan = { graph: false, note: false };
83
-
84
- /** Whether a plan would issue no requests at all. */
85
- export function isNoop(plan: RefetchPlan): boolean {
86
- return !plan.graph && !plan.note;
87
- }
88
-
89
- /**
90
- * Scope → what it can possibly have invalidated.
91
- *
92
- * The table is deliberately thin, and worth stating plainly rather than
93
- * dressing up: **every** scope invalidates the graph, because the graph
94
- * carries the vault's notes, the repository index *and* the git-state node
95
- * plus its staleness report. The one real distinction is the note body — a
96
- * `repo` or `git` change cannot alter the text of a note on disk, so the note
97
- * column is left alone, while a `vault` change can and must re-read it.
98
- *
99
- * Inventing finer granularity here would be fiction. When P2 gives the client
100
- * more than two things to hold, this table earns more rows.
101
- *
102
- * @param hasSelection whether a note is currently open in the note column;
103
- * with nothing selected there is no note to refetch.
104
- */
105
- export function planFor(scope: ChangeScope, hasSelection: boolean): RefetchPlan {
106
- return { graph: true, note: scope === "vault" && hasSelection };
107
- }
108
-
109
- /**
110
- * The "I have been away" plan: everything the client holds.
111
- *
112
- * Used for a reopened stream and for the header's manual `⟳`. Both mean the
113
- * same thing — the client cannot reason about what it missed — so they get
114
- * the same answer rather than two subtly different ones.
115
- */
116
- export function planForEverything(hasSelection: boolean): RefetchPlan {
117
- return { graph: true, note: hasSelection };
118
- }
119
-
120
- // --- state --------------------------------------------------------------------------
121
-
122
- /**
123
- * Everything the liveness layer remembers.
124
- *
125
- * `stamp` is the dedupe key and doubles as the `If-None-Match` value the
126
- * shell sends. It is kept across a disconnect on purpose: a reconnect
127
- * refetches everything, but it refetches *conditionally*, so holding the last
128
- * known stamp turns "refetch everything" into a `304` whenever nothing
129
- * actually moved while we were away.
130
- */
131
- export interface LiveState {
132
- readonly connection: ConnectionState;
133
- /** Last stamp the client has applied, or `null` before the first graph. */
134
- readonly stamp: string | null;
135
- /**
136
- * Whether the stream has ever been open.
137
- *
138
- * The discriminator between "first connect" and "reconnect", which is the
139
- * only reason this flag exists. The first open needs no forced refetch —
140
- * the shell fetches the graph on mount — while every later open does.
141
- */
142
- readonly opened: boolean;
143
- }
144
-
145
- /** Before the socket is created. Matches `state.ts`'s documented defaults. */
146
- export function initialLiveState(): LiveState {
147
- return { connection: "live", stamp: null, opened: false };
148
- }
149
-
150
- /**
151
- * Record a stamp the client now holds.
152
- *
153
- * Called by the shell after any successful graph fetch, including the one on
154
- * mount. That seeding is what makes the server's hello frame free: `sse.ts`
155
- * sends the current stamp to every newly attached client, and a client that
156
- * already fetched that stamp at mount would otherwise treat it as news and
157
- * fetch a second time.
158
- *
159
- * The race is still possible — the hello frame can beat the mount fetch — and
160
- * it is deliberately not defended against. Losing it costs one conditional
161
- * GET that answers `304`, and the machinery to prevent that would be worth
162
- * more than the request it saves.
163
- */
164
- export function withStamp(state: LiveState, stamp: string): LiveState {
165
- return state.stamp === stamp ? state : { ...state, stamp };
166
- }
167
-
168
- // --- events -------------------------------------------------------------------------
169
-
170
- /**
171
- * What can happen to the stream.
172
- *
173
- * `error` carries the socket's `readyState` because that is the *only* way to
174
- * tell the two failures apart: `EventSource` fires the same `error` event
175
- * when it is about to retry (`CONNECTING`) and when it has given up
176
- * (`CLOSED`). Reading the flag is not an optimisation — without it the status
177
- * bar cannot distinguish "back in a moment" from "this session is over", and
178
- * would have to pick one and be wrong half the time.
179
- */
180
- export type LiveEvent =
181
- | { readonly type: "open" }
182
- | { readonly type: "error"; readonly readyState: number }
183
- | { readonly type: "frame"; readonly event: ChangeEvent }
184
- /** The header's `⟳`. Not a socket event; refetches without touching state. */
185
- | { readonly type: "refresh" }
186
- /** The client closed the stream itself — navigation, or `stop()`. */
187
- | { readonly type: "closed" };
188
-
189
- /** The result of one transition: the next state, and what to go and fetch. */
190
- export interface LiveTransition {
191
- readonly state: LiveState;
192
- readonly plan: RefetchPlan;
193
- }
194
-
195
- /**
196
- * Map a socket `readyState` at the moment of an error to a status.
197
- *
198
- * `CLOSED` is terminal: `EventSource` only reaches it after it has stopped
199
- * retrying, so `"offline"` is a statement of fact rather than a guess. Any
200
- * other value means a retry is scheduled, which is `"reconnecting"`. An
201
- * unrecognised number lands there too — the safe direction, because a status
202
- * bar that says "reconnecting" while the client is in fact dead is a smaller
203
- * lie than one that says "offline" while a retry is in flight, and the next
204
- * `open` or `error` corrects it either way.
205
- */
206
- export function connectionForError(readyState: number): ConnectionState {
207
- return readyState === SOCKET_CLOSED ? "offline" : "reconnecting";
208
- }
209
-
210
- /**
211
- * The whole liveness state machine.
212
- *
213
- * @param hasSelection whether the note column currently holds a note. Passed
214
- * in rather than stored, because it is owned by `selectedId` in
215
- * `state.ts` and duplicating it here would create a second copy to keep in
216
- * sync — the exact failure §1.3 avoids by having one signal.
217
- */
218
- export function reduceLive(state: LiveState, event: LiveEvent, hasSelection: boolean): LiveTransition {
219
- switch (event.type) {
220
- case "open": {
221
- // A reopen means an unknown gap: see the module header. The first open
222
- // is not a gap — nothing preceded it — so it only flips the status.
223
- const plan = state.opened ? planForEverything(hasSelection) : NO_REFETCH;
224
- return { state: { ...state, connection: "live", opened: true }, plan };
225
- }
226
-
227
- case "error":
228
- return { state: { ...state, connection: connectionForError(event.readyState) }, plan: NO_REFETCH };
229
-
230
- case "frame": {
231
- // The dedupe that makes the hello frame free, and that absorbs the
232
- // watcher's debounce emitting two frames for one save. The stamp is a
233
- // content digest of the graph payload (§5.3), so an identical stamp
234
- // provably means identical content — which is what entitles this line
235
- // to skip a refetch. It was `generatedAt` until §15.6, and a timestamp
236
- // max does *not* carry that guarantee: an edit that did not move the
237
- // maximum was discarded right here, before the conditional GET that
238
- // would have caught it ever ran.
239
- if (event.event.stamp === state.stamp) return { state, plan: NO_REFETCH };
240
- // The stamp is *not* recorded here. It becomes ours when the refetch it
241
- // triggers succeeds — recording it now would mean a failed fetch left
242
- // the client believing it holds data it never received, and the next
243
- // frame carrying the same stamp would be deduped away.
244
- return { state: { ...state, connection: "live" }, plan: planFor(event.event.scope, hasSelection) };
245
- }
246
-
247
- case "refresh":
248
- return { state, plan: planForEverything(hasSelection) };
249
-
250
- case "closed":
251
- return { state: { ...state, connection: "offline" }, plan: NO_REFETCH };
252
- }
253
- }
254
-
255
- // --- frame decoding ------------------------------------------------------------------
256
-
257
- /**
258
- * Decode one SSE `data:` line, or `null`.
259
- *
260
- * Total by design. This runs inside a socket callback on bytes that survived
261
- * a server restart, a proxy and whatever else sits on loopback, so a
262
- * malformed frame must cost a skipped refetch — never an unhandled rejection
263
- * that kills the listener and silently ends liveness for the session.
264
- * {@link isChangeEvent} does the structural half; this adds the `JSON.parse`
265
- * that can throw.
266
- */
267
- export function parseFrame(data: string): ChangeEvent | null {
268
- let parsed: unknown;
269
- try {
270
- parsed = JSON.parse(data);
271
- } catch {
272
- return null;
273
- }
274
- return isChangeEvent(parsed) ? parsed : null;
275
- }
@@ -1,151 +0,0 @@
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 };
@@ -1,109 +0,0 @@
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 { editorBarVisible } 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 while editing: done, save, and the status word.
46
- *
47
- * `Open in $EDITOR` no longer lives here (P6.3): as the bar's only read-mode
48
- * inhabitant it read as the note's headline control, a full-width bordered
49
- * shout before the prose began. It is an icon in the head's meta row instead
50
- * — same event, same hint, one third the visual weight — and
51
- * `editorBarVisible` is what keeps the read view down to prose rather than a
52
- * bar announcing nothing. */
53
- export function EditorBar(props: EditorProps) {
54
- const { toolbar, send } = props;
55
- if (!editorBarVisible(toolbar, props.prompt)) return null;
56
- return (
57
- <div class="weave-note-bar">
58
- {toolbar.editing ? (
59
- <button
60
- type="button"
61
- class="weave-note-toggle"
62
- aria-pressed={toolbar.editing}
63
- onClick={() => send({ type: "toggle" })}
64
- >
65
- {toolbar.toggleLabel}
66
- </button>
67
- ) : null}
68
- {toolbar.editing ? (
69
- <button type="button" class="weave-note-save" disabled={!toolbar.canSave} onClick={() => send({ type: "save" })}>
70
- {toolbar.saveLabel}
71
- </button>
72
- ) : null}
73
- {toolbar.dirty ? (
74
- <span class="weave-note-dirty" title="unsaved changes" aria-hidden="true">
75
- •
76
- </span>
77
- ) : null}
78
- {toolbar.message === null ? null : <span class={`weave-note-status weave-note-status-${toolbar.tone}`}>{toolbar.message}</span>}
79
- {props.prompt === null ? null : <Prompt prompt={props.prompt} send={send} />}
80
- </div>
81
- );
82
- }
83
-
84
- /**
85
- * The editing surface.
86
- *
87
- * `⌘S` is handled here as well as globally, and deliberately: the global
88
- * `keydown` listener sees every keystroke in the workspace, so claiming a
89
- * modifier combination there is a workspace-wide claim. Handling it on the
90
- * textarea too means the save fires from the element that owns the text even
91
- * if the global map is later narrowed — and `keys.model.ts` returns `null`
92
- * for anything it does not claim, so the two never fight over one event.
93
- */
94
- export function Editor(props: EditorProps) {
95
- return (
96
- <textarea
97
- class="weave-note-editor"
98
- aria-label="Note body"
99
- spellcheck
100
- value={props.draft}
101
- onInput={(event) => props.send({ type: "draft", text: (event.target as HTMLTextAreaElement).value })}
102
- onKeyDown={(event) => {
103
- if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
104
- event.preventDefault();
105
- props.send({ type: "save" });
106
- }}
107
- />
108
- );
109
- }
@@ -1,151 +0,0 @@
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 };