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,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
|
-
}
|
package/src/web/client/live.ts
DELETED
|
@@ -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 };
|