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.
- package/README.md +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -1,60 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Compatibility re-export.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|