pi-weave 0.1.11 → 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 (75) hide show
  1. package/README.md +148 -132
  2. package/package.json +1 -2
  3. package/src/core/cache/workspace.ts +26 -9
  4. package/src/core/concurrency.ts +3 -6
  5. package/src/core/frontmatter.ts +0 -53
  6. package/src/core/graph/build.ts +12 -8
  7. package/src/core/graph/current.ts +4 -6
  8. package/src/core/graph/model.ts +1 -1
  9. package/src/core/graph/wikilinks.ts +3 -3
  10. package/src/core/index.ts +26 -27
  11. package/src/core/paths.ts +0 -7
  12. package/src/core/slug.ts +13 -0
  13. package/src/core/types.ts +1 -0
  14. package/src/core/vault.ts +45 -467
  15. package/src/core/view/detail.ts +1 -1
  16. package/src/core/view/health.ts +1 -1
  17. package/src/core/view/tree.ts +15 -3
  18. package/src/pi/index.ts +6 -85
  19. package/src/pi/summarize.ts +2 -2
  20. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  21. package/src/pi/viewer/tui/branding.ts +7 -148
  22. package/src/pi/viewer/tui/run.ts +3 -17
  23. package/src/pi/viewer/tui/surface/base.ts +24 -3
  24. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  25. package/src/pi/viewer/tui/workspace.ts +23 -351
  26. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  27. package/src/pi/viewer/web/run.ts +7 -117
  28. package/src/web/client/api.dom.ts +2 -2
  29. package/src/web/client/api.ts +14 -176
  30. package/src/web/client/bootstrap.ts +5 -14
  31. package/src/web/client/context/context.model.ts +9 -11
  32. package/src/web/client/dist/app.js +77 -167
  33. package/src/web/client/graph/dynamics.ts +5 -65
  34. package/src/web/client/graph/renderer.dom.ts +7 -8
  35. package/src/web/client/graph/renderer.ts +9 -35
  36. package/src/web/client/main.tsx +1 -1
  37. package/src/web/client/note/Note.tsx +37 -60
  38. package/src/web/client/note/note.model.ts +23 -0
  39. package/src/web/client/search/SearchPalette.tsx +45 -36
  40. package/src/web/client/search/search.model.ts +33 -454
  41. package/src/web/client/shell/Columns.tsx +13 -81
  42. package/src/web/client/shell/Header.tsx +2 -10
  43. package/src/web/client/shell/Shell.tsx +50 -123
  44. package/src/web/client/shell/StatusBar.tsx +1 -4
  45. package/src/web/client/shell/icons.model.ts +4 -7
  46. package/src/web/client/shell/keys.model.ts +5 -42
  47. package/src/web/client/shell/keys.ts +2 -2
  48. package/src/web/client/shell/shell.model.ts +10 -133
  49. package/src/web/client/shell/theme.model.ts +2 -2
  50. package/src/web/client/shell/theme.ts +33 -121
  51. package/src/web/client/state.ts +9 -89
  52. package/src/web/client/tree/Tree.tsx +24 -97
  53. package/src/web/client/tree/tree.model.ts +8 -56
  54. package/src/web/client/workspace.ts +72 -242
  55. package/src/web/server/page.ts +8 -10
  56. package/src/web/server/routes.ts +30 -415
  57. package/src/web/server/server.ts +6 -145
  58. package/src/web/shared/layout.ts +72 -624
  59. package/src/web/shared/wire.ts +10 -178
  60. package/src/core/sessions.ts +0 -929
  61. package/src/pi/sessionScan.ts +0 -104
  62. package/src/pi/viewer/tui/explorer.ts +0 -586
  63. package/src/web/client/live.model.ts +0 -275
  64. package/src/web/client/live.ts +0 -151
  65. package/src/web/client/note/Editor.tsx +0 -109
  66. package/src/web/client/note/editor.controller.ts +0 -151
  67. package/src/web/client/note/editor.model.ts +0 -686
  68. package/src/web/client/search/search.ts +0 -107
  69. package/src/web/client/shell/Divider.tsx +0 -44
  70. package/src/web/client/shell/cssvars.ts +0 -70
  71. package/src/web/client/shell/drag.model.ts +0 -170
  72. package/src/web/client/shell/layout.model.ts +0 -500
  73. package/src/web/client/shell/viewport.ts +0 -29
  74. package/src/web/server/sse.ts +0 -321
  75. package/src/web/server/watcher.ts +0 -507
