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,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Everything the tree column *decides
|
|
2
|
+
* Everything the tree column *decides*.
|
|
3
3
|
*
|
|
4
4
|
* The column itself is `treeRows` with a different renderer — that is §3's
|
|
5
5
|
* whole claim, and this module is what makes it true in the browser: it holds
|
|
@@ -40,7 +40,7 @@ import type { IconName } from "../shell/icons.model";
|
|
|
40
40
|
* It is **not** `src/web/client/state.ts`'s `TreeState`. That one is a P1
|
|
41
41
|
* placeholder carrying only an expanded-id list, and it stays where it is:
|
|
42
42
|
* this state is owned by the tree column, lives in the component that renders
|
|
43
|
-
* it, and never crosses the context bus.
|
|
43
|
+
* it, and never crosses the context bus. 's bus is `selectedId` — which
|
|
44
44
|
* rows happen to be open is not something the note column or the graph has any
|
|
45
45
|
* business reacting to.
|
|
46
46
|
*/
|
|
@@ -81,7 +81,7 @@ export function initialTreeView(roots: readonly string[] = ["vault", "repository
|
|
|
81
81
|
*
|
|
82
82
|
* `WireGraphModel` is `Omit<GraphModel, "danglingLinks">` — the payload hoists
|
|
83
83
|
* that map to its own top-level `dangling` rather than shipping it twice
|
|
84
|
-
* (§
|
|
84
|
+
* (§3) — so exactly one field has to be put back before `treeRows` or
|
|
85
85
|
* `detailModel` will accept it. The door (`shared/view.ts`) deliberately does
|
|
86
86
|
* not do this: it is a wire concern, and a door that carried a transformation
|
|
87
87
|
* would be a second implementation rather than a re-export.
|
|
@@ -137,112 +137,6 @@ export function collapse(state: TreeViewState, id: string): TreeViewState {
|
|
|
137
137
|
return { ...state, expanded };
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
// --- drag & drop and folder helpers -------------------------------------------
|
|
141
|
-
|
|
142
|
-
export const FOLDER_BTN_HINT = "Create a new folder in the vault";
|
|
143
|
-
export const FOLDER_PLACEHOLDER = "Folder name…";
|
|
144
|
-
|
|
145
|
-
/** True when a tree row can accept dropped notes (the vault root or a folder). */
|
|
146
|
-
export function isDropTarget(id: string): boolean {
|
|
147
|
-
return id === "vault" || id.startsWith("vfolder:");
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Extract folder path relative to notes/ from a drop target row id, or null for root. */
|
|
151
|
-
export function folderPathFromId(id: string): string | null {
|
|
152
|
-
if (id === "vault") return null;
|
|
153
|
-
if (id.startsWith("vfolder:")) return id.slice("vfolder:".length);
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** True when a tree row represents a note that can be dragged into folders. */
|
|
158
|
-
export function isDraggableNote(kind: WireNodeKind, id: string): boolean {
|
|
159
|
-
return kind === "note" && id.startsWith("note:");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Target descriptor for a deletable row (vault note or folder). */
|
|
163
|
-
export type DeletableTarget =
|
|
164
|
-
| { readonly type: "note"; readonly slug: string }
|
|
165
|
-
| { readonly type: "folder"; readonly path: string };
|
|
166
|
-
|
|
167
|
-
/** Extract deletable target descriptor, or null if the row is protected / non-deletable. */
|
|
168
|
-
export function deletableTarget(id: string): DeletableTarget | null {
|
|
169
|
-
if (id.startsWith("note:")) return { type: "note", slug: id.slice("note:".length) };
|
|
170
|
-
if (id.startsWith("vfolder:")) return { type: "folder", path: id.slice("vfolder:".length) };
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// --- context menu -------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
export type TreeContextMenuItem =
|
|
177
|
-
| {
|
|
178
|
-
readonly kind: "action";
|
|
179
|
-
readonly id: string;
|
|
180
|
-
readonly label: string;
|
|
181
|
-
readonly icon?: string;
|
|
182
|
-
readonly destructive?: boolean;
|
|
183
|
-
}
|
|
184
|
-
| { readonly kind: "separator" };
|
|
185
|
-
|
|
186
|
-
export interface TreeContextMenuState {
|
|
187
|
-
readonly x: number;
|
|
188
|
-
readonly y: number;
|
|
189
|
-
readonly rowId: string;
|
|
190
|
-
readonly rowLabel: string;
|
|
191
|
-
readonly kind: WireNodeKind;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Actions available in the context menu for a given tree row. */
|
|
195
|
-
export function contextMenuItemsForRow(rowId: string, kind: WireNodeKind): readonly TreeContextMenuItem[] {
|
|
196
|
-
if (rowId === "vault") {
|
|
197
|
-
return [
|
|
198
|
-
{ kind: "action", id: "new-folder", label: "New folder", icon: "📁" },
|
|
199
|
-
{ kind: "separator" },
|
|
200
|
-
{ kind: "action", id: "collapse-all", label: "Collapse all" },
|
|
201
|
-
];
|
|
202
|
-
}
|
|
203
|
-
if (rowId.startsWith("vfolder:")) {
|
|
204
|
-
return [
|
|
205
|
-
{ kind: "action", id: "new-subfolder", label: "New subfolder…", icon: "📁" },
|
|
206
|
-
{ kind: "action", id: "rename-folder", label: "Rename folder…", icon: "✏️" },
|
|
207
|
-
{ kind: "separator" },
|
|
208
|
-
{ kind: "action", id: "delete-folder", label: "Delete folder", icon: "🗑", destructive: true },
|
|
209
|
-
];
|
|
210
|
-
}
|
|
211
|
-
if (kind === "note" && rowId.startsWith("note:")) {
|
|
212
|
-
return [
|
|
213
|
-
{ kind: "action", id: "open", label: "Open note", icon: "📄" },
|
|
214
|
-
{ kind: "action", id: "rename", label: "Rename note…", icon: "✏️" },
|
|
215
|
-
{ kind: "separator" },
|
|
216
|
-
{ kind: "action", id: "delete-note", label: "Delete note", icon: "🗑", destructive: true },
|
|
217
|
-
];
|
|
218
|
-
}
|
|
219
|
-
if (rowId.startsWith("module:") || rowId.startsWith("file:") || rowId.startsWith("entryPoint:")) {
|
|
220
|
-
return [{ kind: "action", id: "copy-id", label: "Copy node id", icon: "📋" }];
|
|
221
|
-
}
|
|
222
|
-
return [];
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Calculate clamped viewport placement for the context menu. */
|
|
226
|
-
export function contextMenuPlacement(
|
|
227
|
-
x: number,
|
|
228
|
-
y: number,
|
|
229
|
-
menuWidth: number,
|
|
230
|
-
menuHeight: number,
|
|
231
|
-
viewportWidth: number,
|
|
232
|
-
viewportHeight: number,
|
|
233
|
-
margin = 8,
|
|
234
|
-
): { x: number; y: number } {
|
|
235
|
-
let left = x;
|
|
236
|
-
let top = y;
|
|
237
|
-
if (left + menuWidth > viewportWidth - margin) {
|
|
238
|
-
left = Math.max(margin, viewportWidth - menuWidth - margin);
|
|
239
|
-
}
|
|
240
|
-
if (top + menuHeight > viewportHeight - margin) {
|
|
241
|
-
top = Math.max(margin, viewportHeight - menuHeight - margin);
|
|
242
|
-
}
|
|
243
|
-
return { x: left, y: top };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
140
|
/**
|
|
247
141
|
* Set the substring filter.
|
|
248
142
|
*
|
|
@@ -354,7 +248,7 @@ export function parentOf(rows: readonly TreeRow[], id: string): string | null {
|
|
|
354
248
|
}
|
|
355
249
|
|
|
356
250
|
/**
|
|
357
|
-
* Vim-ish aliases: `j` is `ArrowDown`, `k` is `ArrowUp` (
|
|
251
|
+
* Vim-ish aliases: `j` is `ArrowDown`, `k` is `ArrowUp` ( P4).
|
|
358
252
|
*
|
|
359
253
|
* A *normalizer* rather than two more branches in {@link treeKey}, because
|
|
360
254
|
* the aliasing and the navigation are separate concerns and folding them
|
|
@@ -363,7 +257,7 @@ export function parentOf(rows: readonly TreeRow[], id: string): string | null {
|
|
|
363
257
|
*
|
|
364
258
|
* ## Why the aliases are tree-scoped and not global
|
|
365
259
|
*
|
|
366
|
-
*
|
|
260
|
+
* says "vim-ish `j/k` **in the tree**", and the qualifier is load-bearing
|
|
367
261
|
* on both sides. A global `j` would move the tree's cursor while the user is
|
|
368
262
|
* reading the note column — an invisible change to a surface they are not
|
|
369
263
|
* looking at — and it would make `j` untypeable in the graph's depth control.
|
|
@@ -474,45 +368,6 @@ export function kindIcon(kind: WireNodeKind): IconName {
|
|
|
474
368
|
return KIND_ICONS[kind];
|
|
475
369
|
}
|
|
476
370
|
|
|
477
|
-
// --- the session fold -----------------------------------------------------------------
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* The synthesized folder session memory lives under.
|
|
481
|
-
*
|
|
482
|
-
* Core's graph builder (`src/core/graph/build.ts`) nests notes whose slug has
|
|
483
|
-
* a directory under a synthesized `vfolder:<dir>` node — kind `module`,
|
|
484
|
-
* because reusing the tree's containment chain needs no client change. That
|
|
485
|
-
* reuse is why there is no `session` node *kind*: a session note is an
|
|
486
|
-
* ordinary `note` that happens to be filed there, and this client recognises
|
|
487
|
-
* it by path, not by kind.
|
|
488
|
-
*/
|
|
489
|
-
export const SESSION_DIR = "sessions";
|
|
490
|
-
|
|
491
|
-
/** True when a node id names a note under the {@link SESSION_DIR} fold. */
|
|
492
|
-
export function isSessionNote(id: string): boolean {
|
|
493
|
-
return id.startsWith(`note:${SESSION_DIR}/`);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* Whether a row renders a notch quieter.
|
|
498
|
-
*
|
|
499
|
-
* `sessions/<n>.md` notes are machine-written memory that accrues by the
|
|
500
|
-
* dozens with near-duplicate titles, so at any real vault size they are most
|
|
501
|
-
* of the tree's rows — and rows that all look alike at full weight make the
|
|
502
|
-
* six notes a human actually wrote harder to find. So session rows sit in
|
|
503
|
-
* `--weave-dim` until hovered or selected, which is how Obsidian treats its
|
|
504
|
-
* own long tails.
|
|
505
|
-
*
|
|
506
|
-
* The *never* half is the load-bearing part: `selectedId` is the §1.3 bus, so
|
|
507
|
-
* the selection is decided somewhere the tree does not own, and a rule that
|
|
508
|
-
* could dim the selected row would be a rule the graph's click could silently
|
|
509
|
-
* break. The class-coverage gate does not see this — it is asserted here,
|
|
510
|
-
* which is where §10 says it belongs.
|
|
511
|
-
*/
|
|
512
|
-
export function isMuted(row: TreeRow, selectedId: string | null): boolean {
|
|
513
|
-
return row.id !== selectedId && isSessionNote(row.id);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
371
|
/**
|
|
517
372
|
* Provenance → glyph.
|
|
518
373
|
*
|
|
@@ -663,14 +518,6 @@ export interface TreeRowView {
|
|
|
663
518
|
readonly hasKids: boolean;
|
|
664
519
|
readonly expanded: boolean;
|
|
665
520
|
readonly selected: boolean;
|
|
666
|
-
/**
|
|
667
|
-
* Render a notch quieter — the session rows of {@link isMuted}.
|
|
668
|
-
*
|
|
669
|
-
* A view-model flag rather than a `.tsx` branch for the same reason every
|
|
670
|
-
* other branch is here, and the stylesheet keys off it with
|
|
671
|
-
* `.weave-row-muted`.
|
|
672
|
-
*/
|
|
673
|
-
readonly muted: boolean;
|
|
674
521
|
/** The trailing annotation, already formatted against `now`. */
|
|
675
522
|
readonly meta: string;
|
|
676
523
|
/** ARIA `aria-level`, which is 1-based where `depth` is 0-based. */
|
|
@@ -706,7 +553,6 @@ export function rowView(row: TreeRow, selectedId: string | null, now: number, po
|
|
|
706
553
|
hasKids: row.hasKids,
|
|
707
554
|
expanded: row.expanded,
|
|
708
555
|
selected: row.id === selectedId,
|
|
709
|
-
muted: isMuted(row, selectedId),
|
|
710
556
|
meta: formatTreeMeta(row.meta, now),
|
|
711
557
|
level: row.depth + 1,
|
|
712
558
|
};
|
|
@@ -781,7 +627,7 @@ export const FILTER_PLACEHOLDER = "filter…";
|
|
|
781
627
|
* Names *what is in it* rather than what it is: "Tree" is already the
|
|
782
628
|
* column's heading and the role announces the widget type, so a second
|
|
783
629
|
* "tree" would be read three times. "Vault and repository" is the sentence
|
|
784
|
-
*
|
|
630
|
+
* uses for the same thing.
|
|
785
631
|
*/
|
|
786
632
|
export const TREE_LABEL = "Vault and repository";
|
|
787
633
|
|
|
@@ -805,7 +651,7 @@ export const FILTER_HINT = "Filter the tree (/)";
|
|
|
805
651
|
* The obvious order — "no rows? then work out why" — is wrong here, and it is
|
|
806
652
|
* wrong in the case that matters most. A brand-new vault is not a graph with
|
|
807
653
|
* no rows: `treeRows` still emits the `vault` root, so the column renders one
|
|
808
|
-
* word and nothing else, and a user's first ever
|
|
654
|
+
* word and nothing else, and a user's first ever use says nothing about
|
|
809
655
|
* how to add a note. `treeEmptyHint` is core's answer to exactly that
|
|
810
656
|
* question, and it is deliberately narrow — it returns a string only for a
|
|
811
657
|
* vault with no notes *and* no repository — so consulting it first cannot
|
|
@@ -814,7 +660,7 @@ export const FILTER_HINT = "Filter the tree (/)";
|
|
|
814
660
|
* It also outranks the filter message, for the same reason: when the vault is
|
|
815
661
|
* genuinely empty, "nothing matches this filter" is true and useless.
|
|
816
662
|
*
|
|
817
|
-
* Using core's sentence rather than writing one here is
|
|
663
|
+
* Using core's sentence rather than writing one here is : the TUI's empty
|
|
818
664
|
* tree and the browser's say the same thing about the same vault, because
|
|
819
665
|
* there is one sentence.
|
|
820
666
|
*/
|
|
@@ -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
|
|
3
|
+
import type { GraphPayload } from "../shared/wire";
|
|
35
4
|
import type { ApiResult, FetchLike } from "./api";
|
|
36
5
|
import { fetchGraph, fetchNote } from "./api";
|
|
37
|
-
import {
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
41
|
+
let state = opts.state;
|
|
42
|
+
let stopped = false;
|
|
43
|
+
let polling = false;
|
|
206
44
|
let cancelRecentExpiry: (() => void) | null = null;
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
98
|
+
const cancelPoll = repeat(() => void poll(), POLL_MS);
|
|
99
|
+
void poll();
|
|
246
100
|
|
|
247
101
|
return {
|
|
248
|
-
refresh()
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return loadNote(opts.fetch);
|
|
102
|
+
refresh: () => void poll(),
|
|
103
|
+
select: async (id) => {
|
|
104
|
+
update({ selectedId: id });
|
|
105
|
+
await loadNote();
|
|
253
106
|
},
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
}
|
package/src/web/server/page.ts
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The HTML shell
|
|
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
|
|
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
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
216
|
+
"#app{height:100%;display:grid;grid-template-rows:auto 1fr auto}",
|
|
219
217
|
].join("");
|
|
220
218
|
|
|
221
219
|
/**
|