@gavin-hu/my-pi-agent 0.19.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.
Files changed (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -1,44 +1,69 @@
1
1
  /**
2
- * The HTTP server for the browse extension.
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
- * Wraps the pure `handleRequest` in `lib/http`'s loopback server: it resolves the
5
- * root once, feeds the live status channel, and exposes an idempotent `close()`.
6
- * The request handler is injectable so tests can avoid a socket when they only
7
- * need routing.
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
- * What stays here is this extension's own policy: the request handler, its
10
- * request count, the catch that turns a thrown handler into a plain-text 500, the
11
- * cap that refuses a page rather than opening a stream for it, and how long a
12
- * server with nobody reading it is worth keeping.
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 { type ServeResponse, serveLoopback, textResponse } from "../../../lib/http/index.ts";
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
- /** Origin URL, e.g. `http://127.0.0.1:49152/`. */
42
+ /** The page's URL, e.g. `http://127.0.0.1:4780/files/`. */
25
43
  url: string;
26
- /** The bound port. */
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
- /** Stop listening. Safe to call more than once or before `listen` settles. */
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 server waits for `close()`. */
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
- /** Start a read-only server rooted at `options.root` on 127.0.0.1. */
50
- export async function createFileServer(options: FileServerOptions): Promise<FileServer> {
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 server = await serveLoopback({
76
- ports: [config.port],
77
- idle: options.onIdle === undefined ? undefined : { ms: options.idleStopMs ?? IDLE_STOP_MS, onIdle: options.onIdle },
78
- handler: async (request) => {
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
- url: `${server.origin}/`,
102
- port: server.port,
103
- root,
104
- close: async () => {
105
- // End the streams first, so a connected page does not hold the close open.
106
- live?.close();
107
- await server.close();
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">${escapeHtml(GLYPHS.browse)}</span><span class="browse-live__text">connecting</span></span>`;
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) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The elements a page emits for a resolved Markdown link or image.
3
+ *
4
+ * Two renderers reach a link: the Markdown renderer (`markdown.ts`) and the raw-HTML
5
+ * sanitizer (`raw-html.ts`). Both ask `urls.ts` what a destination resolves to, and both
6
+ * then build the same anchor and the same image — the external-rel attributes, the
7
+ * `md-img` class, and the lazy decoding — so those live here rather than in a fourth and
8
+ * fifth copy. Values are escaped here; the callers hand in what the document said.
9
+ */
10
+
11
+ import { escapeHtml } from "../../../lib/escape.ts";
12
+
13
+ /**
14
+ * The attributes an external link carries, for a caller that is building the element
15
+ * itself: the raw-HTML sanitizer rewrites an existing tag's attribute list and has no
16
+ * content string to hand an emitter.
17
+ *
18
+ * `noopener` because the target is another origin, and `noreferrer` because the page is a
19
+ * local view of the reader's own repository and should not tell anyone where they read it.
20
+ */
21
+ export const EXTERNAL_ATTRS = ' target="_blank" rel="noopener noreferrer"';
22
+
23
+ /** An `<a>` for one destination; `title` and the rel attributes are omitted when absent. */
24
+ export function emitAnchor(input: { href: string; content: string; title?: string; external: boolean }): string {
25
+ const title = input.title === undefined ? "" : ` title="${escapeHtml(input.title)}"`;
26
+ return `<a href="${escapeHtml(input.href)}"${title}${input.external ? EXTERNAL_ATTRS : ""}>${input.content}</a>`;
27
+ }
28
+
29
+ /**
30
+ * A link standing in for an image the page may not show: an external `src` is blocked by
31
+ * the CSP, so the reader gets something to click, labelled by the alt text.
32
+ */
33
+ export function emitImageLink(href: string, label: string): string {
34
+ return `<a href="${escapeHtml(href)}"${EXTERNAL_ATTRS}>${escapeHtml(label)}</a>`;
35
+ }
36
+
37
+ /** An `<img>`: block-level spacing class, alt text, and the sizes a document may pin. */
38
+ export function emitImage(input: {
39
+ href: string;
40
+ alt: string;
41
+ title?: string;
42
+ /** A document may pin a pixel size; escaped like every other value the page emits. */
43
+ width?: string | number;
44
+ height?: string | number;
45
+ }): string {
46
+ const size = `${input.width === undefined ? "" : ` width="${escapeHtml(String(input.width))}"`}${
47
+ input.height === undefined ? "" : ` height="${escapeHtml(String(input.height))}"`
48
+ }`;
49
+ const title = input.title === undefined ? "" : ` title="${escapeHtml(input.title)}"`;
50
+ return `<img class="md-img" src="${escapeHtml(input.href)}" alt="${escapeHtml(input.alt)}"${size}${title} loading="lazy" decoding="async">`;
51
+ }
@@ -8,7 +8,15 @@
8
8
  * the page-only `browse-*` rules from `styles.ts`.
9
9
  */
10
10
 
11
- import { brandLockup, docHead, type KeyHint, keysSheet, themeToggle } from "../../../lib/web/index.ts";
11
+ import {
12
+ baseAttribute,
13
+ brandLockup,
14
+ docHead,
15
+ type KeyHint,
16
+ keysSheet,
17
+ tabTitle,
18
+ themeToggle,
19
+ } from "../../../lib/web/index.ts";
12
20
  import { escapeHtml } from "../../../lib/escape.ts";
13
21
  import { renderChanges, renderGit, renderLiveChrome, renderLiveStatus, type LivePage } from "./chrome.ts";
14
22
  import { type DirEntry, type FileView, type Listing, type TreeNode } from "../files/files.ts";
@@ -58,11 +66,19 @@ function changeMark(summary: ChangeSummary | undefined, rel: string, isDir: bool
58
66
  : "";
59
67
  }
60
68
 
61
- function hrefFor(entry: { rel: string; isDir: boolean }): string {
62
- return entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`;
69
+ /**
70
+ * The URL one tree or table entry links to, written under the page's `base`.
71
+ *
72
+ * Exported because the JSON `/api/tree` route answers with the same hrefs, so the rendered
73
+ * tree and the client's refresh cannot disagree about where an entry lives.
74
+ */
75
+ export function hrefFor(entry: { rel: string; isDir: boolean }, base: string): string {
76
+ return entry.isDir ? `${base}/browse/${encodePath(entry.rel)}` : `${base}/view/${encodePath(entry.rel)}`;
63
77
  }
64
78
 
65
79
  interface Layout {
80
+ /** The mount prefix this page answers under, e.g. `/files`; `""` for the origin's root. */
81
+ base: string;
66
82
  title: string;
67
83
  rootLabel: string;
68
84
  tree: TreeNode;
@@ -84,10 +100,9 @@ interface Layout {
84
100
  /** A full two-pane page. */
85
101
  function renderLayout(layout: Layout): string {
86
102
  return `<!doctype html>
87
- <html lang="en">
103
+ <html lang="en"${baseAttribute(layout.base)}>
88
104
  <head>
89
- ${docHead({ title: layout.title, css: BROWSE_CSS })}
90
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%2358a6ff'/%3E%3C/svg%3E">
105
+ ${docHead({ title: layout.title, css: BROWSE_CSS, base: layout.base })}
91
106
  </head>
92
107
  <body class="bf-app">
93
108
  <a class="skip" href="#main">Skip to content</a>
@@ -98,7 +113,7 @@ ${SPRITE}
98
113
  ${brandLockup("browse")}
99
114
  <span class="bf-context browse-root">${escapeHtml(layout.rootLabel)}</span>
100
115
  ${renderGit(layout.git)}
101
- ${renderChanges(layout.summary, layout.live)}
116
+ ${renderChanges(layout.summary, layout.live, layout.base)}
102
117
  <span class="bf-chip bf-chip--accent">read-only</span>
103
118
  ${renderLiveChrome(layout.live)}
104
119
  <button type="button" class="bf-btn bf-btn--icon browse-keys-btn" id="keys-open" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
@@ -113,7 +128,7 @@ ${SPRITE}
113
128
  <label class="bf-field">⌕ <input class="bf-input" id="filter" type="search" placeholder="filter paths…" aria-label="Filter paths"></label>
114
129
  <button type="button" class="bf-btn" id="fold-all" title="Fold or expand every folder">fold all</button>
115
130
  </div>
116
- <nav class="bf-tree" aria-label="File tree">${renderTree(layout.tree, layout.currentRel, layout.summary)}</nav>
131
+ <nav class="bf-tree" aria-label="File tree">${renderTree(layout.tree, layout.currentRel, layout.base, layout.summary)}</nav>
117
132
  <p class="bf-note" id="tree-empty" hidden>No matches.</p>
118
133
  </aside>
119
134
  </details>
@@ -124,36 +139,36 @@ ${layout.main}
124
139
  </div>
125
140
  <footer class="bf-app__footer">${layout.live ? renderLiveStatus() : ""}served from ${escapeHtml(layout.rootLabel)} · read-only · ${escapeHtml(layout.footerNote)}</footer>
126
141
  ${layout.keys && layout.keys.length > 0 ? keysSheet(layout.keys) : ""}
127
- <script src="/app.js" defer></script>
142
+ <script src="${layout.base}/app.js" defer></script>
128
143
  </body>
129
144
  </html>
130
145
  `;
131
146
  }
132
147
 
133
148
  /** The breadcrumb trail for a root-relative path, up to (not including) the current page. */
134
- export function renderBreadcrumbs(rel: string): string {
149
+ export function renderBreadcrumbs(rel: string, base: string): string {
135
150
  const segments = rel.split("/").filter(Boolean);
136
151
  if (segments.length === 0) return `<span aria-current="page">home</span>`;
137
152
  // The last segment is the current page, which the heading or card title names;
138
153
  // the trail stops at its parent so the name is not shown twice.
139
- const parts = [`<a href="/browse/">home</a>`];
154
+ const parts = [`<a href="${base}/browse/">home</a>`];
140
155
  let accumulated = "";
141
156
  for (const segment of segments.slice(0, -1)) {
142
157
  accumulated = accumulated ? `${accumulated}/${segment}` : segment;
143
- parts.push(`<a href="/browse/${encodePath(accumulated)}">${escapeHtml(segment)}</a>`);
158
+ parts.push(`<a href="${base}/browse/${encodePath(accumulated)}">${escapeHtml(segment)}</a>`);
144
159
  }
145
160
  return parts.join(`<span class="bf-crumbs__sep">/</span>`);
146
161
  }
147
162
 
148
- function renderTree(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
149
- return `<ul>${renderTreeNode(node, currentRel, summary)}</ul>`;
163
+ function renderTree(node: TreeNode, currentRel: string, base: string, summary?: ChangeSummary): string {
164
+ return `<ul>${renderTreeNode(node, currentRel, base, summary)}</ul>`;
150
165
  }
151
166
 
152
- function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
167
+ function renderTreeNode(node: TreeNode, currentRel: string, base: string, summary?: ChangeSummary): string {
153
168
  const expanded = node.children.length > 0;
154
169
  const isDir = node.isDir;
155
170
  const spec = isDir ? FOLDER_ICON : languageOf(node.name);
156
- const href = hrefFor(node);
171
+ const href = hrefFor(node, base);
157
172
  const current = node.rel === currentRel ? ` aria-current="page"` : "";
158
173
  const twisty = isDir
159
174
  ? `<button type="button" class="bf-tree__tw bf-tree__tw--toggle" tabindex="-1" aria-hidden="true">${expanded ? "▾" : "▸"}</button>`
@@ -161,7 +176,7 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
161
176
  const dataPath = ` data-path="${escapeHtml(node.rel)}"`;
162
177
  const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name);
163
178
  const children = expanded
164
- ? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel, summary)).join("")}</ul>`
179
+ ? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel, base, summary)).join("")}</ul>`
165
180
  : "";
166
181
  const label = node.broken
167
182
  ? `<span class="browse-broken" title="broken symbolic link">${name}</span><span class="browse-link-mark">↩</span>`
@@ -171,11 +186,17 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
171
186
  return `<li data-open="${expanded}"><div class="bf-tree__row"${current}${dataPath}>${twisty}${iconMarkup(spec)}${label}${changeMark(summary, node.rel, isDir)}</div>${children}</li>`;
172
187
  }
173
188
 
174
- function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean, summary?: ChangeSummary): string {
189
+ function renderRow(
190
+ entry: DirEntry,
191
+ maxThumbBytes: number,
192
+ thumbnails: boolean,
193
+ base: string,
194
+ summary?: ChangeSummary,
195
+ ): string {
175
196
  const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
176
197
  const showThumb = thumbnails && entry.isImage && !entry.broken && entry.size <= maxThumbBytes;
177
198
  const visual = showThumb
178
- ? `<img class="bf-table__thumb" alt="" loading="lazy" decoding="async" src="/raw/${encodePath(entry.rel)}">${iconMarkup(spec, true)}`
199
+ ? `<img class="bf-table__thumb" alt="" loading="lazy" decoding="async" src="${base}/raw/${encodePath(entry.rel)}">${iconMarkup(spec, true)}`
179
200
  : iconMarkup(spec);
180
201
  const name = entry.isDir ? `${escapeHtml(entry.name)}/` : escapeHtml(entry.name);
181
202
  const classes = [entry.name.startsWith(".") ? "browse-dotfile" : "", entry.isSymlink ? "browse-symlink" : ""]
@@ -183,7 +204,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean,
183
204
  .join(" ");
184
205
  const link = entry.broken
185
206
  ? `<span class="browse-broken" title="broken symbolic link">${name}</span>`
186
- : `<a href="${hrefFor(entry)}" title="${escapeHtml(entry.rel)}">${name}</a>`;
207
+ : `<a href="${hrefFor(entry, base)}" title="${escapeHtml(entry.rel)}">${name}</a>`;
187
208
  const mark = entry.isSymlink
188
209
  ? `<span class="browse-link-mark" title="${entry.broken ? "broken symbolic link" : "symbolic link"}">↩</span>`
189
210
  : "";
@@ -192,6 +213,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean,
192
213
  }
193
214
 
194
215
  export interface DirectoryPage {
216
+ base: string;
195
217
  rootLabel: string;
196
218
  rel: string;
197
219
  listing: Listing;
@@ -213,7 +235,7 @@ export function renderDirectoryPage(page: DirectoryPage): string {
213
235
  let body = `<table class="bf-table browse-listing" aria-labelledby="page-title">
214
236
  <thead><tr><th scope="col" aria-sort="ascending"><button type="button" class="bf-sort" data-sort="name" title="Sort by name">Name</button></th><th scope="col" class="bf-table__size"><button type="button" class="bf-sort" data-sort="size" title="Sort by size">Size</button></th><th scope="col" class="bf-table__when"><button type="button" class="bf-sort" data-sort="mtime" title="Sort by modified time">Modified</button></th></tr></thead>
215
237
  <tbody>
216
- ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.thumbnails, page.summary)).join("\n")}
238
+ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.thumbnails, page.base, page.summary)).join("\n")}
217
239
  </tbody>