@@ -1,286 +1,116 @@
1
- /**
2
- * The controller: fetches, signals, and the SSE loop joined up
3
- * (weave-workspace §1.3, §6).
4
- *
5
- * Three modules already exist and none of them knows about the others —
6
- * `api.ts` fetches, `live.ts` listens, `state.ts` holds. This is the seam
7
- * that connects them, and it is a plain `.ts` with every dependency injected
8
- * so it is covered by ordinary tests: `fetch` comes in as a {@link FetchLike}
9
- * and the socket as an {@link EventSourceFactory}, exactly as those modules
10
- * were designed to allow.
11
- *
12
- * Keeping it out of a component is what makes the shell's `useEffect` a
13
- * two-liner (`start`, return `stop`). A `.tsx` cannot be tested here, so any
14
- * decision that lands in one is a decision that ships uncovered.
15
- *
16
- * ## Refetch is ordered and conditional
17
- *
18
- * A plan can ask for both endpoints; the graph is fetched first because it
19
- * carries the stamp that `seen()` records and therefore the dedupe key for
20
- * every subsequent frame. Both requests are conditional in the sense that
21
- * matters: the graph sends `If-None-Match` and a `304` costs an empty body,
22
- * so "refetch everything on reconnect" (§6) is genuinely cheap rather than
23
- * merely correct.
24
- *
25
- * ## Failures are absorbed, not thrown
26
- *
27
- * `api.ts` returns a discriminated result precisely so this layer never
28
- * catches. A failed refetch leaves the previous signal value in place — a
29
- * stale graph is strictly better than a blank workspace, and the next frame
30
- * or the `⟳` button retries. The connection indicator, driven separately by
31
- * the socket, is what tells the user something is wrong.
32
- */
1
+ /** Fetching and polling for the browser workspace. */
33
2
 
34
- import type { GraphPayload, NotePayload } from "../shared/wire";
3
+ import type { GraphPayload } from "../shared/wire";
35
4
  import type { ApiResult, FetchLike } from "./api";
36
5
  import { fetchGraph, fetchNote } from "./api";
37
- import { graphFailed, recentIds } from "./state";
38
- import type { EventSourceFactory, LiveHandle } from "./live";
39
- import { startLive } from "./live";
40
- import type { RefetchPlan } from "./live.model";
41
- import { connection, graph, noteBody, selectedId } from "./state";
6
+ import type { WorkspaceState } from "./state";
42
7
 
43
- /** What {@link startWorkspace} needs. Everything injectable is injected. */
44
8
  export interface WorkspaceOptions {
45
9
  fetch: FetchLike;
46
- /** Socket constructor. `domEventSource` at the real call site. */
47
- open: EventSourceFactory;
48
- /** Overrides the SSE path. Tests use it; the shell does not. */
49
- path?: string;
50
- /**
51
- * One-shot timer, injectable for tests. Defaults to `setTimeout`. Used
52
- * only to expire the recent-arrivals highlight ({@link RECENT_TTL_MS}).
53
- */
10
+ state: WorkspaceState;
11
+ setState: (state: WorkspaceState) => void;
54
12
  defer?: (fn: () => void, ms: number) => () => void;
13
+ repeat?: (fn: () => void, ms: number) => () => void;
55
14
  }
56
15
 
57
- /** How long a newly-arrived node stays flagged in the tree (the animation is shorter). */
16
+ export const POLL_MS = 2_000;
58
17
  export const RECENT_TTL_MS = 3_000;
