pi-weave 0.1.7 → 0.1.8

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 (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -1,60 +1,11 @@
1
1
  /**
2
- * openNote — open a vault note in the user's editor (weave-view tui).
2
+ * Compatibility re-export.
3
3
  *
4
- * Lifted out of the old browser viewer's `server.ts` when the in-browser
5
- * viewer was retired in favour of a pixi.js rewrite; the TUI's `openNote`
6
- * loader is the only remaining caller. The slug is validated against the
7
- * vault (traversal-safe) before any shell-out, so an unsafe or missing
8
- * note never reaches the editor.
4
+ * The implementation moved to `src/core/openInEditor.ts` (weave-workspace
5
+ * §5.3): the browser workspace's `POST /api/open` needs it and
6
+ * `src/web/server/**` may not import `src/pi`. Keeping this module as a
7
+ * one-line re-export means `run.ts` and every TUI test kept working without
8
+ * an edit; there is nothing TUI-specific left to live here.
9
9
  */
10
10
 
11
- import { execFile } from "node:child_process";
12
- import { platform } from "node:os";
13
- import { promisify } from "node:util";
14
- import { getNote, resolveNotePath } from "../../../core";
15
-
16
- const execFileAsync = promisify(execFile);
17
-
18
- /**
19
- * The OS command used to open a note file in the user's editor. Respects
20
- * $EDITOR / $VISUAL (which may carry args, e.g. "code --wait"); falls back
21
- * to the platform default opener. `env` and `os` are injectable so the
22
- * mapping is unit-testable on any host without stubbing globals.
23
- */
24
- export function openNoteCommand(
25
- notePath: string,
26
- env: NodeJS.ProcessEnv = process.env,
27
- os: NodeJS.Platform = platform(),
28
- ): { command: string; args: string[] } {
29
- const editor = (env.EDITOR || env.VISUAL || "").trim();
30
- if (editor) {
31
- // editor is a trimmed non-empty string, so split+filter always yields a
32
- // first segment — assert it rather than leave a dead `?? ""` branch.
33
- const parts = editor.split(/\s+/).filter(Boolean);
34
- const command = parts[0] as string;
35
- return { command, args: [...parts.slice(1), notePath] };
36
- }
37
- if (os === "darwin") return { command: "open", args: [notePath] };
38
- if (os === "win32") return { command: "cmd", args: ["/c", "start", "", notePath] };
39
- return { command: "xdg-open", args: [notePath] };
40
- }
41
-
42
- /**
43
- * Open a note in the OS editor. The slug is validated against the vault
44
- * (traversal-safe) before any shell-out; returns false when the note does
45
- * not exist or the slug is unsafe.
46
- */
47
- export async function openNoteInEditor(
48
- vaultRoot: string,
49
- slug: string,
50
- openCommand: (notePath: string) => { command: string; args: string[] } = openNoteCommand,
51
- ): Promise<boolean> {
52
- const path = resolveNotePath(vaultRoot, slug);
53
- if (path === null) return false; // traversal-safe
54
- const note = await getNote(vaultRoot, slug);
55
- if (note === null) return false;
56
- const { command, args } = openCommand(path);
57
- if (!command) return false;
58
- await execFileAsync(command, args);
59
- return true;
60
- }
11
+ export { openNoteCommand, openNoteInEditor } from "../../../core/openInEditor";
@@ -11,6 +11,7 @@
11
11
  import { truncateToWidth } from "@earendil-works/pi-tui";
12
12
  import { decodeAction } from "../explorer";
13
13
  import {
14
+ formatTreeMeta,
14
15
  graphRoots,
15
16
  initialState,
16
17
  reduce,
@@ -126,8 +127,8 @@ export class ExploreSurface implements Surface {
126
127
  showInternals: this.state.showInternals,
127
128
  provFilter: this.state.provFilter,
128
129
  query: this.state.query,
129
- now: this.ctx.now(),
130
130
  });
131
+ const now = this.ctx.now();
131
132
  const hint = treeEmptyHint(this.ctx.model);
132
133
  if (rows.length === 0 && hint) {
133
134
  return { lines: [t.fg("dim", hint)], rows: [] };
@@ -136,7 +137,8 @@ export class ExploreSurface implements Surface {
136
137
  const chev = chevron(r.expanded, r.hasKids);
137
138
  const marker = this.rowMarker(r.kind, r.provenance);
138
139
  const label = sanitize(r.label);
139
- const meta = r.meta ? t.fg("dim", ` ${sanitize(r.meta)}`) : "";
140
+ const metaText = formatTreeMeta(r.meta, now);
141
+ const meta = metaText ? t.fg("dim", ` ${sanitize(metaText)}`) : "";
140
142
  const indent = " ".repeat(r.depth);
141
143
  const body = `${indent}${chev} ${marker}${label}`;
142
144
  return { text: meta ? `${body}${meta}` : body, id: r.id };
@@ -0,0 +1,331 @@
1
+ /**
2
+ * `/weave-view` — the browser workspace, wired into a pi session
3
+ * (weave-workspace §5.4, §6, §13).
4
+ *
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
+ * the SSE hub, the watcher). What is left here is the wiring those three
8
+ * cannot do for themselves, because it is session-shaped:
9
+ *
10
+ * 1. **Composition.** Build one {@link WorkspaceCache} over `ctx.cwd` +
11
+ * the resolved vault root, one {@link SseHub}, one {@link Watcher}, and
12
+ * hand the last two to `startWorkspaceServer` so it owns their teardown.
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
17
+ * running server — a second server would mean a second port, a second
18
+ * watcher on the same directories, and a browser tab pointed at a
19
+ * workspace nobody is going to close (§5.4).
20
+ * 4. **Browser handoff**, with an honest fallback when there is no browser
21
+ * to hand off to.
22
+ *
23
+ * ## Why the browser opener is not `openNoteCommand`
24
+ *
25
+ * `src/core/openInEditor.ts` prefers `$EDITOR`/`$VISUAL` before the platform
26
+ * opener, which is exactly right for a *file* and exactly wrong for a *URL*:
27
+ * a user with `EDITOR=vim` would get vim staring at `http://127.0.0.1:…`.
28
+ * Only the platform-opener tail generalises, and that tail is three lines,
29
+ * so {@link browserOpenCommand} restates it rather than growing a mode flag
30
+ * through core's editor path.
31
+ *
32
+ * ## Why the browser is spawned through `pi.exec` and not `execFile`
33
+ *
34
+ * The harness already owns subprocess policy (cancellation, timeouts, the
35
+ * user's trust prompts) and, pragmatically, it is the seam the mock harness
36
+ * records — so the test asserts the exact command the user's machine would
37
+ * run, rather than that some private function was called.
38
+ */
39
+
40
+ import { platform as osPlatform } from "node:os";
41
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
42
+ import { resolveVaultRoot, WorkspaceCache } from "../../../core";
43
+ import { graphStamp, type Watcher as ServerWatcher } from "../../../web/server/routes";
44
+ import {
45
+ startWorkspaceServer,
46
+ type StartWorkspaceServerOptions,
47
+ type WorkspaceServer,
48
+ } 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
+
53
+ /** The slice of `ExtensionAPI.exec` this module uses. */
54
+ export type ExecFn = (command: string, args: string[]) => Promise<{ code: number; stderr: string }>;
55
+
56
+ /** `startWorkspaceServer`, as a seam so tests can pin `idleMs` and the bundle. */
57
+ export type StartServerFn = (opts: StartWorkspaceServerOptions) => Promise<WorkspaceServer>;
58
+
59
+ /**
60
+ * The command that hands a URL to the desktop's default browser.
61
+ *
62
+ * `os` is injectable so the whole mapping is unit-testable on any host
63
+ * without stubbing globals — the same convention as `openNoteCommand`.
64
+ */
65
+ export function browserOpenCommand(
66
+ url: string,
67
+ os: NodeJS.Platform = osPlatform(),
68
+ ): { command: string; args: string[] } {
69
+ if (os === "darwin") return { command: "open", args: [url] };
70
+ // The empty string is `start`'s title argument. Without it, a URL that
71
+ // happens to be quoted is consumed as the window title and nothing opens.
72
+ if (os === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
73
+ return { command: "xdg-open", args: [url] };
74
+ }
75
+
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
+ /** Everything one running workspace owns. Closed as a unit. */
137
+ export interface WebWorkspaceSession {
138
+ server: WorkspaceServer;
139
+ cache: WorkspaceCache;
140
+ sse: SseHub;
141
+ watcher: Watcher;
142
+ liveness: LivenessBridge;
143
+ }
144
+
145
+ export interface WebWorkspaceDeps {
146
+ /** `ExtensionAPI.exec`. Used only to launch the browser. */
147
+ exec: ExecFn;
148
+ /** Defaults to {@link startWorkspaceServer}. */
149
+ startServer?: StartServerFn | undefined;
150
+ /** Defaults to {@link browserOpenCommand} on the host platform. */
151
+ openCommand?: ((url: string) => { command: string; args: string[] }) | undefined;
152
+ /** Defaults to {@link resolveVaultRoot}, which honours `PI_WEAVE_VAULT`. */
153
+ vaultRoot?: (() => string) | undefined;
154
+ /** Called whenever the running/​stopped state changes, so the status line can follow. */
155
+ onStateChange?: (() => void) | undefined;
156
+ }
157
+
158
+ /** What {@link WebWorkspaceController.run} was asked to do. */
159
+ export interface RunWebOptions {
160
+ /** `false` for `--no-open`: print the URL, launch nothing. */
161
+ open: boolean;
162
+ }
163
+
164
+ /** Outcome of one `/weave-view` invocation, for the caller's next move. */
165
+ export interface RunWebOutcome {
166
+ session: WebWorkspaceSession;
167
+ /** True when this call booted the server rather than reusing one. */
168
+ started: boolean;
169
+ /** True when a browser was actually launched successfully. */
170
+ opened: boolean;
171
+ /**
172
+ * True when the caller should fall back to the in-terminal explorer:
173
+ * a browser was wanted, was attempted, and did not launch. The server
174
+ * stays up regardless — the URL is still the user's way back in.
175
+ */
176
+ fallbackToTui: boolean;
177
+ }
178
+
179
+ /**
180
+ * Owns the at-most-one workspace server for a pi session.
181
+ *
182
+ * A class rather than module state so that two extension instances in one
183
+ * test process (or, one day, two pi sessions in one host) do not share a
184
+ * server. It holds a lifetime and open OS handles; that is what a class is
185
+ * for here, and it mirrors {@link WorkspaceCache}'s reasoning.
186
+ */
187
+ export class WebWorkspaceController {
188
+ private readonly deps: WebWorkspaceDeps;
189
+ private session: WebWorkspaceSession | null = null;
190
+ /** The single in-flight boot, so two fast `/weave-view`s do not race. */
191
+ private booting: Promise<WebWorkspaceSession> | null = null;
192
+
193
+ constructor(deps: WebWorkspaceDeps) {
194
+ this.deps = deps;
195
+ }
196
+
197
+ /** Bound port while a workspace is running, else `null`. Drives the status line. */
198
+ port(): number | null {
199
+ return this.session?.server.port ?? null;
200
+ }
201
+
202
+ /**
203
+ * Start (or reuse) the workspace and hand the URL to the user.
204
+ *
205
+ * The URL is notified on **every** path — `--no-open`, a headless
206
+ * session, and a failed browser launch all leave the user a working link
207
+ * rather than a server they cannot find.
208
+ */
209
+ async run(ctx: ExtensionCommandContext, opts: RunWebOptions): Promise<RunWebOutcome> {
210
+ const existing = this.session;
211
+ const session = existing ?? (await this.boot(ctx));
212
+ const started = existing === null;
213
+
214
+ // Only a session with a UI has a browser worth spawning: over `--mode
215
+ // rpc` or a plain SSH pipe there is no desktop on this side of the
216
+ // connection, and `xdg-open` would fail (or worse, succeed on the
217
+ // wrong machine).
218
+ const wantsBrowser = opts.open && ctx.hasUI;
219
+ const opened = wantsBrowser ? await this.launch(session.server.entryUrl) : false;
220
+
221
+ ctx.ui.notify(describe({ url: session.server.entryUrl, started, opened, wantsBrowser }), "info");
222
+
223
+ return { session, started, opened, fallbackToTui: wantsBrowser && !opened };
224
+ }
225
+
226
+ /** Stop the workspace: server, watcher and every SSE stream. Idempotent. */
227
+ async close(): Promise<void> {
228
+ // A close arriving mid-boot must still close what that boot produced,
229
+ // or `session_shutdown` during a slow start leaks the whole stack.
230
+ const booting = this.booting;
231
+ if (booting !== null) await booting.catch(() => undefined);
232
+ const session = this.session;
233
+ if (session === null) return;
234
+ 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
+ await session.server.close();
238
+ this.deps.onStateChange?.();
239
+ }
240
+
241
+ // --- internals --------------------------------------------------------------
242
+
243
+ private async boot(ctx: ExtensionCommandContext): Promise<WebWorkspaceSession> {
244
+ if (this.booting !== null) return this.booting;
245
+ const boot = this.bootOnce(ctx);
246
+ this.booting = boot;
247
+ try {
248
+ const session = await boot;
249
+ this.session = session;
250
+ this.deps.onStateChange?.();
251
+ return session;
252
+ } finally {
253
+ this.booting = null;
254
+ }
255
+ }
256
+
257
+ private async bootOnce(ctx: ExtensionCommandContext): Promise<WebWorkspaceSession> {
258
+ const cwd = ctx.cwd;
259
+ const vaultRoot = (this.deps.vaultRoot ?? resolveVaultRoot)();
260
+ 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
+ const startServer = this.deps.startServer ?? startWorkspaceServer;
274
+ const server = await startServer({
275
+ cwd,
276
+ vaultRoot,
277
+ 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
+ });
287
+
288
+ return { server, cache, sse, watcher, liveness };
289
+ }
290
+
291
+ /** Spawn the browser, reporting failure rather than throwing it. */
292
+ private async launch(url: string): Promise<boolean> {
293
+ const open = this.deps.openCommand ?? ((u: string) => browserOpenCommand(u));
294
+ const { command, args } = open(url);
295
+ try {
296
+ const result = await this.deps.exec(command, args);
297
+ return result.code === 0;
298
+ } catch {
299
+ // No such binary (a container with no `xdg-open`), or the harness
300
+ // refused the spawn. Either way the user gets the URL and a reason.
301
+ return false;
302
+ }
303
+ }
304
+ }
305
+
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
+ /** The one notification, worded for whichever path got us here. */
324
+ function describe(facts: { url: string; started: boolean; opened: boolean; wantsBrowser: boolean }): string {
325
+ const where = facts.started ? "workspace running at" : "workspace already running at";
326
+ if (facts.opened) return `pi-weave: ${where} ${facts.url} — opened in your browser.`;
327
+ if (facts.wantsBrowser) {
328
+ return `pi-weave: ${where} ${facts.url} — could not launch a browser; open the URL yourself (falling back to the in-terminal explorer).`;
329
+ }
330
+ return `pi-weave: ${where} ${facts.url} — open it in a browser.`;
331
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The platform `fetch`, adapted to `api.ts`'s injected port.
3
+ *
4
+ * `api.ts` deliberately does not name `fetch`, `Response` or `RequestInit`:
5
+ * it is a `.ts` compiled by the **root** `tsconfig.json` whenever a test
6
+ * imports it, and that project has no `DOM` lib, so those types do not exist
7
+ * there. {@link FetchLike} and {@link HttpResponse} are the structural ports
8
+ * that stand in for them.
9
+ *
10
+ * Something still has to call the real thing. That is this file, and it is
11
+ * separate so the untestable surface — one reference to a DOM global — is
12
+ * isolated in four lines rather than smeared through the module that holds
13
+ * all the request logic. Same shape as `domEventSource` in `live.ts`, for the
14
+ * same reason.
15
+ *
16
+ * This module is only reachable from `.tsx` entry points, so it is compiled
17
+ * by `tsconfig.web.json` (which has `DOM`) and never pulled into the root
18
+ * project. That is what lets it name `fetch` at all.
19
+ */
20
+
21
+ import type { FetchLike, HttpRequest, HttpResponse } from "./api";
22
+
23
+ /**
24
+ * `globalThis.fetch`, as a {@link FetchLike}.
25
+ *
26
+ * The real `Response` satisfies {@link HttpResponse} structurally — `ok`,
27
+ * `status` and `json()` are all present — so the only work here is dropping
28
+ * the `RequestInit` fields `api.ts` never sets.
29
+ */
30
+ export const fetchJson: FetchLike = (url: string, init?: HttpRequest): Promise<HttpResponse> =>
31
+ fetch(url, {
32
+ ...(init?.method === undefined ? {} : { method: init.method }),
33
+ ...(init?.headers === undefined ? {} : { headers: init.headers }),
34
+ ...(init?.body === undefined ? {} : { body: init.body }),
35
+ // Loopback only, and the `__Host-weave` cookie is how §5.1 authenticates.
36
+ // `same-origin` is the default in modern browsers; stated explicitly
37
+ // because the whole security model depends on it and a default is a worse
38
+ // place for that dependency than a line of code.
39
+ credentials: "same-origin",
40
+ });