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.
- package/README.md +8 -37
- package/package.json +1 -2
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +6 -7
- package/src/core/graph/current.ts +2 -4
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +3 -3
- package/src/core/index.ts +26 -27
- package/src/core/paths.ts +0 -7
- package/src/core/vault.ts +16 -681
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +1 -1
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -223
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +93 -219
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +21 -63
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +13 -83
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -125
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/shell.model.ts +10 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +33 -157
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +25 -575
- package/src/web/client/tree/tree.model.ts +8 -162
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -563
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -196
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/client/shell/Divider.tsx +0 -44
- package/src/web/client/shell/cssvars.ts +0 -70
- package/src/web/client/shell/drag.model.ts +0 -170
- package/src/web/client/shell/layout.model.ts +0 -500
- package/src/web/client/shell/viewport.ts +0 -29
- package/src/web/server/sse.ts +0 -321
- 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
|
-
}
|