59
18
 
60
19
  const NO_IDS: ReadonlySet<string> = new Set();
61
20
 
62
- /**
63
- * Node ids present in `next` but not in `previous`.
64
- *
65
- * The mount fetch passes `previous === null`, which yields the empty set —
66
- * a first load must not animate the entire tree as "new". A node that left
67
- * and returned is new again: from the reader's point of view it *is* a
68
- * fresh arrival.
69
- */
70
21
  export function addedNodeIds(previous: GraphPayload | null, next: GraphPayload): ReadonlySet<string> {
71
22
  if (previous === null) return NO_IDS;
72
23
  const before = new Set(previous.model.nodes.map((node) => node.id));
73
- const added = new Set<string>();
74
- for (const node of next.model.nodes) {
75
- if (!before.has(node.id)) added.add(node.id);
76
- }
77
- return added;
24
+ return new Set(next.model.nodes.filter((node) => !before.has(node.id)).map((node) => node.id));
78
25
  }
79
26
 
80
- /**
81
- * Told about every note that arrives, so the editor can decide (§6, P5).
82
- *
83
- * A module-level hook rather than a parameter threaded through five call
84
- * sites, because a note reaches the column from three unrelated directions —
85
- * the mount fetch, a selection, and an SSE refetch — and the editor's
86
- * decision ("is this the note I am editing, at a revision I do not hold?")
87
- * has to be made on all three or it is made on none. The alternative was
88
- * `loadNote` taking a callback that every caller had to remember to pass.
89
- *
90
- * Set by the shell at mount and cleared on unmount, exactly like
91
- * `Shell.tsx`'s `fit` ref. `null` — the shape every test that does not care
92
- * about editing sees — means the load simply publishes and nothing else
93
- * happens.
94
- */
95
- let onNoteLoaded: ((payload: NotePayload) => void) | null = null;
96
-
97
- /** Register the editor's load hook. Returns an unsubscribe. */
98
- export function observeNotes(hook: (payload: NotePayload) => void): () => void {
99
- onNoteLoaded = hook;
100
- return () => {
101
- // Only clear our own registration: two shells in one test process
102
- // unmounting out of order must not blank a live hook.
103
- if (onNoteLoaded === hook) onNoteLoaded = null;
104
- };
105
- }
106
-
107
- /** A running workspace. */
108
27
  export interface WorkspaceHandle {
109
- /** Force a full refetch — the header's `⟳`. */
110
28
  refresh(): void;
111
- /** Fetch the body for the current selection, or clear it. */
29
+ select(id: string | null): Promise<void>;
112
30
  syncNote(): Promise<void>;
113
- /** Close the socket. Idempotent. */
114
31
  stop(): void;
115
32
  }
116
33
 