218
240
  </table>`;
219
241
  if (page.listing.entries.length === 0) body = `<div class="bf-empty">This folder is empty.</div>`;
@@ -221,11 +243,12 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
221
243
  body += `<p class="bf-note">Showing the first ${page.listing.entries.length} of ${page.listing.total} entries.</p>`;
222
244
 
223
245
  return renderLayout({
224
- title: `browse · ${page.rel || folderName}`,
246
+ base: page.base,
247
+ title: tabTitle("browse", page.rel || folderName),
225
248
  rootLabel: page.rootLabel,
226
249
  tree: page.tree,
227
250
  currentRel: page.rel,
228
- breadcrumbs: renderBreadcrumbs(page.rel),
251
+ breadcrumbs: renderBreadcrumbs(page.rel, page.base),
229
252
  keys: pageKeys(),
230
253
  main: ` ${heading}\n ${body}`,
231
254
  footerNote: `${page.listing.total} entr${page.listing.total === 1 ? "y" : "ies"}`,
@@ -236,6 +259,7 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
236
259
  }
237
260
 
238
261
  export interface FilePage {
262
+ base: string;
239
263
  rootLabel: string;
240
264
  rel: string;
241
265
  file: FileView;
@@ -291,12 +315,12 @@ function numberedLines(page: FilePage): boolean {
291
315
  * plus the download the diff head keeps in the same group. */
292
316
  function renderFileActions(page: FilePage, download: string): string {
293
317
  const isMarkdown = page.file.kind === "markdown";
294
- const rawHref = `/raw/${encodePath(page.rel)}`;
295
- const viewHref = `/view/${encodePath(page.rel)}`;
318
+ const rawHref = `${page.base}/raw/${encodePath(page.rel)}`;
319
+ const viewHref = `${page.base}/view/${encodePath(page.rel)}`;
296
320
  const groups: string[] = [];
297
321
  if (page.changed) {
298
322
  groups.push(
299
- `<a class="bf-btn" href="/diff/${encodePath(page.rel)}" title="View this file's uncommitted changes">changes</a>`,
323
+ `<a class="bf-btn" href="${page.base}/diff/${encodePath(page.rel)}" title="View this file's uncommitted changes">changes</a>`,
300
324
  );
301
325
  }
302
326
  if (isMarkdown) {
@@ -337,7 +361,7 @@ export function renderFilePage(page: FilePage): string {
337
361
  // Text is always served as octet-stream, so the response type says nothing
338
362
  // about the file; the language label, size, and date are the useful metadata.
339
363
  const meta = `${spec.label} · ${formatSize(page.file.size)} · ${formatDate(page.file.mtimeMs)}`;
340
- const download = `<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`;
364
+ const download = `<a class="bf-btn" href="${page.base}/raw/${encodePath(page.rel)}" download>download</a>`;
341
365
  const head = `<div class="bf-card__head">${iconMarkup(spec)}<h1 class="bf-card__title">${escapeHtml(name)}</h1><span class="bf-card__meta">${escapeHtml(meta)}</span><span class="bf-spacer"></span>${renderFileActions(page, download)}</div>`;
342
366
 
343
367
  const isMarkdown = page.file.kind === "markdown";
@@ -355,6 +379,7 @@ export function renderFilePage(page: FilePage): string {
355
379
  if (page.file.kind === "markdown" && !page.markdownRaw) {
356
380
  const html = renderMarkdown(page.file.lines.join("\n"), markdownBaseDir(page.rel), {
357
381
  html: page.markdownHtml !== false,
382
+ base: page.base,
358
383
  });
359
384
  content = `<article class="bf-md">${html}</article>`;
360
385
  } else {
@@ -363,7 +388,7 @@ export function renderFilePage(page: FilePage): string {
363
388
  if (page.file.truncated)
364
389
  note = `<p class="bf-note">Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.</p>`;
365
390
  } else if (page.file.kind === "image") {
366
- content = `<div class="bf-media"><img src="/raw/${encodePath(page.rel)}" alt="${escapeHtml(name)}"></div>`;
391
+ content = `<div class="bf-media"><img src="${page.base}/raw/${encodePath(page.rel)}" alt="${escapeHtml(name)}"></div>`;
367
392
  } else if (page.file.kind === "tooLarge") {
368
393
  content = `<div class="bf-empty"><p class="bf-empty__title">File is ${escapeHtml(formatSize(page.file.size))}.</p><p class="bf-empty__sub">Larger than the ${escapeHtml(formatSize(page.maxFileBytes))} inline limit.</p>${download}</div>`;
369
394
  } else {
@@ -371,11 +396,12 @@ export function renderFilePage(page: FilePage): string {
371
396
  }
372
397
 
373
398
  return renderLayout({
374
- title: `browse · ${page.rel}`,
399
+ base: page.base,
400
+ title: tabTitle("browse", page.rel),
375
401
  rootLabel: page.rootLabel,
376
402
  tree: page.tree,
377
403
  currentRel: page.rel,
378
- breadcrumbs: renderBreadcrumbs(page.rel),
404
+ breadcrumbs: renderBreadcrumbs(page.rel, page.base),
379
405
  mainClass: rendered ? "browse-page browse-measure" : "browse-page",
380
406
  keys,
381
407
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
@@ -419,6 +445,7 @@ function renderModeControl(mode: DiffMode, build: (mode: DiffMode) => string): s
419
445
  }
420
446
 
421
447
  export interface DiffPage {
448
+ base: string;
422
449
  rootLabel: string;
423
450
  mode: DiffMode;
424
451
  files: ChangedFile[];
@@ -432,23 +459,24 @@ export interface DiffPage {
432
459
 
433
460
  /** The change overview: `git diff --stat` plus every changed path. */
434
461
  export function renderDiffPage(page: DiffPage): string {
435
- const modeHref = (mode: DiffMode) => (mode === "head" ? "/diff" : `/diff?mode=${mode}`);
462
+ const modeHref = (mode: DiffMode) => (mode === "head" ? `${page.base}/diff` : `${page.base}/diff?mode=${mode}`);
436
463
  const body =
437
464
  page.files.length === 0
438
465
  ? `<div class="bf-empty"><p class="bf-empty__title">No changes ${escapeHtml(modeScope(page.mode))}.</p><p class="bf-empty__sub">The working tree matches this scope.</p></div>`
439
466
  : `<table class="bf-table browse-diff" aria-labelledby="page-title">
