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,686 +0,0 @@
1
- /**
2
- * The note editor, as a pure state machine (weave-workspace §11 P5.3–P5.4).
3
- *
4
- * > `<textarea>` editor with live preview, `⌘S` save, `⌘E` toggle edit/read.
5
- * > Conflict handling: save carries the mtime it read; a mismatch returns
6
- * > `409` and the UI offers reload-or-overwrite.
7
- *
8
- * Everything that decides is here: dirty tracking, the save lifecycle, the
9
- * conflict resolution, and the two guards that stand between a user's
10
- * unsaved paragraph and the ways it can be lost. `Editor.tsx` is a
11
- * `<textarea>`, a toolbar and three handlers; `editor.ts` owns the fetch. §10
12
- * forbids a DOM test environment, so a branch that lands in the component is
13
- * a branch that ships uncovered — and the branches below are precisely the
14
- * ones where being wrong costs the user their writing.
15
- *
16
- * ## Why a reducer and not a handful of `useState` calls
17
- *
18
- * Because the interesting properties are *relationships between* the fields,
19
- * not the fields:
20
- *
21
- * - a save must carry the revision read at load, not one read later;
22
- * - a `409` must leave the draft intact **and** offer the server's version;
23
- * - a response that arrives after the user has typed again must not silently
24
- * replace what they typed;
25
- * - navigating away while dirty must be refused *and* remember where the
26
- * user was trying to go, so confirming does not make them click twice.
27
- *
28
- * Each of those is a statement about two or three fields moving together.
29
- * Spread across component state they are four invariants nobody can check;
30
- * as {@link reduceEditor} they are a table of transitions with a test each.
31
- *
32
- * ## The three ways an edit can be lost, and what stops each
33
- *
34
- * | Loss | Guard |
35
- * | --- | --- |
36
- * | Another writer overwrites you | `expectedRevision` on every save → `409` |
37
- * | You overwrite another writer | the same `409`, offered as a choice not a default |
38
- * | You navigate away mid-edit | {@link EditorEvent} `navigate` is refused while dirty, parked in {@link EditorState.pending} |
39
- *
40
- * The fourth — closing the tab — cannot be solved here, because the browser
41
- * only offers `beforeunload`. {@link shouldBlockUnload} is the predicate;
42
- * `editor.ts` attaches the listener.
43
- *
44
- * ## An SSE change to the note being edited: keep typing, offer the reload
45
- *
46
- * §6 pushes a frame when the vault moves, and `workspace.ts` refetches the
47
- * open note. If that refetch lands on a note the user is *editing*, there are
48
- * three possible policies and only one of them is defensible:
49
- *
50
- * 1. **Overwrite the draft with the server's version.** Destroys unsaved
51
- * work with no prompt, triggered by a background event the user did not
52
- * cause. Never.
53
- * 2. **Block with a modal.** Steals focus mid-sentence, from a notification
54
- * rather than an action. Editors that do this are the reason people
55
- * disable file watching.
56
- * 3. **Keep the draft; mark the note as changed on disk; offer a reload.**
57
- *
58
- * This module does (3), in {@link applyLoad}: a load carrying a revision
59
- * different from the one held is recorded in {@link EditorState.external}
60
- * rather than applied, and the draft is untouched. The user keeps typing and
61
- * sees a passive marker offering to take the disk version.
62
- *
63
- * What makes (3) *safe* rather than merely polite is that it is not the last
64
- * line of defence. The held revision is now stale by construction, so the
65
- * next `⌘S` produces a `409` carrying the current note — the same choice,
66
- * re-offered at the moment the user is actually about to write, and this time
67
- * authoritative rather than a snapshot that may itself have aged. The passive
68
- * marker is an early warning; the `409` is the guarantee. Dropping the marker
69
- * would still be correct and would just make the conflict a surprise;
70
- * dropping the `409` would not be correct at all, which is why the marker is
71
- * the part that is allowed to be non-blocking.
72
- *
73
- * When the note is **not** dirty, the same load is adopted silently — there
74
- * is nothing to protect, and showing a "changed on disk" badge over a
75
- * document the user is only reading would be noise.
76
- *
77
- * Compiled by the root `tsconfig.json` when a test imports it: no DOM types,
78
- * no `node:*`, no `src/core`.
79
- */
80
-
81
- import type { ConflictPayload, NotePayload, SaveNoteRequest } from "../../shared/wire";
82
-
83
- // --- state --------------------------------------------------------------------------
84
-
85
- /** Read-only rendering, or the `<textarea>`. Toggled by `⌘E`. */
86
- export type EditorMode = "read" | "edit";
87
-
88
- /**
89
- * Where the save lifecycle is.
90
- *
91
- * `"saved"` is a transient acknowledgement rather than a resting state — it
92
- * exists so the toolbar can say "saved" for a moment — and any keystroke
93
- * returns it to `"idle"`. Without it, a save with no visible outcome is
94
- * indistinguishable from a save that did not fire, which is exactly the
95
- * uncertainty that makes people hit `⌘S` four times.
96
- */
97
- export type SaveStatus = "idle" | "saving" | "saved" | "error";
98
-
99
- /**
100
- * A navigation the editor refused because the draft is dirty.
101
- *
102
- * The *destination* is remembered, not merely the fact of the refusal, so
103
- * confirming the discard completes the move the user asked for. Parking only
104
- * a boolean would make "discard" mean "stay here with your work gone", which
105
- * is the worst available outcome.
106
- *
107
- * `id` is nullable because clearing the selection (`Esc`, a click on empty
108
- * graph stage) is a navigation too, and it must be guarded like any other.
109
- */
110
- export interface PendingNavigation {
111
- readonly id: string | null;
112
- }
113
-
114
- export interface EditorState {
115
- readonly mode: EditorMode;
116
- /** The note being edited, or `null` when the column holds nothing. */
117
- readonly slug: string | null;
118
- /**
119
- * The body as last known to be on disk: what was loaded, or what the
120
- * server echoed back from the last successful save.
121
- *
122
- * Dirtiness is `draft !== baseline`, so this is deliberately the
123
- * **server's** text rather than what was sent to it. Core trims the body
124
- * and re-attaches the append-only `## Raw` tail, so a save's response can
125
- * legitimately differ from its request — and baselining against the
126
- * request would leave the editor permanently dirty by exactly that
127
- * difference.
128
- */
129
- readonly baseline: string;
130
- /** The `<textarea>`'s content. */
131
- readonly draft: string;
132
- /**
133
- * The revision every save carries. `null` before the first load.
134
- *
135
- * Opaque: compare it, never parse it. It is the revision read *with* the
136
- * body, which is what makes the pair meaningful — a revision fetched
137
- * separately would describe a state the draft was not typed against.
138
- */
139
- readonly revision: string | null;
140
- readonly status: SaveStatus;
141
- /** Human-facing, already safe to render. `null` when there is nothing to say. */
142
- readonly message: string | null;
143
- /**
144
- * The draft as it was when the in-flight save was issued, or `null`.
145
- *
146
- * The stale-response guard. A save is asynchronous and the user keeps
147
- * typing through it, so when the response lands this is what decides
148
- * whether the server's echoed body may be written back into the textarea:
149
- * it may, if the draft has not moved since; it must not, if it has, or the
150
- * keystrokes made during the round trip vanish under the reply to a
151
- * request that predates them.
152
- */
153
- readonly saving: string | null;
154
- /** A `409` awaiting the user's decision. Blocks further saves until resolved. */
155
- readonly conflict: ConflictPayload | null;
156
- /**
157
- * A newer version seen on disk while the draft was dirty (§6).
158
- *
159
- * Passive: it does not block typing or saving. See the module header for
160
- * why an SSE change is an early warning rather than an interruption.
161
- */
162
- readonly external: NotePayload | null;
163
- /** A navigation refused because the draft is dirty. */
164
- readonly pending: PendingNavigation | null;
165
- }
166
-
167
- /** Nothing loaded, nothing typed. */
168
- export function initialEditorState(): EditorState {
169
- return {
170
- mode: "read",
171
- slug: null,
172
- baseline: "",
173
- draft: "",
174
- revision: null,
175
- status: "idle",
176
- message: null,
177
- saving: null,
178
- conflict: null,
179
- external: null,
180
- pending: null,
181
- };
182
- }
183
-
184
- /**
185
- * Whether the draft differs from what is known to be on disk.
186
- *
187
- * Gated on `mode === "edit"` deliberately. In read mode the draft is a stale
188
- * copy of whatever was last loaded and nobody is typing into it, so treating
189
- * a difference as unsaved work would block navigation for a user who has not
190
- * edited anything — the most annoying possible false positive.
191
- */
192
- export function isDirty(state: EditorState): boolean {
193
- return state.mode === "edit" && state.draft !== state.baseline;
194
- }
195
-
196
- /** Whether a save is worth issuing: dirty, loaded, and not already in flight. */
197
- export function canSave(state: EditorState): boolean {
198
- return isDirty(state) && state.slug !== null && state.status !== "saving";
199
- }
200
-
201
- // --- effects ------------------------------------------------------------------------
202
-
203
- /**
204
- * What a transition asks the outside world to do.
205
- *
206
- * Returned as *data* rather than performed, so every branch below is
207
- * assertable by equality. `editor.ts` is the only thing that turns one of
208
- * these into a request.
209
- */
210
- export type EditorEffect =
211
- | { readonly type: "save"; readonly slug: string; readonly input: SaveNoteRequest }
212
- /** Complete a navigation the editor was holding — the §1.3 context bus. */
213
- | { readonly type: "select"; readonly id: string | null }
214
- /** `POST /api/open` — hand the note to `$EDITOR` (§16). */
215
- | { readonly type: "open"; readonly slug: string };
216
-
217
- /** The result of one transition. */
218
- export interface EditorTransition {
219
- readonly state: EditorState;
220
- readonly effect: EditorEffect | null;
221
- }
222
-
223
- function still(state: EditorState): EditorTransition {
224
- return { state, effect: null };
225
- }
226
-
227
- // --- events -------------------------------------------------------------------------
228
-
229
- export type EditorEvent =
230
- /** A note arrived from the server — first load, a reselect, or an SSE refetch. */
231
- | { readonly type: "loaded"; readonly payload: NotePayload }
232
- /** The selection no longer names a note. */
233
- | { readonly type: "cleared" }
234
- /** `⌘E`. */
235
- | { readonly type: "toggle" }
236
- /** The `<textarea>` changed. */
237
- | { readonly type: "draft"; readonly text: string }
238
- /** `⌘S`, or the toolbar's save. */
239
- | { readonly type: "save" }
240
- | { readonly type: "saved"; readonly payload: NotePayload }
241
- | { readonly type: "failed"; readonly message: string }
242
- | { readonly type: "conflicted"; readonly conflict: ConflictPayload }
243
- /** Take the server's version, discarding the draft. */
244
- | { readonly type: "reload" }
245
- /** Take the draft, discarding the server's version. */
246
- | { readonly type: "overwrite" }
247
- /** Close a conflict prompt or an external-change marker without choosing. */
248
- | { readonly type: "dismiss" }
249
- /** The user selected something else. Refused while dirty. */
250
- | { readonly type: "navigate"; readonly id: string | null }
251
- /** Confirm the refused navigation, losing the draft. */
252
- | { readonly type: "discard" }
253
- /** Cancel the refused navigation and stay. */
254
- | { readonly type: "stay" }
255
- /** Open the note in `$EDITOR`. */
256
- | { readonly type: "open" }
257
- | { readonly type: "opened"; readonly ok: boolean };
258
-
259
- // --- the machine ---------------------------------------------------------------------
260
-
261
- /** Copy of the payload's body/revision, with the save lifecycle reset. */
262
- function adopt(state: EditorState, payload: NotePayload, mode: EditorMode): EditorState {
263
- return {
264
- ...state,
265
- mode,
266
- slug: payload.note.slug,
267
- baseline: payload.note.body,
268
- draft: payload.note.body,
269
- revision: payload.revision,
270
- status: "idle",
271
- message: null,
272
- saving: null,
273
- conflict: null,
274
- external: null,
275
- };
276
- }
277
-
278
- /**
279
- * A note arrived. Three cases, and the middle one is the §6 decision.
280
- *
281
- * 1. **A different note.** Nothing to protect — the guard on `navigate` is
282
- * what stopped us reaching here with unsaved work — so load it in read
283
- * mode, which is the honest default for a document the user has not yet
284
- * said they want to change.
285
- * 2. **The same note, dirty, at a revision we do not hold.** Somebody else
286
- * wrote while the user was typing. Keep the draft; record the newer
287
- * version. See the module header.
288
- * 3. **The same note, otherwise.** Adopt it, keeping the current mode: a
289
- * refetch triggered by our own save, or by an unrelated frame, must not
290
- * eject a reader out of the editor they had open.
291
- */
292
- function applyLoad(state: EditorState, payload: NotePayload): EditorState {
293
- if (payload.note.slug !== state.slug) return adopt(state, payload, "read");
294
- if (isDirty(state) && payload.revision !== state.revision) return { ...state, external: payload };
295
- return adopt(state, payload, state.mode);
296
- }
297
-
298
- /**
299
- * The whole editor, as one function.
300
- *
301
- * Ordered so the guards are visible: `navigate` and `discard` come first
302
- * because they are the ones that can destroy work, and the save lifecycle
303
- * follows.
304
- */
305
- export function reduceEditor(state: EditorState, event: EditorEvent): EditorTransition {
306
- switch (event.type) {
307
- case "loaded":
308
- return still(applyLoad(state, event.payload));
309
-
310
- case "cleared":
311
- // Reached only through `navigate`, which has already checked
312
- // dirtiness — so there is nothing here to lose. Resetting rather than
313
- // blanking selectively, because a half-cleared editor holding a
314
- // revision for a note that is no longer open is a save waiting to go
315
- // to the wrong file.
316
- return still(initialEditorState());
317
-
318
- case "toggle": {
319
- // With nothing loaded there is nothing to edit, and entering edit mode
320
- // over an empty column would present a textarea whose save has no
321
- // slug to go to.
322
- if (state.slug === null) return still(state);
323
- if (state.mode === "read") return still({ ...state, mode: "edit", draft: state.baseline, status: "idle", message: null });
324
- // Leaving edit mode with unsaved changes would discard them silently,
325
- // which is the same loss `navigate` guards against arriving by a
326
- // different key. Refuse and say so; `⌘S` or an explicit discard is the
327
- // way out.
328
- if (isDirty(state)) return still({ ...state, status: "error", message: UNSAVED_MESSAGE });
329
- return still({ ...state, mode: "read", status: "idle", message: null });
330
- }
331
-
332
- case "draft":
333
- // Any keystroke clears a transient acknowledgement or error: they
334
- // describe the *previous* draft, and leaving "saved" on screen while
335
- // the text moves under it is a lie the user will believe.
336
- return still({ ...state, draft: event.text, status: state.status === "saving" ? "saving" : "idle", message: null });
337
-
338
- case "save": {
339
- if (!canSave(state) || state.slug === null) return still(state);
340
- // A save while a conflict is unresolved would be the overwrite the
341
- // prompt exists to make deliberate. Refuse until the user chooses.
342
- if (state.conflict !== null) return still(state);
343
- return {
344
- state: { ...state, status: "saving", message: null, saving: state.draft },
345
- effect: { type: "save", slug: state.slug, input: saveInputFor(state) },
346
- };
347
- }
348
-
349
- case "saved":
350
- return still(applySaved(state, event.payload));
351
-
352
- case "failed":
353
- return still({ ...state, status: "error", message: event.message, saving: null });
354
-
355
- case "conflicted":
356
- // The draft is untouched — that is the whole contract of a `409`. The
357
- // conflict sits alongside it until the user picks a side.
358
- return still({ ...state, status: "error", message: event.conflict.error, saving: null, conflict: event.conflict });
359
-
360
- case "reload":
361
- return still(applyReload(state));
362
-
363
- case "overwrite":
364
- return applyOverwrite(state);
365
-
366
- case "dismiss":
367
- // Clears the *prompts*, never the draft. Dismissing a conflict means
368
- // "I have read it and I am still thinking", so the next save conflicts
369
- // again — which is correct, because nothing has been resolved.
370
- return still({ ...state, conflict: null, external: null, status: "idle", message: null });
371
-
372
- case "navigate":
373
- return applyNavigate(state, event.id);
374
-
375
- case "discard":
376
- // The one place a draft is thrown away, and only ever after an
377
- // explicit confirmation of a navigation the user initiated.
378
- return state.pending === null
379
- ? still(state)
380
- : { state: initialEditorState(), effect: { type: "select", id: state.pending.id } };
381
-
382
- case "stay":
383
- return still({ ...state, pending: null });
384
-
385
- case "open":
386
- return state.slug === null ? still(state) : { state, effect: { type: "open", slug: state.slug } };
387
-
388
- case "opened":
389
- return still({
390
- ...state,
391
- status: event.ok ? "idle" : "error",
392
- message: event.ok ? OPENED_MESSAGE : OPEN_FAILED_MESSAGE,
393
- });
394
- }
395
- }
396
-
397
- /**
398
- * The request body for a save.
399
- *
400
- * Body only. The editor is a `<textarea>` over the Markdown body (§0 V10) and
401
- * has no metadata fields, so sending a `meta` it did not collect would be
402
- * echoing values back at the server for it to rewrite over themselves — and
403
- * the round trip through JSON is exactly where a tag list picks up a
404
- * reordering nobody asked for.
405
- *
406
- * `expectedRevision` is included whenever one is held, which is always after
407
- * a load. It is *absent* only in {@link applyOverwrite}, and its absence
408
- * there is the entire meaning of "overwrite".
409
- */
410
- function saveInputFor(state: EditorState): SaveNoteRequest {
411
- return state.revision === null ? { body: state.draft } : { body: state.draft, expectedRevision: state.revision };
412
- }
413
-
414
- /**
415
- * A save succeeded.
416
- *
417
- * The echoed body replaces the draft **only if** the draft has not moved
418
- * since the request went out. Core trims and may re-attach a `## Raw` tail,
419
- * so the echo is genuinely worth adopting — but adopting it over keystrokes
420
- * made during the round trip would delete them, and a save is not supposed to
421
- * be able to delete anything.
422
- *
423
- * When the user *has* typed on, the baseline still advances to the echoed
424
- * body: that is what is on disk now, so the editor correctly reads as dirty
425
- * by exactly the new keystrokes, and the next save carries the fresh
426
- * revision rather than conflicting with its own previous write.
427
- */
428
- function applySaved(state: EditorState, payload: NotePayload): EditorState {
429
- const raced = state.saving !== null && state.saving !== state.draft;
430
- return {
431
- ...state,
432
- slug: payload.note.slug,
433
- baseline: payload.note.body,
434
- draft: raced ? state.draft : payload.note.body,
435
- revision: payload.revision,
436
- status: "saved",
437
- message: SAVED_MESSAGE,
438
- saving: null,
439
- conflict: null,
440
- external: null,
441
- };
442
- }
443
-
444
- /**
445
- * Take the disk version, discarding the draft.
446
- *
447
- * Serves both prompts, because both hold the same thing — a
448
- * {@link NotePayload} the server already sent us. A `409`'s `collision` arm
449
- * does not (it names a taken slug, not a note), so it is left alone: there is
450
- * nothing to reload, and the user's answer is to pick a different name.
451
- */
452
- function applyReload(state: EditorState): EditorState {
453
- const payload = state.conflict?.reason === "conflict" ? state.conflict.current : state.external;
454
- return payload === null || payload === undefined ? state : adopt(state, payload, state.mode);
455
- }
456
-
457
- /**
458
- * Take the draft, discarding the disk version.
459
- *
460
- * Implemented by re-issuing the save **without** `expectedRevision`, which is
461
- * how core spells last-write-wins. The alternative — adopting the conflict's
462
- * revision and saving against that — would look equivalent and is not: it
463
- * would succeed only if nothing had moved *again* in the meantime, so a
464
- * second concurrent writer would turn the user's deliberate "overwrite" into
465
- * a second surprise conflict. "Overwrite" should mean it.
466
- */
467
- function applyOverwrite(state: EditorState): EditorTransition {
468
- if (state.slug === null) return still(state);
469
- return {
470
- state: { ...state, status: "saving", message: null, saving: state.draft, conflict: null, external: null },
471
- effect: { type: "save", slug: state.slug, input: { body: state.draft } },
472
- };
473
- }
474
-
475
- /**
476
- * The user selected something else.
477
- *
478
- * Clean → let it through. Dirty → park the destination and let the component
479
- * ask. The navigation is *not* performed and *not* forgotten, so confirming
480
- * costs one click rather than two.
481
- */
482
- function applyNavigate(state: EditorState, id: string | null): EditorTransition {
483
- if (!isDirty(state)) return { state, effect: { type: "select", id } };
484
- return still({ ...state, pending: { id } });
485
- }
486
-
487
- // --- the unload guard -----------------------------------------------------------------
488
-
489
- /**
490
- * Whether `beforeunload` should be cancelled.
491
- *
492
- * A predicate rather than a listener, because the listener is one line and
493
- * the *decision* is the part worth testing. Browsers ignore any custom
494
- * message here and show their own wording, so there is nothing else to
495
- * return.
496
- *
497
- * A pending navigation is deliberately *not* a reason to block: the tab is
498
- * closing either way, and the parked destination is about to stop existing.
499
- * Only actual unsaved text counts.
500
- */
501
- export function shouldBlockUnload(state: EditorState): boolean {
502
- return isDirty(state);
503
- }
504
-
505
- // --- the view model ---------------------------------------------------------------------
506
-
507
- /** Copy. Centralised so the toolbar and the tests quote the same strings. */
508
- export const SAVED_MESSAGE = "saved";
509
- export const UNSAVED_MESSAGE = "unsaved changes — save with the button, or discard them";
510
- export const OPENED_MESSAGE = "opened in your editor";
511
- export const OPEN_FAILED_MESSAGE = "could not open the note in an editor";
512
- export const EDIT_LABEL = "Edit";
513
- export const READ_LABEL = "Done";
514
- export const SAVE_LABEL = "Save";
515
- export const SAVING_LABEL = "Saving…";
516
- /**
517
- * The `Open in $EDITOR` control's name — kept as its accessible label, not as
518
- * its text.
519
- *
520
- * P6.3 demoted this control out of the note bar: as a full-width bordered
521
- * button sitting between the title and the prose it out-shouted the document
522
- * it opens. It is now an icon in the head's meta row ({@link OPEN_ICON} in
523
- * `Note.tsx`'s header), so the label lives on for `aria-label` and the
524
- * title/tooltip, where the explanation belongs — the icon alone is a glyph,
525
- * not an invitation.
526
- */
527
- export const OPEN_LABEL = "Open in $EDITOR";
528
- export const OPEN_HINT = "Hand this note to $EDITOR (or your platform's opener)";
529
- export const DISCARD_LABEL = "Discard changes";
530
- export const KEEP_LABEL = "Keep editing";
531
- export const RELOAD_LABEL = "Reload from disk";
532
- export const OVERWRITE_LABEL = "Overwrite";
533
- export const EXTERNAL_MESSAGE = "this note changed on disk while you were editing";
534
- export const COLLISION_HINT = "pick a different name";
535
-
536
- /**
537
- * The icon the demoted `Open in $EDITOR` control shows, as a string.
538
- *
539
- * A single inline SVG is the whole icon system this button needs, and a
540
- * string constant is what keeps it CSP-legal and testable: it is inserted as
541
- * *markup* (not as an attribute, which a strict CSP would block), it is
542
- * `currentColor` so the button's own colour states style it for free, and
543
- * carrying it here — with `aria-hidden: true` because the accessible name is
544
- * {@link OPEN_LABEL} — means a test can assert the icon ships with the
545
- * accessible name and hint rather than hoping the component wires them.
546
- *
547
- * Deliberately not the shared icon sprite P6.4 builds for the tree and rail:
548
- * that is another column's job and another module's vocabulary, and a 15px
549
- * pencil-plus-page does not want a dependency on it.
550
- */
551
- export const OPEN_ICON =
552
- '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.4" ' +
553
- 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' +
554
- // The page, open at the corner the pencil is leaving through.
555
- '<path d="M9 2H3.6A1.6 1.6 0 0 0 2 3.6v8.8A1.6 1.6 0 0 0 3.6 14h8.8A1.6 1.6 0 0 0 14 12.4V9"/>' +
556
- // The pencil: `edit`'s geometry from the Feather icon, scaled to 16px.
557
- '<path d="M11.3 2a1.9 1.9 0 1 1 2.7 2.7L5.6 13 2 14l1-3.6 8.3-8.4z"/>' +
558
- "</svg>";
559
-
560
- /** Which prompt, if any, the column must render. */
561
- export type EditorPromptKind = "conflict" | "collision" | "external" | "discard";
562
-
563
- /**
564
- * Every {@link EditorPromptKind}, as a runtime value.
565
- *
566
- * `Editor.tsx` emits `weave-note-prompt-${kind}`, so the stylesheet needs one
567
- * rule per kind and a *type* cannot be walked to check that. A hand-written
568
- * list in the theme test would pass on the day a fifth kind arrives
569
- * unstyled — the same reasoning `WIRE_NODE_KINDS` records.
570
- */
571
- export const EDITOR_PROMPT_KINDS: readonly EditorPromptKind[] = ["conflict", "collision", "external", "discard"];
572
-
573
- /**
574
- * A prompt: a sentence and the choices under it.
575
- *
576
- * Modelled as data so "a conflict offers reload *and* overwrite, a collision
577
- * offers neither" is a table with a test rather than a nest of ternaries in
578
- * JSX. `actions` are {@link EditorEvent} type names, which is what lets the
579
- * component dispatch them without knowing what any of them mean.
580
- */
581
- export interface EditorPrompt {
582
- readonly kind: EditorPromptKind;
583
- readonly message: string;
584
- readonly actions: readonly { readonly label: string; readonly event: EditorEvent }[];
585
- }
586
-
587
- /**
588
- * The prompt to show, or `null`.
589
- *
590
- * Ordered by urgency, and only one is ever shown: a `409` is the user's
591
- * immediate blocker, a refused navigation is a question they just asked, and
592
- * the external marker is an FYI. Stacking them would produce two dialogs
593
- * about the same underlying fact — someone else wrote — with different
594
- * buttons.
595
- */
596
- export function editorPrompt(state: EditorState): EditorPrompt | null {
597
- const conflict = state.conflict;
598
- if (conflict?.reason === "conflict") {
599
- return {
600
- kind: "conflict",
601
- message: conflict.error,
602
- actions: [
603
- { label: RELOAD_LABEL, event: { type: "reload" } },
604
- { label: OVERWRITE_LABEL, event: { type: "overwrite" } },
605
- { label: KEEP_LABEL, event: { type: "dismiss" } },
606
- ],
607
- };
608
- }
609
- if (conflict?.reason === "collision") {
610
- // No reload and no overwrite: there is no competing *version* of this
611
- // note, only a different note already occupying the name. Offering
612
- // "overwrite" would be offering to destroy an unrelated file.
613
- return {
614
- kind: "collision",
615
- message: `${conflict.error} (${conflict.slug}) — ${COLLISION_HINT}`,
616
- actions: [{ label: KEEP_LABEL, event: { type: "dismiss" } }],
617
- };
618
- }
619
- if (state.pending !== null) {
620
- return {
621
- kind: "discard",
622
- message: UNSAVED_MESSAGE,
623
- actions: [
624
- { label: KEEP_LABEL, event: { type: "stay" } },
625
- { label: DISCARD_LABEL, event: { type: "discard" } },
626
- ],
627
- };
628
- }
629
- if (state.external !== null) {
630
- return {
631
- kind: "external",
632
- message: EXTERNAL_MESSAGE,
633
- actions: [
634
- { label: RELOAD_LABEL, event: { type: "reload" } },
635
- { label: KEEP_LABEL, event: { type: "dismiss" } },
636
- ],
637
- };
638
- }
639
- return null;
640
- }
641
-
642
- /** Everything the note column's toolbar renders. */
643
- export interface EditorToolbar {
644
- /** `Edit` or `Done` — the `⌘E` button's current meaning. */
645
- readonly toggleLabel: string;
646
- /** True while the editor is open, for the button's pressed state. */
647
- readonly editing: boolean;
648
- readonly saveLabel: string;
649
- /** Whether the save control is actionable. */
650
- readonly canSave: boolean;
651
- /** `saved`, an error, or `null`. */
652
- readonly message: string | null;
653
- /** Tone for the message, so the component picks a class rather than a colour. */
654
- readonly tone: "none" | "ok" | "warn";
655
- /** The `•` unsaved marker. */
656
- readonly dirty: boolean;
657
- }
658
-
659
- /** Derive the toolbar. One function, so the component has no conditionals. */
660
- export function editorToolbar(state: EditorState): EditorToolbar {
661
- const editing = state.mode === "edit";
662
- return {
663
- toggleLabel: editing ? READ_LABEL : EDIT_LABEL,
664
- editing,
665
- saveLabel: state.status === "saving" ? SAVING_LABEL : SAVE_LABEL,
666
- canSave: canSave(state) && state.conflict === null,
667
- message: state.message,
668
- tone: state.message === null ? "none" : state.status === "error" ? "warn" : "ok",
669
- dirty: isDirty(state),
670
- };
671
- }
672
-
673
- /**
674
- * Whether the editor bar should render at all.
675
- *
676
- * P6.3 removed the `Open in $EDITOR` button from the bar (it lives in the
677
- * head's meta row now), which took away the bar's only read-mode inhabitant.
678
- * A bar that renders in read mode is then an empty ruled strip between the
679
- * head and the prose — a border describing nothing. So the bar is *earned*:
680
- * while editing it is always present, otherwise only a message or a prompt
681
- * (a save's status line, an "opened in your editor" acknowledgement, a
682
- * conflict) puts something inside it.
683
- */
684
- export function editorBarVisible(toolbar: EditorToolbar, prompt: EditorPrompt | null): boolean {
685
- return toolbar.editing || toolbar.message !== null || prompt !== null;
686
- }