117
- /**
118
- * Fetch the graph and publish it.
119
- *
120
- * The stamp is handed to {@link LiveHandle.seen} only on success, which is
121
- * the invariant `live.model.ts` documents: a stamp recorded for a fetch that
122
- * failed would dedupe away the very frame that would have retried it.
123
- *
124
- * A `304` arrives as `cached: true` with the caller's own payload, so
125
- * re-assigning the signal would be a no-op write that still wakes every
126
- * subscriber. Skipping it is the difference between an idle workspace doing
127
- * nothing and one re-rendering three columns every time the watcher twitches.
128
- */
129
- async function loadGraph(
130
- fetchImpl: FetchLike,
131
- live: LiveHandle | null,
132
- onPublished?: (previous: GraphPayload | null, next: GraphPayload) => void,
133
- ): Promise<ApiResult<unknown>> {
134
- // Captured before the fetch so the diff describes exactly what the reader
135
- // was looking at when the update landed.
136
- const previous = graph.value;
137
- const result = await fetchGraph(fetchImpl, graph.value);
138
- if (!result.ok) {
139
- // Only a *boot* failure is news: with a graph already published, the
140
- // stale value is deliberately left standing and the failure would be a
141
- // downgrade dressed as an error. The next frame or the ⟳ button retries.
142
- if (graph.value === null) graphFailed.value = true;
143
- return result;
144
- }
145
- graphFailed.value = false;
146
- if (!result.cached) {
147
- graph.value = result.data;
148
- onPublished?.(previous, result.data);
149
- }
150
- live?.seen(result.data.stamp);
151
- return result;
152
- }
153
-
154
- /**
155
- * Fetch the selected note's body, or clear it.
156
- *
157
- * The selection is a graph node id, and only *note* nodes have a body — the
158
- * repository, git-state and file nodes do not. `note:` is the prefix core's
159
- * graph builder gives them; anything else clears the column rather than
160
- * issuing a request the server would answer `404`.
161
- */
162
- async function loadNote(fetchImpl: FetchLike): Promise<void> {
163
- const slug = noteSlug(selectedId.value);
164
- if (slug === null) {
165
- noteBody.value = null;
166
- return;
167
- }
168
- const result = await fetchNote(fetchImpl, slug);
169
- // A failed note fetch leaves the previous body on screen. The alternative —
170
- // blanking the column on a transient error — throws away readable content
171
- // to display nothing, and the note is usually still there.
172
- if (!result.ok) return;
173
- noteBody.value = result.data;
174
- // After the signal, not before: the editor's decision may leave the draft
175
- // in place *while* the column's read-mode rendering shows the new version,
176
- // and the two are independent. Publishing second would let the editor see
177
- // a payload the rest of the workspace does not yet hold.
178
- onNoteLoaded?.(result.data);
179
- }
180
-
181
- /**
182
- * The slug inside a `note:<slug>` node id, or `null` for any other node.
183
- *
184
- * Exported because it is the one piece of id-shape knowledge in this file and
185
- * it deserves a test of its own rather than being reached only through a
186
- * fetch. An empty slug (`"note:"`) is rejected: it would produce a request
187
- * for `/api/note/` and a 404 that means nothing to anyone.
188
- */
189
34
  export function noteSlug(id: string | null): string | null {
190
35
  if (id === null || !id.startsWith("note:")) return null;
191
36
  const slug = id.slice("note:".length);
192
37
  return slug === "" ? null : slug;
193
38
  }
194
39
 
195
- /**
196
- * Boot the workspace: first graph fetch, then the event stream.
197
- *
198
- * In that order, deliberately. The mount fetch seeds the stamp via `seen()`,
199
- * so the hello frame `sse.ts` sends every newly attached client is recognised
200
- * as already-held and deduped away. Opening the socket first would make the
201
- * first frame arrive before there is a stamp to compare it to, and the
202
- * workspace would fetch the same graph twice on every single load.
203
- */
204
40
  export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
205
- let live: LiveHandle | null = null;
41
+ let state = opts.state;
42
+ let stopped = false;
43
+ let polling = false;
206
44
  let cancelRecentExpiry: (() => void) | null = null;
