@gavin-hu/my-pi-agent 0.20.0 → 0.21.0
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 +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/extensions/README.md +1 -0
- package/docs/extensions/board.md +57 -42
- package/docs/extensions/bytefly.md +85 -89
- package/docs/extensions/file-browse.md +36 -41
- package/docs/extensions/plan.md +31 -14
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +1 -1
- package/docs/extensions/team.md +11 -11
- package/extensions/board/commands.ts +27 -14
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +53 -83
- package/extensions/board/result.ts +1 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +9 -3
- package/extensions/board/snapshot.ts +7 -6
- package/extensions/board/tools.ts +27 -15
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +0 -10
- package/extensions/board/view/client.ts +3 -2
- package/extensions/board/view/live.ts +1 -7
- package/extensions/board/view/page.ts +16 -5
- package/extensions/board/view/render.ts +22 -17
- package/extensions/board/view/styles.ts +7 -1
- package/extensions/bytefly/footer/config.ts +3 -6
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +4 -4
- package/extensions/bytefly/footer/render/segments.ts +10 -34
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -4
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/html.ts +73 -40
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +13 -14
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +9 -9
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/index.ts +7 -1
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/runtime.ts +12 -2
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +99 -2
- package/extensions/plan/web-collab.ts +4 -4
- package/extensions/plan/web-comments.ts +39 -8
- package/extensions/plan/web-composer.ts +14 -3
- package/extensions/plan/web-keys.ts +149 -25
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +22 -2
- package/extensions/plan/web-page.ts +56 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +23 -13
- package/extensions/plan/web-settle.ts +14 -7
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/team/commands.ts +3 -1
- package/extensions/todo/tui.ts +21 -3
- package/lib/README.md +5 -4
- package/lib/board/page-pointer.ts +29 -4
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +34 -8
- package/lib/board/view.ts +20 -14
- package/lib/bus-request.ts +83 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/plan-entry.ts +9 -13
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-tool.ts +37 -21
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +2 -4
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/markup.ts +18 -1
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
|
@@ -16,6 +16,7 @@ import { screenHint } from "../../../lib/tui/chrome.ts";
|
|
|
16
16
|
import { keyHintText } from "../../../lib/tui/glyphs.ts";
|
|
17
17
|
import { BODY_INDENT } from "../../../lib/tui/indent.ts";
|
|
18
18
|
import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
|
|
19
|
+
import { RenderCache } from "../../../lib/tui/render-cache.ts";
|
|
19
20
|
import { paint } from "../../../lib/tui/tokens.ts";
|
|
20
21
|
import { HEADER_CONFIG, type HeaderHeight, type HelpEntry } from "./config.ts";
|
|
21
22
|
import { identityCells } from "./identity.ts";
|
|
@@ -69,11 +70,7 @@ function helpParts(entry: HelpEntry): { key: string; description: string } {
|
|
|
69
70
|
|
|
70
71
|
/** The themed header component. */
|
|
71
72
|
export class ByteflyHeader implements Component {
|
|
72
|
-
private
|
|
73
|
-
private cachedTheme: Theme | undefined;
|
|
74
|
-
private cachedView = "";
|
|
75
|
-
private cachedKeys = "";
|
|
76
|
-
private cachedLines: string[] = [];
|
|
73
|
+
private readonly cache = new RenderCache();
|
|
77
74
|
|
|
78
75
|
constructor(
|
|
79
76
|
private readonly getTheme: () => Theme,
|
|
@@ -82,7 +79,7 @@ export class ByteflyHeader implements Component {
|
|
|
82
79
|
) {}
|
|
83
80
|
|
|
84
81
|
invalidate(): void {
|
|
85
|
-
this.
|
|
82
|
+
this.cache.invalidate();
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
/**
|
|
@@ -97,20 +94,7 @@ export class ByteflyHeader implements Component {
|
|
|
97
94
|
const view = this.view();
|
|
98
95
|
const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}:${view.rows ?? ""}`;
|
|
99
96
|
const keys = this.keySignature();
|
|
100
|
-
|
|
101
|
-
this.cachedWidth === w &&
|
|
102
|
-
this.cachedTheme === theme &&
|
|
103
|
-
this.cachedView === viewKey &&
|
|
104
|
-
this.cachedKeys === keys
|
|
105
|
-
) {
|
|
106
|
-
return this.cachedLines;
|
|
107
|
-
}
|
|
108
|
-
this.cachedLines = this.build(w, theme, view);
|
|
109
|
-
this.cachedWidth = w;
|
|
110
|
-
this.cachedTheme = theme;
|
|
111
|
-
this.cachedView = viewKey;
|
|
112
|
-
this.cachedKeys = keys;
|
|
113
|
-
return this.cachedLines;
|
|
97
|
+
return this.cache.keyed([w, theme, viewKey, keys], () => this.build(w, theme, view));
|
|
114
98
|
}
|
|
115
99
|
|
|
116
100
|
/** The chord of every help binding, so a rebind repaints the help rows. */
|
|
@@ -16,6 +16,7 @@ import type { Component } from "@earendil-works/pi-tui";
|
|
|
16
16
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
17
17
|
import { clip } from "../../lib/tui/columns.ts";
|
|
18
18
|
import { expandHint, GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
19
|
+
import { RenderCache } from "../../lib/tui/render-cache.ts";
|
|
19
20
|
import { listRow } from "../../lib/tui/rows.ts";
|
|
20
21
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
21
22
|
import { earlierHint } from "../../lib/tui/tool.ts";
|
|
@@ -134,6 +135,7 @@ export interface CheckResultInput {
|
|
|
134
135
|
export class CheckResultBlock implements Component {
|
|
135
136
|
private input: CheckResultInput;
|
|
136
137
|
private theme: Theme;
|
|
138
|
+
private readonly cache = new RenderCache();
|
|
137
139
|
|
|
138
140
|
constructor(input: CheckResultInput, theme: Theme) {
|
|
139
141
|
this.input = input;
|
|
@@ -144,12 +146,19 @@ export class CheckResultBlock implements Component {
|
|
|
144
146
|
setInput(input: CheckResultInput, theme: Theme): void {
|
|
145
147
|
this.input = input;
|
|
146
148
|
this.theme = theme;
|
|
149
|
+
this.cache.invalidate();
|
|
147
150
|
}
|
|
148
151
|
|
|
149
|
-
invalidate(): void {
|
|
152
|
+
invalidate(): void {
|
|
153
|
+
this.cache.invalidate();
|
|
154
|
+
}
|
|
150
155
|
|
|
151
156
|
render(width: number): string[] {
|
|
152
157
|
const w = Math.max(1, width);
|
|
158
|
+
return this.cache.lines(w, undefined, () => this.build(w));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private build(w: number): string[] {
|
|
153
162
|
const theme = this.theme;
|
|
154
163
|
const { details, expanded } = this.input;
|
|
155
164
|
const role = statusRole(details.status);
|
|
@@ -2,16 +2,14 @@
|
|
|
2
2
|
* Configuration for the browse extension.
|
|
3
3
|
*
|
|
4
4
|
* The `file-browse` section of the shared `bytefly.json` (global then project,
|
|
5
|
-
* project wins). Everything
|
|
6
|
-
* is
|
|
5
|
+
* project wins). Everything is validated and clamped so a typo cannot produce a
|
|
6
|
+
* nonsensical page. The port is not here: the session serves every page on one, and
|
|
7
|
+
* `serve.port` is where that is pinned.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import { randomInt } from "node:crypto";
|
|
10
10
|
import { asBoolean, clampInteger, loadConfigSection } from "../../../lib/config.ts";
|
|
11
11
|
|
|
12
12
|
export interface ServeConfig {
|
|
13
|
-
/** TCP port on 127.0.0.1; `0` picks one random port fixed for the session, a positive value pins it. */
|
|
14
|
-
port: number;
|
|
15
13
|
/** Open the default browser when the server starts. */
|
|
16
14
|
autoOpen: boolean;
|
|
17
15
|
/** Files larger than this are not rendered inline. */
|
|
@@ -43,7 +41,6 @@ export interface ServeConfig {
|
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
export const DEFAULT_CONFIG: ServeConfig = {
|
|
46
|
-
port: 0,
|
|
47
44
|
autoOpen: true,
|
|
48
45
|
maxFileBytes: 1024 * 1024,
|
|
49
46
|
maxThumbBytes: 5 * 1024 * 1024,
|
|
@@ -60,12 +57,6 @@ export const DEFAULT_CONFIG: ServeConfig = {
|
|
|
60
57
|
liveIntervalMs: 5000,
|
|
61
58
|
};
|
|
62
59
|
|
|
63
|
-
/** Base of the per-session port range. */
|
|
64
|
-
export const PORT_BASE = 4780;
|
|
65
|
-
|
|
66
|
-
/** Number of ports in the per-session range. */
|
|
67
|
-
export const PORT_RANGE = 1000;
|
|
68
|
-
|
|
69
60
|
/** Fastest live-status cadence a page may be given: short enough to watch, and a
|
|
70
61
|
* floor so a page cannot outrun the git reads behind each frame. */
|
|
71
62
|
export const LIVE_INTERVAL_MIN = 250;
|
|
@@ -76,7 +67,7 @@ export const LIVE_INTERVAL_MAX = 60_000;
|
|
|
76
67
|
/**
|
|
77
68
|
* How long the server keeps listening once the last page has gone. A reload or a
|
|
78
69
|
* navigation detaches the stream and re-attaches within a moment, so the grace has
|
|
79
|
-
* to outlast one; a closed window never re-attaches, and the
|
|
70
|
+
* to outlast one; a closed window never re-attaches, and the prefix is given back
|
|
80
71
|
* instead of listening for nobody until the session ends.
|
|
81
72
|
*/
|
|
82
73
|
export const IDLE_STOP_MS = 10_000;
|
|
@@ -90,11 +81,6 @@ export const IDLE_STOP_MS = 10_000;
|
|
|
90
81
|
*/
|
|
91
82
|
export const LIVE_MAX_CLIENTS = 8;
|
|
92
83
|
|
|
93
|
-
/** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
|
|
94
|
-
export function randomPort(): number {
|
|
95
|
-
return PORT_BASE + randomInt(PORT_RANGE);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
84
|
/** Validate and clamp a raw config object over the defaults. */
|
|
99
85
|
export function normalizeConfig(
|
|
100
86
|
raw: Record<string, unknown> | undefined,
|
|
@@ -102,7 +88,6 @@ export function normalizeConfig(
|
|
|
102
88
|
): ServeConfig {
|
|
103
89
|
if (!raw) return base;
|
|
104
90
|
return {
|
|
105
|
-
port: clampInteger(raw.port, base.port, 0, 65535),
|
|
106
91
|
autoOpen: asBoolean(raw.autoOpen, base.autoOpen),
|
|
107
92
|
maxFileBytes: clampInteger(raw.maxFileBytes, base.maxFileBytes, 0, 1024 * 1024 * 1024),
|
|
108
93
|
maxThumbBytes: clampInteger(raw.maxThumbBytes, base.maxThumbBytes, 0, 1024 * 1024 * 1024),
|
|
@@ -14,9 +14,8 @@ import { realpathSync } from "node:fs";
|
|
|
14
14
|
import { stat } from "node:fs/promises";
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { loadConfig, randomPort } from "./config/config.ts";
|
|
17
|
+
import { requestMount } from "../../lib/http/index.ts";
|
|
18
|
+
import { loadConfig } from "./config/config.ts";
|
|
20
19
|
import { createGitDiff, createGitStatus } from "./git/git.ts";
|
|
21
20
|
import { openInBrowser } from "../../lib/browser.ts";
|
|
22
21
|
import { HttpError, resolveRequestPath } from "./files/paths.ts";
|
|
@@ -27,8 +26,6 @@ export interface FileBrowserDeps {
|
|
|
27
26
|
createServer?: typeof createFileServer;
|
|
28
27
|
/** Override the browser opener (tests). */
|
|
29
28
|
open?: typeof openInBrowser;
|
|
30
|
-
/** Override session-port selection (tests). */
|
|
31
|
-
pickPort?: () => number;
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
interface RunningServer {
|
|
@@ -40,40 +37,25 @@ interface RunningServer {
|
|
|
40
37
|
export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}): void {
|
|
41
38
|
if (!isExtensionEnabled("file-browse")) return;
|
|
42
39
|
let running: RunningServer | undefined;
|
|
43
|
-
/**
|
|
44
|
-
let
|
|
40
|
+
/** The session this page serves as; the mount lands on its one server. */
|
|
41
|
+
let sessionId = "";
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
const setBrowseStatus = (ctx: ExtensionContext, port: number | undefined): void => {
|
|
48
|
-
try {
|
|
49
|
-
const value =
|
|
50
|
-
port === undefined
|
|
51
|
-
? undefined
|
|
52
|
-
: `${paint(ctx.ui.theme, "ok", GLYPHS.browse)} ${paint(ctx.ui.theme, "accent", String(port))}`;
|
|
53
|
-
ctx.ui.setStatus(STATUS_KEYS.browse, value);
|
|
54
|
-
} catch {
|
|
55
|
-
// A UI without a theme or setStatus must not break the command.
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const stop = async (ctx?: ExtensionContext): Promise<boolean> => {
|
|
43
|
+
const stop = async (): Promise<boolean> => {
|
|
60
44
|
const current = running;
|
|
61
45
|
if (!current) return false;
|
|
62
46
|
running = undefined;
|
|
63
47
|
await current.server.close();
|
|
64
|
-
if (ctx) setBrowseStatus(ctx, undefined);
|
|
65
48
|
return true;
|
|
66
49
|
};
|
|
67
50
|
|
|
68
51
|
/**
|
|
69
|
-
*
|
|
70
|
-
* left to serve
|
|
71
|
-
* one. `/browse` starts it again on the same session port.
|
|
52
|
+
* Give the prefix back once the page that was its only reader has gone: nothing is
|
|
53
|
+
* left to serve. `/browse` starts it again on the session's port.
|
|
72
54
|
*/
|
|
73
55
|
const stopWhenPageLeft = async (): Promise<void> => {
|
|
74
56
|
const current = running;
|
|
75
57
|
if (!current) return;
|
|
76
|
-
if (await stop(
|
|
58
|
+
if (await stop()) current.ctx.ui.notify("browse: stopped — the last page was closed", "info");
|
|
77
59
|
};
|
|
78
60
|
|
|
79
61
|
const start = async (args: string, ctx: ExtensionContext): Promise<void> => {
|
|
@@ -86,15 +68,12 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
86
68
|
return;
|
|
87
69
|
}
|
|
88
70
|
if (target === "stop") {
|
|
89
|
-
ctx.ui.notify((await stop(
|
|
71
|
+
ctx.ui.notify((await stop()) ? "browse: stopped" : "browse: not running", "info");
|
|
90
72
|
return;
|
|
91
73
|
}
|
|
92
74
|
|
|
93
75
|
const cwd = resolveEffectiveCwd(ctx.cwd);
|
|
94
76
|
const config = loadConfig(cwd);
|
|
95
|
-
// A pinned config port wins; otherwise one random port is fixed for the session.
|
|
96
|
-
const unpinned = config.port <= 0;
|
|
97
|
-
const port = unpinned ? (sessionPort ?? (deps.pickPort ?? randomPort)()) : config.port;
|
|
98
77
|
let root = cwd;
|
|
99
78
|
if (target) {
|
|
100
79
|
try {
|
|
@@ -117,17 +96,19 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
117
96
|
} catch {
|
|
118
97
|
// Keep the unresolved root; the server factory reports a real failure.
|
|
119
98
|
}
|
|
120
|
-
if (running && running.server.root === realRoot
|
|
99
|
+
if (running && running.server.root === realRoot) {
|
|
121
100
|
ctx.ui.notify(`browse: ${running.server.url} (already running)`, "info");
|
|
122
101
|
return;
|
|
123
102
|
}
|
|
124
103
|
|
|
125
|
-
await stop(
|
|
104
|
+
await stop();
|
|
126
105
|
const create = deps.createServer ?? createFileServer;
|
|
127
106
|
try {
|
|
128
|
-
const
|
|
107
|
+
const outcome = await create({
|
|
129
108
|
root,
|
|
130
|
-
config
|
|
109
|
+
config,
|
|
110
|
+
mount: (spec) => requestMount(pi, spec),
|
|
111
|
+
session: sessionId,
|
|
131
112
|
git: createGitStatus(pi, root),
|
|
132
113
|
diff: createGitDiff(pi, root, {
|
|
133
114
|
context: config.diffContext,
|
|
@@ -137,21 +118,16 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
137
118
|
}),
|
|
138
119
|
onIdle: () => void stopWhenPageLeft(),
|
|
139
120
|
});
|
|
140
|
-
if (
|
|
121
|
+
if (outcome.kind === "refused") {
|
|
122
|
+
ctx.ui.notify(`browse: ${outcome.reason}`, "error");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const server = outcome.server;
|
|
141
126
|
running = { server, ctx };
|
|
142
|
-
setBrowseStatus(ctx, server.port);
|
|
143
127
|
ctx.ui.notify(`browse: ${server.root} at ${server.url}`, "info");
|
|
144
128
|
if (config.autoOpen) (deps.open ?? openInBrowser)(server.url);
|
|
145
129
|
} catch (error) {
|
|
146
|
-
|
|
147
|
-
if (code === "EADDRINUSE") {
|
|
148
|
-
ctx.ui.notify(
|
|
149
|
-
`browse: port ${port} is in use; run /browse again to try another or set "port" in the bytefly.json file-browse section`,
|
|
150
|
-
"error",
|
|
151
|
-
);
|
|
152
|
-
} else {
|
|
153
|
-
ctx.ui.notify(`browse: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
154
|
-
}
|
|
130
|
+
ctx.ui.notify(`browse: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
155
131
|
}
|
|
156
132
|
};
|
|
157
133
|
|
|
@@ -168,8 +144,11 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
168
144
|
},
|
|
169
145
|
});
|
|
170
146
|
|
|
171
|
-
pi.on("
|
|
172
|
-
|
|
173
|
-
|
|
147
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
148
|
+
sessionId = ctx.sessionManager.getSessionId();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_shutdown", async () => {
|
|
152
|
+
await stop();
|
|
174
153
|
});
|
|
175
154
|
}
|
|
@@ -174,7 +174,7 @@ export interface LiveChannel {
|
|
|
174
174
|
* attached, so an idle server does no status work at all; the last page leaving
|
|
175
175
|
* parks it and the next page starts it again. What happens to a server nobody is
|
|
176
176
|
* reading — the idle grace and the close it leads to — is the server's policy, so
|
|
177
|
-
* the caller hands it to
|
|
177
|
+
* the caller hands it to its own mount instead of configuring it
|
|
178
178
|
* here.
|
|
179
179
|
*/
|
|
180
180
|
export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
|
|
@@ -46,13 +46,16 @@ import {
|
|
|
46
46
|
renderErrorPage,
|
|
47
47
|
renderFileDiffPage,
|
|
48
48
|
renderFilePage,
|
|
49
|
+
hrefFor,
|
|
49
50
|
} from "../view/html.ts";
|
|
50
51
|
import type { ChangeSummary } from "../view/html.ts";
|
|
51
|
-
import {
|
|
52
|
+
import { HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "../files/paths.ts";
|
|
52
53
|
import { listingRevision } from "../live/rev.ts";
|
|
53
54
|
|
|
54
55
|
export interface ServeContext {
|
|
55
56
|
root: string;
|
|
57
|
+
/** The prefix this page answers under, e.g. `/files`; `""` for the origin's root. */
|
|
58
|
+
base: string;
|
|
56
59
|
config: ServeConfig;
|
|
57
60
|
/** Read-only git context for the page header; omitted when unavailable. */
|
|
58
61
|
git?: GitStatusProvider;
|
|
@@ -152,6 +155,7 @@ async function errorPage(
|
|
|
152
155
|
// Keep the minimal tree.
|
|
153
156
|
}
|
|
154
157
|
const body = renderErrorPage({
|
|
158
|
+
base: ctx.base,
|
|
155
159
|
rootLabel: ctx.root,
|
|
156
160
|
status,
|
|
157
161
|
message,
|
|
@@ -187,6 +191,7 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
|
|
|
187
191
|
return htmlResponse(
|
|
188
192
|
200,
|
|
189
193
|
renderDirectoryPage({
|
|
194
|
+
base: ctx.base,
|
|
190
195
|
rootLabel: ctx.root,
|
|
191
196
|
rel,
|
|
192
197
|
listing,
|
|
@@ -211,6 +216,7 @@ async function fileResponse(ctx: ServeContext, rel: string, raw: boolean): Promi
|
|
|
211
216
|
return htmlResponse(
|
|
212
217
|
200,
|
|
213
218
|
renderFilePage({
|
|
219
|
+
base: ctx.base,
|
|
214
220
|
rootLabel: ctx.root,
|
|
215
221
|
rel,
|
|
216
222
|
file,
|
|
@@ -235,6 +241,7 @@ async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise<ServeRes
|
|
|
235
241
|
return htmlResponse(
|
|
236
242
|
200,
|
|
237
243
|
renderDiffPage({
|
|
244
|
+
base: ctx.base,
|
|
238
245
|
rootLabel: ctx.root,
|
|
239
246
|
mode,
|
|
240
247
|
files: list?.files ?? [],
|
|
@@ -267,6 +274,7 @@ async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Prom
|
|
|
267
274
|
return htmlResponse(
|
|
268
275
|
200,
|
|
269
276
|
renderFileDiffPage({
|
|
277
|
+
base: ctx.base,
|
|
270
278
|
rootLabel: ctx.root,
|
|
271
279
|
rel,
|
|
272
280
|
mode,
|
|
@@ -303,12 +311,12 @@ async function raw(ctx: ServeContext, rawRel: string): Promise<ServeResponse> {
|
|
|
303
311
|
};
|
|
304
312
|
}
|
|
305
313
|
|
|
306
|
-
function treeApiEntry(entry: DirEntry): Record<string, unknown> {
|
|
314
|
+
function treeApiEntry(entry: DirEntry, base: string): Record<string, unknown> {
|
|
307
315
|
const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
|
|
308
316
|
return {
|
|
309
317
|
name: entry.name,
|
|
310
318
|
path: entry.rel,
|
|
311
|
-
href:
|
|
319
|
+
href: hrefFor(entry, base),
|
|
312
320
|
isDir: entry.isDir,
|
|
313
321
|
broken: entry.broken,
|
|
314
322
|
iconHref: iconHref(spec.icon),
|
|
@@ -323,7 +331,7 @@ async function treeApi(ctx: ServeContext, decodedPath: string): Promise<ServeRes
|
|
|
323
331
|
const info = await stat(abs).catch(() => undefined);
|
|
324
332
|
if (info && !info.isDirectory()) throw new HttpError(400, "That path is not a folder.");
|
|
325
333
|
const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
|
|
326
|
-
return jsonResponse(200, { path: rel, entries: listing.entries.map(treeApiEntry) });
|
|
334
|
+
return jsonResponse(200, { path: rel, entries: listing.entries.map((entry) => treeApiEntry(entry, ctx.base)) });
|
|
327
335
|
}
|
|
328
336
|
|
|
329
337
|
/** The revision of one folder's listing: what a page's reload control compares against. */
|
|
@@ -1,44 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The browse page's mount: the prefix it holds on the session's one server, plus the
|
|
3
|
+
* live status channel it feeds.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `lib/http` owns the socket and `serve` owns the session's port, so this module owns
|
|
6
|
+
* only this page's own policy: the request handler, its request count, the cap that
|
|
7
|
+
* refuses a page rather than opening a stream for it, and how long a page with nobody
|
|
8
|
+
* reading it is worth keeping.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The page takes the namespace `/files`, beside `/board` and `/plan/<id>`: every URL it
|
|
11
|
+
* emits is built from that `base` — the links, the scripts, the destinations it rewrites
|
|
12
|
+
* out of Markdown, and the client's own fetches — so one prefix is all it needs, and the
|
|
13
|
+
* origin's root stays free. `BROWSE_BASE` is that claim, named where the mount and its
|
|
14
|
+
* suite can see it.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import { realpathSync } from "node:fs";
|
|
16
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
type HttpMount,
|
|
20
|
+
type MountOutcome,
|
|
21
|
+
type MountSource,
|
|
22
|
+
type ServeResponse,
|
|
23
|
+
textResponse,
|
|
24
|
+
} from "../../../lib/http/index.ts";
|
|
17
25
|
import { IDLE_STOP_MS, type ServeConfig } from "../config/config.ts";
|
|
18
26
|
import { createFileIndex } from "../files/files.ts";
|
|
19
27
|
import type { GitDiffProvider, GitStatusProvider } from "../git/git.ts";
|
|
20
28
|
import { createLiveChannel, createLiveStatus } from "../live/live.ts";
|
|
21
29
|
import { handleRequest, type ServeContext } from "./router.ts";
|
|
22
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The prefix this page answers under: its own namespace on the session's origin.
|
|
33
|
+
*
|
|
34
|
+
* Route names inside the page (`/`, `/browse`, `/view`, `/raw`, `/diff`, `/api/*`,
|
|
35
|
+
* `/events`) are unchanged and relative to this prefix, so the same page is served under
|
|
36
|
+
* `/files` and nowhere else, and the origin's root answers nothing at all.
|
|
37
|
+
*/
|
|
38
|
+
export const BROWSE_BASE = "/files";
|
|
39
|
+
|
|
40
|
+
/** A mounted page. */
|
|
23
41
|
export interface FileServer {
|
|
24
|
-
/**
|
|
42
|
+
/** The page's URL, e.g. `http://127.0.0.1:4780/files/`. */
|
|
25
43
|
url: string;
|
|
26
|
-
/** The bound
|
|
44
|
+
/** The port the session's server bound. */
|
|
27
45
|
port: number;
|
|
28
46
|
/** Resolved absolute root that is actually served. */
|
|
29
47
|
root: string;
|
|
30
|
-
/**
|
|
48
|
+
/** Give the prefix back. Safe to call more than once. */
|
|
31
49
|
close(): Promise<void>;
|
|
32
50
|
}
|
|
33
51
|
|
|
52
|
+
/** The page, or the reason the session's server has no place for it. */
|
|
53
|
+
export type FileServerResult = { kind: "served"; server: FileServer } | { kind: "refused"; reason: string };
|
|
54
|
+
|
|
34
55
|
export interface FileServerOptions {
|
|
35
56
|
root: string;
|
|
36
57
|
config: ServeConfig;
|
|
58
|
+
/** Where the prefix comes from: the session's server, or a suite's own. */
|
|
59
|
+
mount: MountSource;
|
|
60
|
+
/** The Pi session asking, so the prefix lands on this session's socket. */
|
|
61
|
+
session: string;
|
|
37
62
|
/** Read-only git context for the page header; omitted when unavailable. */
|
|
38
63
|
git?: GitStatusProvider;
|
|
39
64
|
/** Read-only diff provider for the change and diff views; omitted when unavailable. */
|
|
40
65
|
diff?: GitDiffProvider;
|
|
41
|
-
/** Called once the last page has been gone for `idleStopMs`; omitted, the
|
|
66
|
+
/** Called once the last page has been gone for `idleStopMs`; omitted, the mount waits for `close()`. */
|
|
42
67
|
onIdle?: () => void;
|
|
43
68
|
/** Grace before `onIdle`, in ms; defaults to `IDLE_STOP_MS`. */
|
|
44
69
|
idleStopMs?: number;
|
|
@@ -46,8 +71,8 @@ export interface FileServerOptions {
|
|
|
46
71
|
handler?: typeof handleRequest;
|
|
47
72
|
}
|
|
48
73
|
|
|
49
|
-
/**
|
|
50
|
-
export async function createFileServer(options: FileServerOptions): Promise<
|
|
74
|
+
/** Mount a read-only page rooted at `options.root` on the session's server. */
|
|
75
|
+
export async function createFileServer(options: FileServerOptions): Promise<FileServerResult> {
|
|
51
76
|
const root = realpathSync.native(options.root);
|
|
52
77
|
const { config } = options;
|
|
53
78
|
// The status feed's two server-owned facts: how long it has been up, and how
|
|
@@ -64,6 +89,7 @@ export async function createFileServer(options: FileServerOptions): Promise<File
|
|
|
64
89
|
const live = status ? createLiveChannel({ status, intervalMs: config.liveIntervalMs }) : undefined;
|
|
65
90
|
const context: ServeContext = {
|
|
66
91
|
root,
|
|
92
|
+
base: BROWSE_BASE,
|
|
67
93
|
config,
|
|
68
94
|
git: options.git,
|
|
69
95
|
diff: options.diff,
|
|
@@ -72,10 +98,13 @@ export async function createFileServer(options: FileServerOptions): Promise<File
|
|
|
72
98
|
};
|
|
73
99
|
const handler = options.handler ?? handleRequest;
|
|
74
100
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
101
|
+
const outcome: MountOutcome = await options.mount({
|
|
102
|
+
session: options.session,
|
|
103
|
+
prefix: BROWSE_BASE,
|
|
104
|
+
...(options.onIdle === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { idle: { ms: options.idleStopMs ?? IDLE_STOP_MS, onIdle: options.onIdle } }),
|
|
107
|
+
async handler(request) {
|
|
79
108
|
requests += 1;
|
|
80
109
|
let result: ServeResponse;
|
|
81
110
|
try {
|
|
@@ -96,15 +125,19 @@ export async function createFileServer(options: FileServerOptions): Promise<File
|
|
|
96
125
|
};
|
|
97
126
|
},
|
|
98
127
|
});
|
|
99
|
-
|
|
128
|
+
if (outcome.kind === "refused") return { kind: "refused", reason: outcome.reason };
|
|
129
|
+
const mount: HttpMount = outcome.mount;
|
|
100
130
|
return {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
kind: "served",
|
|
132
|
+
server: {
|
|
133
|
+
url: `${mount.origin}${mount.base}/`,
|
|
134
|
+
port: mount.port,
|
|
135
|
+
root,
|
|
136
|
+
close: async () => {
|
|
137
|
+
// End the streams first, so a connected page does not hold the mount open.
|
|
138
|
+
live?.close();
|
|
139
|
+
await mount.release();
|
|
140
|
+
},
|
|
108
141
|
},
|
|
109
142
|
};
|
|
110
143
|
}
|
|
@@ -46,7 +46,7 @@ export function renderLiveChrome(live: LivePage | undefined): string {
|
|
|
46
46
|
if (!live) return "";
|
|
47
47
|
const rev =
|
|
48
48
|
live.rev === undefined ? "" : ` data-dir="${escapeHtml(live.dir ?? "")}" data-rev="${escapeHtml(live.rev)}"`;
|
|
49
|
-
const chip = `<span class="bf-chip bf-chip--accent browse-live" id="live-state" title="Live status from the browse server" data-interval="${live.intervalMs}"${rev} hidden><span class="browse-live__glyph" aria-hidden="true"
|
|
49
|
+
const chip = `<span class="bf-chip bf-chip--accent browse-live" id="live-state" title="Live status from the browse server" data-interval="${live.intervalMs}"${rev} hidden><span class="browse-live__glyph" aria-hidden="true">◉</span><span class="browse-live__text">connecting</span></span>`;
|
|
50
50
|
if (live.rev === undefined) return chip;
|
|
51
51
|
return `${chip}<button type="button" class="bf-chip bf-chip--warn browse-reload" id="reload-hint" title="This folder changed on disk" hidden>⟳ reload</button>`;
|
|
52
52
|
}
|
|
@@ -64,11 +64,11 @@ export function renderLiveStatus(): string {
|
|
|
64
64
|
* a hidden shell is always rendered so the chip can appear and disappear as the
|
|
65
65
|
* working tree changes, without the client inserting elements into the header.
|
|
66
66
|
*/
|
|
67
|
-
export function renderChanges(summary: ChangeIndex | undefined, live: LivePage | undefined): string {
|
|
67
|
+
export function renderChanges(summary: ChangeIndex | undefined, live: LivePage | undefined, base: string): string {
|
|
68
68
|
const count = summary?.count ?? 0;
|
|
69
69
|
if (count <= 0) {
|
|
70
70
|
return live
|
|
71
|
-
? `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" hidden></a>`
|
|
71
|
+
? `<a class="bf-chip browse-diff" id="browse-changes" href="${base}/diff" title="View uncommitted changes" hidden></a>`
|
|
72
72
|
: "";
|
|
73
73
|
}
|
|
74
74
|
const label = `${count} change${count === 1 ? "" : "s"}`;
|
|
@@ -79,5 +79,5 @@ export function renderChanges(summary: ChangeIndex | undefined, live: LivePage |
|
|
|
79
79
|
? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${insertions}</span> <span class="browse-diff-del">−${deletions}</span></span>`
|
|
80
80
|
: "";
|
|
81
81
|
const aria = `${label} against HEAD${insertions + deletions > 0 ? `, ${insertions} insertions and ${deletions} deletions` : ""}`;
|
|
82
|
-
return `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
|
|
82
|
+
return `<a class="bf-chip browse-diff" id="browse-changes" href="${base}/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
|
|
83
83
|
}
|
|
@@ -12,13 +12,15 @@
|
|
|
12
12
|
* and `bun build --no-bundle` needs no separate asset.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { keysDialogScript, THEME_TOGGLE_JS } from "../../../lib/web/index.ts";
|
|
15
|
+
import { CLIENT_BASE_JS, keysDialogScript, THEME_TOGGLE_JS } from "../../../lib/web/index.ts";
|
|
16
16
|
import { LIVE_CLIENT_JS } from "./live-client.ts";
|
|
17
17
|
import { PALETTE_CLIENT_JS } from "./palette.ts";
|
|
18
18
|
import { TREE_CLIENT_JS } from "./tree.ts";
|
|
19
19
|
|
|
20
20
|
export const CLIENT_JS = `(function () {
|
|
21
21
|
"use strict";
|
|
22
|
+
/* Spliced at the outermost scope: every fragment below builds its URLs from it. */
|
|
23
|
+
${CLIENT_BASE_JS}
|
|
22
24
|
var rows = Array.prototype.slice.call(document.querySelectorAll("table.bf-table tbody tr"));
|
|
23
25
|
${TREE_CLIENT_JS}
|
|
24
26
|
Array.prototype.forEach.call(document.querySelectorAll("img.bf-table__thumb"), function (img) {
|