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,22 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* workspaceRoot.ts — the v2 workspace ROOT component (weave-view-tui-v2 §4, §7).
|
|
3
3
|
*
|
|
4
|
-
* Sits above the pure `workspace.ts`
|
|
4
|
+
* Sits above the pure fixed `workspace.ts` model and the `surface/*`
|
|
5
5
|
* components. It composes panes from a `Workspace` value into a real pi-tui
|
|
6
|
-
* tree (
|
|
7
|
-
*
|
|
6
|
+
* tree (HStack/Pane), owns the single input listener, routes keys and
|
|
7
|
+
* cross-pane SurfaceEvents, renders the
|
|
8
8
|
* branded header + footer + narrow-mode tab bar (§9.2, decision 5), and
|
|
9
9
|
* applies responsive collapse.
|
|
10
10
|
*
|
|
11
|
-
* It is drivable with a fake tui/theme/loaders
|
|
12
|
-
* `WeaveExplorer` (v1 single-pane path) is kept intact for backward
|
|
13
|
-
* compatibility; the workspace root is the v2 multi-pane path wired in run.ts.
|
|
11
|
+
* It is drivable with a fake tui/theme/loaders and is the path wired in run.ts.
|
|
14
12
|
*/
|
|
15
13
|
|
|
16
|
-
import { HStack, matchesKey, truncateToWidth, visibleWidth,
|
|
14
|
+
import { HStack, matchesKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
17
15
|
import { BodyStore } from "./bodyStore";
|
|
18
16
|
import { renderMark } from "./branding";
|
|
19
|
-
import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./
|
|
17
|
+
import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./surface/base";
|
|
20
18
|
import { countProvenance } from "./model";
|
|
21
19
|
import type { GraphModel, GraphNode } from "../../../core/graph/model";
|
|
22
20
|
import { Pane } from "./surface/base";
|
|
@@ -25,19 +23,7 @@ import { ExploreSurface } from "./surface/explore";
|
|
|
25
23
|
import { bindDetail, DetailSurface } from "./surface/detail";
|
|
26
24
|
import { FocusSurface } from "./surface/focus";
|
|
27
25
|
import { HealthSurface } from "./surface/health";
|
|
28
|
-
import {
|
|
29
|
-
close,
|
|
30
|
-
collapseForWidth,
|
|
31
|
-
defaultWorkspace,
|
|
32
|
-
focusNext,
|
|
33
|
-
resize,
|
|
34
|
-
setPaneSurface,
|
|
35
|
-
split,
|
|
36
|
-
workspacePanes,
|
|
37
|
-
type PaneNode,
|
|
38
|
-
type Workspace,
|
|
39
|
-
type WorkspaceNode,
|
|
40
|
-
} from "./workspace";
|
|
26
|
+
import { collapseForWidth, defaultWorkspace, focusNext, workspacePanes, type PaneNode, type Workspace } from "./workspace";
|
|
41
27
|
|
|
42
28
|
export interface WeaveWorkspaceOptions {
|
|
43
29
|
model: GraphModel;
|
|
@@ -47,24 +33,10 @@ export interface WeaveWorkspaceOptions {
|
|
|
47
33
|
done: (result: null) => void;
|
|
48
34
|
rows?: number;
|
|
49
35
|
now?: () => number;
|
|
50
|
-
/** Pre-rendered brand mark line (from branding.renderMark). */
|
|
51
|
-
logo?: string;
|
|
52
|
-
/**
|
|
53
|
-
* Raster kitty logo (branding.bundledLogoImage) rendered on its own row(s)
|
|
54
|
-
* above the header text strip when Kitty graphics are available. `null`/
|
|
55
|
-
* absent keeps the one-line glyph header (decision 1 favicon, not a splash).
|
|
56
|
-
*/
|
|
57
|
-
logoImage?: Component | null;
|
|
58
|
-
/** Optional initial workspace (defaults to the Explore default). */
|
|
59
|
-
workspace?: Workspace;
|
|
60
36
|
}
|
|
61
37
|
|
|
62
|
-
/** Decode
|
|
38
|
+
/** Decode the few root-level keys; all other input belongs to the active surface. */
|
|
63
39
|
export function decodeWorkspaceKey(data: string): string | null {
|
|
64
|
-
if (data === "\\") return "splitV";
|
|
65
|
-
if (data === "|") return "splitH";
|
|
66
|
-
if (data === "x") return "close";
|
|
67
|
-
if (data === "w") return "workspace";
|
|
68
40
|
if (data === "?") return "help";
|
|
69
41
|
if (data === "q") return "quit";
|
|
70
42
|
if (data === "r") return "refresh";
|
|
@@ -80,12 +52,11 @@ export class WeaveWorkspace implements Component {
|
|
|
80
52
|
private readonly rows: number;
|
|
81
53
|
private readonly nowFn: () => number;
|
|
82
54
|
private readonly logo: string;
|
|
83
|
-
private readonly logoImage: Component | null;
|
|
84
55
|
private readonly bodies: BodyStore;
|
|
85
56
|
private ctx: SurfaceContext;
|
|
86
57
|
/** paneId → surface instance. */
|
|
87
58
|
private panes = new Map<string, Surface>();
|
|
88
|
-
/** The
|
|
59
|
+
/** The fixed pane layout + focus. */
|
|
89
60
|
workspace: Workspace;
|
|
90
61
|
private helpOpen = false;
|
|
91
62
|
refreshing = false;
|
|
@@ -101,8 +72,7 @@ export class WeaveWorkspace implements Component {
|
|
|
101
72
|
this.done = opts.done;
|
|
102
73
|
this.rows = opts.rows ?? 24;
|
|
103
74
|
this.nowFn = opts.now ?? Date.now;
|
|
104
|
-
this.logo =
|
|
105
|
-
this.logoImage = opts.logoImage ?? null;
|
|
75
|
+
this.logo = renderMark(opts.theme, 20);
|
|
106
76
|
this.bodies = new BodyStore({
|
|
107
77
|
loaders: opts.loaders,
|
|
108
78
|
onChange: () => this.invalidateAndRender(),
|
|
@@ -114,7 +84,7 @@ export class WeaveWorkspace implements Component {
|
|
|
114
84
|
bodies: this.bodies,
|
|
115
85
|
now: this.nowFn,
|
|
116
86
|
};
|
|
117
|
-
this.workspace =
|
|
87
|
+
this.workspace = defaultWorkspace(opts.model);
|
|
118
88
|
this.syncPanes();
|
|
119
89
|
}
|
|
120
90
|
|
|
@@ -192,17 +162,6 @@ export class WeaveWorkspace implements Component {
|
|
|
192
162
|
this.applyWorkspaceKey(wsKey);
|
|
193
163
|
return;
|
|
194
164
|
}
|
|
195
|
-
if (data === "e" || data === "d" || data === "h") {
|
|
196
|
-
this.swapSurface(data);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
// Ctrl+letters resize — only the four resize bytes are intercepted so
|
|
200
|
-
// enter (\r/0x0d), esc (0x1b) and other control keys reach the pane.
|
|
201
|
-
const ctrl = charCode(data);
|
|
202
|
-
if (ctrl !== undefined && (ctrl === 0x08 || ctrl === 0x0a || ctrl === 0x0b || ctrl === 0x0c)) {
|
|
203
|
-
this.applyResize(ctrl);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
165
|
const active = this.activePane();
|
|
207
166
|
if (active?.handleInput) {
|
|
208
167
|
active.handleInput(data);
|
|
@@ -225,21 +184,6 @@ export class WeaveWorkspace implements Component {
|
|
|
225
184
|
|
|
226
185
|
private applyWorkspaceKey(key: string): void {
|
|
227
186
|
switch (key) {
|
|
228
|
-
case "splitV":
|
|
229
|
-
this.workspace = split(this.workspace, this.workspace.activePaneId, "vertical");
|
|
230
|
-
this.syncPanes();
|
|
231
|
-
this.invalidateAndRender();
|
|
232
|
-
return;
|
|
233
|
-
case "splitH":
|
|
234
|
-
this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal");
|
|
235
|
-
this.syncPanes();
|
|
236
|
-
this.invalidateAndRender();
|
|
237
|
-
return;
|
|
238
|
-
case "close":
|
|
239
|
-
this.workspace = close(this.workspace, this.workspace.activePaneId);
|
|
240
|
-
this.syncPanes();
|
|
241
|
-
this.invalidateAndRender();
|
|
242
|
-
return;
|
|
243
187
|
case "quit":
|
|
244
188
|
this.quit();
|
|
245
189
|
return;
|
|
@@ -255,11 +199,6 @@ export class WeaveWorkspace implements Component {
|
|
|
255
199
|
this.invalidateAndRender();
|
|
256
200
|
});
|
|
257
201
|
return;
|
|
258
|
-
case "workspace":
|
|
259
|
-
// TODO(M5): named-workspace switcher overlay; for now toggles help.
|
|
260
|
-
this.helpOpen = !this.helpOpen;
|
|
261
|
-
this.invalidateAndRender();
|
|
262
|
-
return;
|
|
263
202
|
case "help":
|
|
264
203
|
this.helpOpen = !this.helpOpen;
|
|
265
204
|
this.invalidateAndRender();
|
|
@@ -267,28 +206,6 @@ export class WeaveWorkspace implements Component {
|
|
|
267
206
|
}
|
|
268
207
|
}
|
|
269
208
|
|
|
270
|
-
/** Ctrl+h/l adjust row weights; Ctrl+j/k adjust column weights. */
|
|
271
|
-
private applyResize(byte: number): void {
|
|
272
|
-
const active = this.workspace.activePaneId;
|
|
273
|
-
const d = 2;
|
|
274
|
-
if (byte === 0x08) this.workspace = resize(this.workspace, active, "row", -d); // Ctrl-h
|
|
275
|
-
else if (byte === 0x0c) this.workspace = resize(this.workspace, active, "row", d); // Ctrl-l
|
|
276
|
-
else if (byte === 0x0a) this.workspace = resize(this.workspace, active, "column", d); // Ctrl-j
|
|
277
|
-
else if (byte === 0x0b) this.workspace = resize(this.workspace, active, "column", -d); // Ctrl-k
|
|
278
|
-
this.syncPanes();
|
|
279
|
-
this.invalidateAndRender();
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** e/d/h — swap the active pane's surface in place (f stays the pane focus key). */
|
|
283
|
-
private swapSurface(key: string): void {
|
|
284
|
-
const kind = key === "e" ? "explore" : key === "d" ? "detail" : "health";
|
|
285
|
-
const activeId = this.workspace.activePaneId;
|
|
286
|
-
this.workspace = setPaneSurface(this.workspace, activeId, kind);
|
|
287
|
-
const s = this.createSurface({ type: "pane", id: activeId, surface: kind, nodeId: null });
|
|
288
|
-
this.panes.set(activeId, s);
|
|
289
|
-
this.invalidateAndRender();
|
|
290
|
-
}
|
|
291
|
-
|
|
292
209
|
private cycleFocus(dir: 1 | -1): void {
|
|
293
210
|
this.workspace = focusNext(this.workspace, dir);
|
|
294
211
|
this.refreshFocusFlags();
|
|
@@ -331,27 +248,15 @@ export class WeaveWorkspace implements Component {
|
|
|
331
248
|
if (node.detail.slug) void this.loaders.openNote(node.detail.slug);
|
|
332
249
|
}
|
|
333
250
|
|
|
334
|
-
/**
|
|
251
|
+
/** Open a selected node in the fixed Detail pane. */
|
|
335
252
|
private openDetail(id: string): void {
|
|
336
253
|
// (The active Detail surface already rebinds itself on enter, so no
|
|
337
254
|
// active-pane special case is needed here.)
|
|
338
255
|
const panes = workspacePanes(this.workspace);
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
this.setActive(toRight.id);
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
// none to the right — split the active pane horizontally into a Detail
|
|
347
|
-
this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal", "detail");
|
|
348
|
-
this.syncPanes();
|
|
349
|
-
const detail = workspacePanes(this.workspace).find((p) => p.surface === "detail");
|
|
350
|
-
if (detail) {
|
|
351
|
-
this.bindDetail(detail.id, id);
|
|
352
|
-
this.setActive(detail.id);
|
|
353
|
-
}
|
|
354
|
-
this.invalidateAndRender();
|
|
256
|
+
const detail = panes.find((p) => p.surface === "detail");
|
|
257
|
+
if (!detail) return;
|
|
258
|
+
this.bindDetail(detail.id, id);
|
|
259
|
+
this.setActive(detail.id);
|
|
355
260
|
}
|
|
356
261
|
|
|
357
262
|
private bindDetail(paneId: string, id: string): void {
|
|
@@ -375,16 +280,7 @@ export class WeaveWorkspace implements Component {
|
|
|
375
280
|
(this.panes.get(focus.id) as FocusSurface).setFocus(id);
|
|
376
281
|
this.setActive(focus.id);
|
|
377
282
|
this.invalidateAndRender();
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal", "focus");
|
|
381
|
-
this.syncPanes();
|
|
382
|
-
const fp = workspacePanes(this.workspace).find((p) => p.surface === "focus");
|
|
383
|
-
if (fp) {
|
|
384
|
-
(this.panes.get(fp.id) as FocusSurface).setFocus(id);
|
|
385
|
-
this.setActive(fp.id);
|
|
386
283
|
}
|
|
387
|
-
this.invalidateAndRender();
|
|
388
284
|
}
|
|
389
285
|
|
|
390
286
|
private requestBody(id: string): void {
|
|
@@ -414,25 +310,11 @@ export class WeaveWorkspace implements Component {
|
|
|
414
310
|
const repo = this.model.nodes.find((n) => n.kind === "repository");
|
|
415
311
|
const repoState = repoStaleness(this.model, repo);
|
|
416
312
|
const repoPart = repo ? ` · repo ${repo.label}:${repoState}` : "";
|
|
417
|
-
|
|
418
|
-
// the text strip (decision 1 favicon). Otherwise the one-line glyph header
|
|
419
|
-
// stays intact.
|
|
420
|
-
let imageLines: string[] | null = null;
|
|
421
|
-
if (this.logoImage) {
|
|
422
|
-
try {
|
|
423
|
-
const lines = this.logoImage.render(width);
|
|
424
|
-
if (lines && lines.length) imageLines = lines;
|
|
425
|
-
} catch {
|
|
426
|
-
imageLines = null;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
const mark = imageLines ? "" : this.logo;
|
|
313
|
+
const mark = this.logo;
|
|
430
314
|
const head = `${mark}${mark ? " " : ""}${t.bold("weave view")}`;
|
|
431
315
|
const fill = "─".repeat(Math.max(1, width - visibleWidth(head) - 2));
|
|
432
316
|
const line1 = `${head} ${fill} ${this.workspace.name} · ${workspacePanes(this.workspace).length} panes${repoPart}`;
|
|
433
|
-
const out: string[] = [];
|
|
434
|
-
if (imageLines) out.push(...imageLines);
|
|
435
|
-
out.push(truncateToWidth(line1, width));
|
|
317
|
+
const out: string[] = [truncateToWidth(line1, width)];
|
|
436
318
|
const banner = this.bannerText();
|
|
437
319
|
if (banner) out.push(t.fg("warning", truncateToWidth(banner, width)));
|
|
438
320
|
const countsLine = `notes ${counts.total} (● ${counts.human} / ◐ ${counts.agent} / ○ ${counts.generated})`;
|
|
@@ -447,36 +329,19 @@ export class WeaveWorkspace implements Component {
|
|
|
447
329
|
private renderBody(width: number): string[] {
|
|
448
330
|
const effective = collapseForWidth(this.workspace, width);
|
|
449
331
|
const bodyRows = Math.max(1, this.rows - 4);
|
|
450
|
-
const
|
|
451
|
-
|
|
332
|
+
const stack = new HStack();
|
|
333
|
+
for (const pane of effective.panes) stack.addChild(this.buildPane(pane, bodyRows), { grow: 1 });
|
|
334
|
+
return stack.render(width);
|
|
452
335
|
}
|
|
453
336
|
|
|
454
|
-
private
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
return p;
|
|
463
|
-
}
|
|
464
|
-
const StackCtor = node.direction === "row" ? HStack : VStack;
|
|
465
|
-
const stack = new StackCtor();
|
|
466
|
-
// VStack stacks panes vertically, so each child's height must be its share
|
|
467
|
-
// of the available rows (by grow weight) — otherwise every child renders at
|
|
468
|
-
// full height, the stack overflows, and the render clamp leaves only the
|
|
469
|
-
// first pane visible (the "split hides the new pane" bug). HStack shares
|
|
470
|
-
// width, so its children keep the full height.
|
|
471
|
-
const vertical = node.direction !== "row";
|
|
472
|
-
const sizes = node.sizes.length ? node.sizes : node.children.map(() => 1);
|
|
473
|
-
const total = sizes.reduce((a, b) => a + b, 0) || node.children.length;
|
|
474
|
-
node.children.forEach((c, i) => {
|
|
475
|
-
const grow = sizes[i] ?? 1;
|
|
476
|
-
const childRows = vertical ? Math.max(1, Math.round((rows * grow) / total)) : rows;
|
|
477
|
-
stack.addChild(this.buildSplit(c, childRows), { grow });
|
|
478
|
-
});
|
|
479
|
-
return stack;
|
|
337
|
+
private buildPane(node: PaneNode, rows: number): Component {
|
|
338
|
+
const s = this.panes.get(node.id);
|
|
339
|
+
if (!s) return { render: () => [], invalidate: () => {} };
|
|
340
|
+
(s as { paneRows?: number }).paneRows = Math.max(1, rows - 3);
|
|
341
|
+
const p = new Pane(s, this.theme);
|
|
342
|
+
p.rows = rows;
|
|
343
|
+
p.setFocused(node.id === this.workspace.activePaneId);
|
|
344
|
+
return p;
|
|
480
345
|
}
|
|
481
346
|
|
|
482
347
|
private renderTabBar(width: number): string[] {
|
|
@@ -493,22 +358,16 @@ export class WeaveWorkspace implements Component {
|
|
|
493
358
|
const t = this.theme;
|
|
494
359
|
if (this.helpOpen) {
|
|
495
360
|
const help = [
|
|
496
|
-
"Tab focus ·
|
|
361
|
+
"Tab focus · ↑↓/jk move · ←→/hl expand · enter open · f focus · r refresh · ? help · q quit",
|
|
497
362
|
"↑↓/jk move · ←→/hl expand · enter open · / filter · p prov · f focus · r refresh · ? help · q quit",
|
|
498
363
|
];
|
|
499
364
|
return help.map((l) => t.fg("dim", truncateToWidth(l, width)));
|
|
500
365
|
}
|
|
501
|
-
const hint = "Tab focus ·
|
|
366
|
+
const hint = "Tab focus · ↑↓/jk move · ←→/hl expand · enter open · f focus · r refresh · ? help · q quit";
|
|
502
367
|
return [t.fg("dim", truncateToWidth(hint, width))];
|
|
503
368
|
}
|
|
504
369
|
}
|
|
505
370
|
|
|
506
|
-
function charCode(data: string): number | undefined {
|
|
507
|
-
if (data.length !== 1) return undefined;
|
|
508
|
-
const c = data.charCodeAt(0);
|
|
509
|
-
return c >= 0 && c < 0x20 ? c : undefined;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
371
|
/** True when the active surface is in a sub-mode that Esc should clear instead
|
|
513
372
|
* of quitting — Explore's search (`state.searching`) or Detail's goto-line
|
|
514
373
|
* (`state.gotoBuf`). Surfaces own their own state; read defensively so a
|
package/src/pi/viewer/web/run.ts
CHANGED
|
@@ -3,19 +3,15 @@
|
|
|
3
3
|
* (weave-workspace §5.4, §6, §13).
|
|
4
4
|
*
|
|
5
5
|
* This is the adapter half of the browser workspace: everything portable
|
|
6
|
-
* already lives in `src/core` (the cache) and `src/web/server` (the server
|
|
7
|
-
*
|
|
6
|
+
* already lives in `src/core` (the cache) and `src/web/server` (the server).
|
|
7
|
+
* What is left here is the wiring those two
|
|
8
8
|
* cannot do for themselves, because it is session-shaped:
|
|
9
9
|
*
|
|
10
10
|
* 1. **Composition.** Build one {@link WorkspaceCache} over `ctx.cwd` +
|
|
11
|
-
* the resolved vault root
|
|
12
|
-
*
|
|
13
|
-
* 2. **The liveness loop.** `watcher.onPath → cache.invalidate` (evict
|
|
14
|
-
* precisely) and `watcher.onChange → cache.graph() → sse.broadcast`
|
|
15
|
-
* (one frame per scope). §6's diagram, in code.
|
|
16
|
-
* 3. **Singleton per session.** A second `/weave-view` must reuse the
|
|
11
|
+
* the resolved vault root and hand it to `startWorkspaceServer`.
|
|
12
|
+
* 2. **Singleton per session.** A second `/weave-view` must reuse the
|
|
17
13
|
* running server — a second server would mean a second port, a second
|
|
18
|
-
*
|
|
14
|
+
* second browser tab pointed at a
|
|
19
15
|
* workspace nobody is going to close (§5.4).
|
|
20
16
|
* 4. **Browser handoff**, with an honest fallback when there is no browser
|
|
21
17
|
* to hand off to.
|
|
@@ -40,15 +36,11 @@
|
|
|
40
36
|
import { platform as osPlatform } from "node:os";
|
|
41
37
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
42
38
|
import { resolveVaultRoot, WorkspaceCache } from "../../../core";
|
|
43
|
-
import { graphStamp, type Watcher as ServerWatcher } from "../../../web/server/routes";
|
|
44
39
|
import {
|
|
45
40
|
startWorkspaceServer,
|
|
46
41
|
type StartWorkspaceServerOptions,
|
|
47
42
|
type WorkspaceServer,
|
|
48
43
|
} from "../../../web/server/server";
|
|
49
|
-
import { SseHub } from "../../../web/server/sse";
|
|
50
|
-
import { Watcher } from "../../../web/server/watcher";
|
|
51
|
-
import type { ChangeScope } from "../../../web/shared/wire";
|
|
52
44
|
|
|
53
45
|
/** The slice of `ExtensionAPI.exec` this module uses. */
|
|
54
46
|
export type ExecFn = (command: string, args: string[]) => Promise<{ code: number; stderr: string }>;
|
|
@@ -73,73 +65,10 @@ export function browserOpenCommand(
|
|
|
73
65
|
return { command: "xdg-open", args: [url] };
|
|
74
66
|
}
|
|
75
67
|
|
|
76
|
-
/**
|
|
77
|
-
* The §6 liveness loop, as three callbacks over a cache and a hub.
|
|
78
|
-
*
|
|
79
|
-
* Extracted from the boot closure so it can be driven directly in a test
|
|
80
|
-
* instead of through a real filesystem event: "a change arrives, the cache
|
|
81
|
-
* is evicted, the graph is rebuilt, one frame per scope goes out" is the
|
|
82
|
-
* behaviour worth asserting, and waiting on `fs.watch` to assert it would
|
|
83
|
-
* be slow, platform-dependent and flaky for no gain.
|
|
84
|
-
*/
|
|
85
|
-
export interface LivenessBridge {
|
|
86
|
-
/** One call per accepted path, immediately — precise eviction. */
|
|
87
|
-
onPath(absPath: string): void;
|
|
88
|
-
/** One call per debounce window — rebuild once, broadcast per scope. */
|
|
89
|
-
onChange(scopes: readonly ChangeScope[]): void;
|
|
90
|
-
/** The stamp a newly attached SSE client is told about. */
|
|
91
|
-
currentStamp(): string | null;
|
|
92
|
-
/** Test seam: resolves when the in-flight rebuild settles. */
|
|
93
|
-
settled(): Promise<void>;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function createLivenessBridge(cache: WorkspaceCache, sse: SseHub): LivenessBridge {
|
|
97
|
-
// The last stamp we broadcast, or `null` before the first change. Held
|
|
98
|
-
// rather than derived because `currentStamp` is synchronous (sse.ts) and
|
|
99
|
-
// `cache.snapshot()` is not.
|
|
100
|
-
let lastStamp: string | null = null;
|
|
101
|
-
let inFlight: Promise<void> = Promise.resolve();
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
onPath: (absPath) => cache.invalidate(absPath),
|
|
105
|
-
onChange: (scopes) => {
|
|
106
|
-
const done = (async () => {
|
|
107
|
-
// A rebuild can fail — the vault was deleted mid-session — and a
|
|
108
|
-
// failed broadcast must cost one missed frame, not an unhandled
|
|
109
|
-
// rejection inside a filesystem callback.
|
|
110
|
-
const snapshot = await cache.snapshot().catch(() => null);
|
|
111
|
-
if (snapshot === null) return;
|
|
112
|
-
// The *same* key `/api/graph` puts in its ETag (§5.3, §6): a content
|
|
113
|
-
// digest, not `generatedAt`.
|
|
114
|
-
//
|
|
115
|
-
// This is the half of §15.6 that was easy to miss. The client dedupes
|
|
116
|
-
// frames on `stamp` (`live.model.ts`) and already holds the stamp of
|
|
117
|
-
// the graph it last fetched, so while this broadcast a timestamp max,
|
|
118
|
-
// an edit that did not advance that maximum produced a frame the
|
|
119
|
-
// client discarded *before* issuing the conditional GET. The stale
|
|
120
|
-
// `304` was the second line of defence; this was the first, and it
|
|
121
|
-
// failed for the same three cases. Sharing one key fixes both.
|
|
122
|
-
//
|
|
123
|
-
// `snapshot()`, not `graph()`, because the payload — and therefore
|
|
124
|
-
// the digest — depends on the notes as well as the model (§4.3).
|
|
125
|
-
const stamp = graphStamp(snapshot);
|
|
126
|
-
lastStamp = stamp;
|
|
127
|
-
for (const scope of scopes) sse.broadcast({ scope, stamp });
|
|
128
|
-
})();
|
|
129
|
-
inFlight = inFlight.then(() => done);
|
|
130
|
-
},
|
|
131
|
-
currentStamp: () => lastStamp,
|
|
132
|
-
settled: () => inFlight,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
68
|
/** Everything one running workspace owns. Closed as a unit. */
|
|
137
69
|
export interface WebWorkspaceSession {
|
|
138
70
|
server: WorkspaceServer;
|
|
139
71
|
cache: WorkspaceCache;
|
|
140
|
-
sse: SseHub;
|
|
141
|
-
watcher: Watcher;
|
|
142
|
-
liveness: LivenessBridge;
|
|
143
72
|
}
|
|
144
73
|
|
|
145
74
|
export interface WebWorkspaceDeps {
|
|
@@ -223,7 +152,7 @@ export class WebWorkspaceController {
|
|
|
223
152
|
return { session, started, opened, fallbackToTui: wantsBrowser && !opened };
|
|
224
153
|
}
|
|
225
154
|
|
|
226
|
-
/** Stop the workspace
|
|
155
|
+
/** Stop the workspace server. Idempotent. */
|
|
227
156
|
async close(): Promise<void> {
|
|
228
157
|
// A close arriving mid-boot must still close what that boot produced,
|
|
229
158
|
// or `session_shutdown` during a slow start leaks the whole stack.
|
|
@@ -232,8 +161,6 @@ export class WebWorkspaceController {
|
|
|
232
161
|
const session = this.session;
|
|
233
162
|
if (session === null) return;
|
|
234
163
|
this.session = null;
|
|
235
|
-
// `server.close()` closes the hub and awaits the watcher — it was handed
|
|
236
|
-
// both at construction and owns their teardown (server.ts §5.4).
|
|
237
164
|
await session.server.close();
|
|
238
165
|
this.deps.onStateChange?.();
|
|
239
166
|
}
|
|
@@ -258,34 +185,14 @@ export class WebWorkspaceController {
|
|
|
258
185
|
const cwd = ctx.cwd;
|
|
259
186
|
const vaultRoot = (this.deps.vaultRoot ?? resolveVaultRoot)();
|
|
260
187
|
const cache = new WorkspaceCache({ cwd, vaultRoot });
|
|
261
|
-
const sse = new SseHub({ currentStamp: () => liveness.currentStamp() });
|
|
262
|
-
const liveness = createLivenessBridge(cache, sse);
|
|
263
|
-
|
|
264
|
-
const watcher = new Watcher({
|
|
265
|
-
cwd,
|
|
266
|
-
vaultRoot,
|
|
267
|
-
// Per-path, immediately: eviction wants every path (§6).
|
|
268
|
-
onPath: (absPath) => liveness.onPath(absPath),
|
|
269
|
-
// Per debounce window: one frame per scope, whatever the path count.
|
|
270
|
-
onChange: (scopes) => liveness.onChange(scopes),
|
|
271
|
-
});
|
|
272
|
-
|
|
273
188
|
const startServer = this.deps.startServer ?? startWorkspaceServer;
|
|
274
189
|
const server = await startServer({
|
|
275
190
|
cwd,
|
|
276
191
|
vaultRoot,
|
|
277
192
|
cache,
|
|
278
|
-
sse,
|
|
279
|
-
watcher: asServerWatcher(watcher),
|
|
280
|
-
// The idle timeout closed the server behind our back; forget it so the
|
|
281
|
-
// next `/weave-view` boots a fresh one instead of handing out a dead port.
|
|
282
|
-
onIdleShutdown: () => {
|
|
283
|
-
this.session = null;
|
|
284
|
-
this.deps.onStateChange?.();
|
|
285
|
-
},
|
|
286
193
|
});
|
|
287
194
|
|
|
288
|
-
return { server, cache
|
|
195
|
+
return { server, cache };
|
|
289
196
|
}
|
|
290
197
|
|
|
291
198
|
/** Spawn the browser, reporting failure rather than throwing it. */
|
|
@@ -303,23 +210,6 @@ export class WebWorkspaceController {
|
|
|
303
210
|
}
|
|
304
211
|
}
|
|
305
212
|
|
|
306
|
-
/**
|
|
307
|
-
* Adapt the concrete {@link Watcher} to the promise-shaped contract
|
|
308
|
-
* `server.ts` owns. The class is synchronous because `fs.watch` is; the
|
|
309
|
-
* interface is async because a future watcher (a `chokidar`-style poller, a
|
|
310
|
-
* remote FS) would not be.
|
|
311
|
-
*/
|
|
312
|
-
function asServerWatcher(watcher: Watcher): ServerWatcher {
|
|
313
|
-
return {
|
|
314
|
-
start: async () => {
|
|
315
|
-
watcher.start();
|
|
316
|
-
},
|
|
317
|
-
close: async () => {
|
|
318
|
-
watcher.close();
|
|
319
|
-
},
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
213
|
/** The one notification, worded for whichever path got us here. */
|
|
324
214
|
function describe(facts: { url: string; started: boolean; opened: boolean; wantsBrowser: boolean }): string {
|
|
325
215
|
const where = facts.started ? "workspace running at" : "workspace already running at";
|
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
* Something still has to call the real thing. That is this file, and it is
|
|
11
11
|
* separate so the untestable surface — one reference to a DOM global — is
|
|
12
12
|
* isolated in four lines rather than smeared through the module that holds
|
|
13
|
-
* all the request logic.
|
|
14
|
-
* same reason.
|
|
13
|
+
* all the request logic.
|
|
15
14
|
*
|
|
16
15
|
* This module is only reachable from `.tsx` entry points, so it is compiled
|
|
17
16
|
* by `tsconfig.web.json` (which has `DOM`) and never pulled into the root
|
|
@@ -32,6 +31,7 @@ export const fetchJson: FetchLike = (url: string, init?: HttpRequest): Promise<H
|
|
|
32
31
|
...(init?.method === undefined ? {} : { method: init.method }),
|
|
33
32
|
...(init?.headers === undefined ? {} : { headers: init.headers }),
|
|
34
33
|
...(init?.body === undefined ? {} : { body: init.body }),
|
|
34
|
+
...(init?.signal === undefined ? {} : { signal: init.signal as AbortSignal }),
|
|
35
35
|
// Loopback only, and the `__Host-weave` cookie is how §5.1 authenticates.
|
|
36
36
|
// `same-origin` is the default in modern browsers; stated explicitly
|
|
37
37
|
// because the whole security model depends on it and a default is a worse
|