207
- const defer =
208
- opts.defer ??
209
- ((fn: () => void, ms: number) => {
210
- const handle = setTimeout(fn, ms);
211
- return () => clearTimeout(handle);
212
- });
213
-
214
- /** Publish the frame's arrivals; the tree flashes them while they are new. */
215
- const onPublished = (previous: GraphPayload | null, next: GraphPayload): void => {
216
- const added = addedNodeIds(previous, next);
217
- recentIds.value = added;
218
- cancelRecentExpiry?.();
219
- cancelRecentExpiry = null;
220
- if (added.size > 0) {
221
- cancelRecentExpiry = defer(() => {
222
- cancelRecentExpiry = null;
223
- recentIds.value = NO_IDS;
224
- }, RECENT_TTL_MS);
45
+ const update = (patch: Partial<WorkspaceState>): void => {
46
+ if (Object.entries(patch).every(([key, value]) => state[key as keyof WorkspaceState] === value)) return;
47
+ state = { ...state, ...patch };
48
+ opts.setState(state);
49
+ };
50
+ const defer = opts.defer ?? ((fn, ms) => {
51
+ const timer = setTimeout(fn, ms);
52
+ return () => clearTimeout(timer);
53
+ });
54
+ const loadNote = async (): Promise<void> => {
55
+ const slug = noteSlug(state.selectedId);
56
+ if (slug === null) {
57
+ update({ note: null });
58
+ return;
225
59
  }
60
+ const result = await fetchNote(opts.fetch, slug);
61
+ if (result.ok) update({ note: result.data });
226
62
  };
227
-
228
- const runPlan = (plan: RefetchPlan): void => {
229
- // Fire-and-forget: this is called from a socket callback, which cannot
230
- // await. Failures are values (`api.ts`), so there is nothing to reject —
231
- // `void` documents that rather than hiding a floating promise.
232
- void (async () => {
233
- if (plan.graph) await loadGraph(opts.fetch, live, onPublished);
234
- if (plan.note) await loadNote(opts.fetch);
235
- })();
63
+ const loadGraph = async (): Promise<ApiResult<unknown>> => {
64
+ const previous = state.graph;
65
+ const result = await fetchGraph(opts.fetch, state.graph);
66
+ if (!result.ok) {
67
+ if (state.graph === null) update({ graphFailed: true });
68
+ return result;
69
+ }
70
+ if (!result.cached) {
71
+ const added = addedNodeIds(previous, result.data);
72
+ update({ graph: result.data, graphFailed: false, recentIds: added });
73
+ cancelRecentExpiry?.();
74
+ cancelRecentExpiry = null;
75
+ if (added.size > 0) {
76
+ cancelRecentExpiry = defer(() => {
77
+ cancelRecentExpiry = null;
78
+ update({ recentIds: NO_IDS });
79
+ }, RECENT_TTL_MS);
80
+ }
81
+ }
82
+ return result;
236
83
  };
237
-
238
- live = startLive({
239
- open: opts.open,
240
- refetch: runPlan,
241
- hasSelection: () => noteSlug(selectedId.value) !== null,
242
- ...(opts.path === undefined ? {} : { path: opts.path }),
84
+ const poll = async (): Promise<void> => {
85
+ if (stopped || polling) return;
86
+ polling = true;
87
+ try {
88
+ const result = await loadGraph();
89
+ if (result.ok && !result.cached && noteSlug(state.selectedId) !== null) await loadNote();
90
+ } finally {
91
+ polling = false;
92
+ }
93
+ };
94
+ const repeat = opts.repeat ?? ((fn, ms) => {
95
+ const timer = setInterval(fn, ms);
96
+ return () => clearInterval(timer);
243
97
  });
244
-
245
- void loadGraph(opts.fetch, live, onPublished);
98
+ const cancelPoll = repeat(() => void poll(), POLL_MS);
99
+ void poll();
246
100
 
247
101
  return {
248
- refresh() {
249
- live?.refresh();
250
- },
251
- syncNote() {
252
- return loadNote(opts.fetch);
102
+ refresh: () => void poll(),
103
+ select: async (id) => {
104
+ update({ selectedId: id });
105
+ await loadNote();
253
106
  },
254
- stop() {
255
- live?.stop();
256
- live = null;
107
+ syncNote: loadNote,
108
+ stop: () => {
109
+ if (stopped) return;
110
+ stopped = true;
111
+ cancelPoll();
257
112
  cancelRecentExpiry?.();
258
113
  cancelRecentExpiry = null;
259
- recentIds.value = NO_IDS;
260
114
  },
261
115
  };
262
116
  }
263
-
264
- /**
265
- * Select a node — the §1.3 context bus, in one function.
266
- *
267
- * Writing `selectedId` is the whole mechanism; the note fetch that follows is
268
- * a *consequence* of the write, not part of it, which is why the signal is
269
- * set before the fetch is issued. Every column that derives from the
270
- * selection updates on the synchronous write, so the UI responds immediately
271
- * and the body arrives when it arrives.
272
- */
273
- export function select(fetchImpl: FetchLike, id: string | null): Promise<void> {
274
- selectedId.value = id;
275
- return loadNote(fetchImpl);
276
- }
277
-
278
- /** Reset every signal. The shell's unmount path, and every test's cleanup. */
279
- export function resetWorkspace(): void {
280
- selectedId.value = null;
281
- graph.value = null;
282
- noteBody.value = null;
283
- graphFailed.value = false;
284
- connection.value = "live";
285
- recentIds.value = NO_IDS;
286
- }
@@ -1,20 +1,19 @@
1
1
  /**
2
- * The HTML shell (weave-workspace §5.2, §5.3, §9).
2
+ * The HTML shell.
3
3
  *
4
4
  * Four elements and nothing else: a nonce'd `<style>` carrying the CSS
5
5
  * variable theme, `<div id="app">`, a nonce'd JSON bootstrap block, and
6
6
  * `<script nonce src="/app.js">`. All behaviour lives in the committed
7
- * bundle; this file exists to deliver a nonce, a CSP header and three
8
- * strings of context.
7
+ * bundle; this file exists to deliver a nonce, a CSP header and the cwd.
9
8
  *
10
9
  * ## Why the no-backtick guard survived the rewrite
11
10
  *
12
- * §9 retired the *source* guard for `dist/app.js` in favour of a stronger
11
+ * retired the *source* guard for `dist/app.js` in favour of a stronger
13
12
  * invariant — `build:web:check` byte-compares the committed bundle against a
14
13
  * fresh build, so the shipped artifact provably matches its source. That
15
14
  * argument does not transfer here, because this file is not generated: it is
16
- * a template literal into which `cwd`, `vaultRoot` and a session id are
17
- * interpolated, and it is therefore still an injection surface. A vault path
15
+ * a template literal into which `cwd` is interpolated, and it is therefore
16
+ * still an injection surface. A path
18
17
  * containing `</script>` is not a hypothetical — it is one `mkdir` away.
19
18
  *
20
19
  * So the guard stays, in two forms, both in `tests/web/page.test.ts`:
@@ -32,8 +31,7 @@
32
31
  *
33
32
  * `default-src 'none'` and a per-response nonce. Nothing loads that we did
34
33
  * not name: no `'unsafe-inline'`, no `'unsafe-eval'`, no `blob:`, no remote
35
- * origin. `connect-src 'self'` is what permits `/api/*` and the `/events`
36
- * stream; `frame-ancestors 'none'` means no page can embed us, which
34
+ * origin. `connect-src 'self'` is what permits `/api/*`; `frame-ancestors 'none'` means no page can embed us, which
37
35
  * matters because a framed workspace plus a stolen click is a way to reach
38
36
  * `POST /api/open`.
39
37
  *
@@ -76,7 +74,7 @@ export function cspNonce(nonce: string): string {
76
74
  }
77
75
 
78
76
  /**
79
- * The §5.2 policy, bound to one nonce.
77
+ * The policy, bound to one nonce.
80
78
  *
81
79
  * Emitted as a single line with `; ` separators. `tests/web/routes.test.ts`
82
80
  * asserts the exact string — a policy that drifts silently is a policy that
@@ -215,7 +213,7 @@ const THEME_CSS = [
215
213
  "html,body{height:100%}",
216
214
  "body{margin:0;background:var(--weave-bg);color:var(--weave-fg);",
217
215
  "font-family:var(--weave-sans);font-size:14px;line-height:1.5}",
218
- "#app{height:100%;display:flex;flex-direction:column}",
216
+ "#app{height:100%;display:grid;grid-template-rows:auto 1fr auto}",
219
217
  ].join("");
220
218
 
221
219
  /**