440
467
  <thead><tr><th scope="col" class="browse-diff-status">Status</th><th scope="col">Path</th><th scope="col" class="browse-diff-word">Changes</th></tr></thead>
441
468
  <tbody>
442
- ${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
469
+ ${page.files.map((file) => renderChangedRow(file, page.mode, page.base)).join("\n")}
443
470
  </tbody>
444
471
  </table>`;
445
472
  const stat = page.stat ? `<pre class="browse-diff-stat">${escapeHtml(page.stat)}</pre>` : "";
446
473
  return renderLayout({
447
- title: "browse · changes",
474
+ base: page.base,
475
+ title: tabTitle("browse", "changes"),
448
476
  rootLabel: page.rootLabel,
449
477
  tree: page.tree,
450
478
  currentRel: "",
451
- breadcrumbs: renderBreadcrumbs(""),
479
+ breadcrumbs: renderBreadcrumbs("", page.base),
452
480
  mainClass: "browse-page",
453
481
  keys: pageKeys(),
454
482
  main: ` <h1 class="bf-title browse-heading" id="page-title">Changes <span class="browse-diff-scope">${escapeHtml(modeScope(page.mode))}</span></h1>\n <div class="browse-diff-toolbar">${renderModeControl(page.mode, modeHref)}</div>\n ${stat}\n ${body}`,
@@ -459,16 +487,17 @@ ${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
459
487
  });
460
488
  }
461
489
 
462
- function renderChangedRow(file: ChangedFile, mode: DiffMode): string {
490
+ function renderChangedRow(file: ChangedFile, mode: DiffMode, base: string): string {
463
491
  const letter = statusLetter(file);
464
492
  const label = statusWord(letter);
465
493
  const badge = `browse-diff-badge browse-diff-badge--${letter === "?" ? "Q" : letter}`;
466
494
  const orig = file.origRel ? ` <span class="browse-diff-orig">from ${escapeHtml(file.origRel)}</span>` : "";
467
- const href = `/diff/${encodePath(file.rel)}${mode === "head" ? "" : `?mode=${mode}`}`;
495
+ const href = `${base}/diff/${encodePath(file.rel)}${mode === "head" ? "" : `?mode=${mode}`}`;
468
496
  return `<tr data-path="${escapeHtml(file.rel)}"><td><span class="${badge}" aria-label="${escapeHtml(label)}">${escapeHtml(letter)}</span></td><td><span class="bf-table__name">${iconMarkup(languageOf(file.rel))}<a href="${href}">${escapeHtml(file.rel)}</a>${orig}</span></td><td class="browse-diff-word">${escapeHtml(label)}</td></tr>`;
469
497
  }
470
498
 
471
499
  export interface FileDiffPage {
500
+ base: string;
472
501
  rootLabel: string;
473
502
  rel: string;
474
503
  mode: DiffMode;
@@ -484,12 +513,12 @@ export interface FileDiffPage {
484
513
  export function renderFileDiffPage(page: FileDiffPage): string {
485
514
  const name = page.rel.slice(page.rel.lastIndexOf("/") + 1);
486
515
  const spec = languageOf(name);
487
- const base = `/diff/${encodePath(page.rel)}`;
516
+ const diffBase = `${page.base}/diff/${encodePath(page.rel)}`;
488
517
  const actions = [
489
- renderModeControl(page.mode, (mode) => (mode === "head" ? base : `${base}?mode=${mode}`)),
490
- `<a class="bf-btn" href="/view/${encodePath(page.rel)}" title="Open the file">view file</a>`,
518
+ renderModeControl(page.mode, (mode) => (mode === "head" ? diffBase : `${diffBase}?mode=${mode}`)),
519
+ `<a class="bf-btn" href="${page.base}/view/${encodePath(page.rel)}" title="Open the file">view file</a>`,
491
520
  `<button type="button" class="bf-btn" id="wrap-toggle" aria-pressed="false" title="Toggle line wrapping">wrap</button>`,
492
- `<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`,
521
+ `<a class="bf-btn" href="${page.base}/raw/${encodePath(page.rel)}" download>download</a>`,
493
522
  `<button type="button" class="bf-btn" data-copy="path" data-value="${escapeHtml(page.rel)}" title="Copy the root-relative path">copy path</button>`,
494
523
  ];
495
524
  // A per-file change summary and hunk count, so the scope is visible before scrolling.
@@ -508,7 +537,7 @@ export function renderFileDiffPage(page: FileDiffPage): string {
508
537
  let content: string;
509
538
  let note = "";
510
539
  if (page.diff.binary) {
511
- content = `<div class="bf-empty"><p class="bf-empty__title">Binary or unreadable change.</p><p class="bf-empty__sub">No text diff is shown for this file.</p><a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a></div>`;
540
+ content = `<div class="bf-empty"><p class="bf-empty__title">Binary or unreadable change.</p><p class="bf-empty__sub">No text diff is shown for this file.</p><a class="bf-btn" href="${page.base}/raw/${encodePath(page.rel)}" download>download</a></div>`;
512
541
  } else if (page.diff.lines.length === 0) {
513
542
  content = `<div class="bf-empty"><p class="bf-empty__title">No changes for this file.</p><p class="bf-empty__sub">It is unchanged in the selected scope.</p></div>`;
514
543
  } else {
@@ -516,11 +545,12 @@ export function renderFileDiffPage(page: FileDiffPage): string {
516
545
  if (page.diff.truncated) note = `<p class="bf-note">Showing the first ${page.diff.lines.length} diff lines.</p>`;
517
546
  }
518
547
  return renderLayout({
519
- title: `browse · ${page.rel}`,
548
+ base: page.base,
549
+ title: tabTitle("browse", page.rel),
520
550
  rootLabel: page.rootLabel,
521
551
  tree: page.tree,
522
552
  currentRel: page.rel,
523
- breadcrumbs: renderBreadcrumbs(page.rel),
553
+ breadcrumbs: renderBreadcrumbs(page.rel, page.base),
524
554
  mainClass: "browse-page",
525
555
  keys,
526
556
  main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
@@ -532,6 +562,7 @@ export function renderFileDiffPage(page: FileDiffPage): string {
532
562
  }
533
563
 
534
564
  export interface ErrorPage {
565
+ base: string;
535
566
  rootLabel: string;
536
567
  status: number;
537
568
  message: string;
@@ -544,12 +575,13 @@ export interface ErrorPage {
544
575
  /** Render a status/error page with the same chrome. */
545
576
  export function renderErrorPage(page: ErrorPage): string {
546
577
  return renderLayout({
547
- title: `browse · ${page.status}`,
578
+ base: page.base,
579
+ title: tabTitle("browse", String(page.status)),
548
580
  rootLabel: page.rootLabel,
549
581
  tree: page.tree,
550
582
  currentRel: "",
551
- breadcrumbs: renderBreadcrumbs(""),
552
- main: ` <div class="bf-empty"><div class="bf-empty__code">${page.status}</div><p class="bf-empty__title">${escapeHtml(page.message)}</p><p class="bf-empty__sub"><a href="/browse/">back to the served folder</a></p></div>`,
583
+ breadcrumbs: renderBreadcrumbs("", page.base),
584
+ main: ` <div class="bf-empty"><div class="bf-empty__code">${page.status}</div><p class="bf-empty__title">${escapeHtml(page.message)}</p><p class="bf-empty__sub"><a href="${page.base}/browse/">back to the served folder</a></p></div>`,
553
585
  footerNote: "error",
554
586
  git: page.git,
555
587
  live: page.live,
@@ -166,7 +166,7 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
166
166
 
167
167
  function checkLiveDir() {
168
168
  if (document.hidden || liveTone !== "success") return;
169
- fetch("/api/dir-rev?path=" + encodeURIComponent(liveDir))
169
+ fetch(pageBase() + "/api/dir-rev?path=" + encodeURIComponent(liveDir))
170
170
  .then(function (response) {
171
171
  if (!response.ok) throw new Error("check failed");
172
172
  return response.json();
@@ -179,7 +179,7 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
179
179
  }
180
180
 
181
181
  if (window.EventSource) {
182
- var liveStream = new EventSource("/events");
182
+ var liveStream = new EventSource(pageBase() + "/events");
183
183
  liveStream.addEventListener("open", function () {
184
184
  if (liveTone !== "success") setLiveTone("accent", "connecting");
185
185
  });