@gavin-hu/my-pi-agent 0.17.0 → 0.18.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 (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. package/extensions/wechat/qrcode.ts +0 -46
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The header chrome for the browse pages.
3
+ *
4
+ * The chips the toolbar carries — the git branch, the change totals, the live
5
+ * connection state — and the footer's live line. Everything here is pure string
6
+ * building over an escaped view model: a branch name, a count, and a status word
7
+ * are all text a repository can choose, so each one crosses `escapeHtml` before
8
+ * it reaches the page. The page shell and the page bodies stay in `html.ts`.
9
+ */
10
+
11
+ import { escapeHtml } from "../../lib/escape.ts";
12
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
13
+ import type { GitStatus } from "./git.ts";
14
+ import type { ChangeIndex } from "./live.ts";
15
+
16
+ /** The header's git chip: the branch, or the detached head; changes are the changes chip's job. */
17
+ export function renderGit(git: GitStatus | undefined): string {
18
+ if (!git) return "";
19
+ const name = git.branch ?? git.head;
20
+ if (!name) return "";
21
+ const glyph = git.branch ? GLYPHS.branch : GLYPHS.detached;
22
+ const aria = git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`;
23
+ return `<span class="bf-chip browse-git" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span><span class="browse-git__name">${escapeHtml(name)}</span></span>`;
24
+ }
25
+
26
+ /** The live chrome a page renders: how often its stream is sent a frame. */
27
+ export interface LivePage {
28
+ intervalMs: number;
29
+ /** Root-relative folder the page listed; absent on a page with no listing to check. */
30
+ dir?: string;
31
+ /** Revision of the names that listing drew, which the reload control compares against. */
32
+ rev?: string;
33
+ }
34
+
35
+ /**
36
+ * The header's live controls: the connection chip, and the reload control the
37
+ * client reveals once the folder the page drew has changed on disk. Both start
38
+ * hidden, so a page with JS off — or with live status disabled — never shows a
39
+ * state or a control it cannot keep true; the reload control is not rendered at
40
+ * all when the page has no listing revision to compare.
41
+ *
42
+ * The chip is deliberately not a `role="status"` region: the header is where a
43
+ * reader looks for the page's state, not a place to announce every change to it.
44
+ */
45
+ export function renderLiveChrome(live: LivePage | undefined): string {
46
+ if (!live) return "";
47
+ const rev =
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>`;
50
+ if (live.rev === undefined) return chip;
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
+ }
53
+
54
+ /**
55
+ * The footer's live line, hidden until the first frame: uptime and requests are
56
+ * server facts the rendered page does not know at build time.
57
+ */
58
+ export function renderLiveStatus(): string {
59
+ return `<span class="browse-live-line" id="live-status" hidden></span>`;
60
+ }
61
+
62
+ /**
63
+ * The header's changes chip, linking to the diff overview. With live status on,
64
+ * a hidden shell is always rendered so the chip can appear and disappear as the
65
+ * working tree changes, without the client inserting elements into the header.
66
+ */
67
+ export function renderChanges(summary: ChangeIndex | undefined, live: LivePage | undefined): string {
68
+ const count = summary?.count ?? 0;
69
+ if (count <= 0) {
70
+ return live
71
+ ? `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" hidden></a>`
72
+ : "";
73
+ }
74
+ const label = `${count} change${count === 1 ? "" : "s"}`;
75
+ const insertions = summary?.insertions ?? 0;
76
+ const deletions = summary?.deletions ?? 0;
77
+ const totals =
78
+ insertions + deletions > 0
79
+ ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${insertions}</span> <span class="browse-diff-del">−${deletions}</span></span>`
80
+ : "";
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>`;
83
+ }
@@ -148,6 +148,65 @@ ${TREE_CLIENT_JS}
148
148
 
149
149
  ${PALETTE_CLIENT_JS}
150
150
 
151
+ // A code reference: the file's path and the lines the reader selected. A click on
152
+ // the button collapses the selection before its click handler runs, so a new
153
+ // selection replaces the stored one and a collapse leaves it alone.
154
+ var reference = null;
155
+
156
+ function lineNode(node) {
157
+ var element = node && node.nodeType === 3 ? node.parentElement : node;
158
+ return element && element.closest ? element.closest(".bf-code__line") : null;
159
+ }
160
+
161
+ function referenceText() {
162
+ if (!reference || !reference.path) return "";
163
+ var span = reference.start === reference.end ? String(reference.start) : reference.start + "-" + reference.end;
164
+ return reference.path + ":" + span;
165
+ }
166
+
167
+ function captureReference() {
168
+ var button = document.getElementById("copy-reference");
169
+ if (!button) return;
170
+ var selection = window.getSelection && window.getSelection();
171
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
172
+ var range = selection.getRangeAt(0);
173
+ var from = lineNode(range.startContainer);
174
+ var to = lineNode(range.endContainer);
175
+ if (!from || !to) return;
176
+ var pre = from.closest("pre[data-path]");
177
+ if (!pre || pre !== to.closest("pre[data-path]")) return;
178
+ var lines = Array.prototype.slice.call(pre.querySelectorAll(".bf-code__line"));
179
+ var first = lines.indexOf(from);
180
+ var last = lines.indexOf(to);
181
+ if (first < 0 || last < 0) return;
182
+ reference = {
183
+ path: pre.getAttribute("data-path") || "",
184
+ start: Math.min(first, last) + 1,
185
+ end: Math.max(first, last) + 1
186
+ };
187
+ button.disabled = false;
188
+ button.title = "Copy " + referenceText();
189
+ }
190
+
191
+ function copyReference() {
192
+ var text = referenceText();
193
+ var button = document.getElementById("copy-reference");
194
+ if (!text) {
195
+ var status = document.getElementById("copy-status");
196
+ if (status) status.textContent = "Select lines in the code to copy a reference.";
197
+ return;
198
+ }
199
+ copyText(text)
200
+ .then(function () { if (button) flash(button, "copied", "Copied " + text); })
201
+ .catch(function () { if (button) flash(button, "failed", "Copy failed"); });
202
+ }
203
+
204
+ if (document.getElementById("copy-reference")) {
205
+ document.getElementById("copy-reference").addEventListener("click", copyReference);
206
+ document.addEventListener("mouseup", captureReference);
207
+ document.addEventListener("keyup", captureReference);
208
+ }
209
+
151
210
  // Keyboard: the shared shortcut sheet, the per-page actions the server
152
211
  // rendered, and hunk-to-hunk navigation through a diff.
153
212
  ${keysDialogScript("keys-open")}
@@ -256,6 +315,9 @@ ${PALETTE_CLIENT_JS}
256
315
  } else if (event.key === "u" && wrapButton) {
257
316
  event.preventDefault();
258
317
  wrapButton.click();
318
+ } else if (event.key === "y" && document.getElementById("copy-reference")) {
319
+ event.preventDefault();
320
+ copyReference();
259
321
  } else if (event.key === "r" && document.querySelector(".bf-card__actions .bf-segmented")) {
260
322
  event.preventDefault();
261
323
  toggleRaw();
@@ -57,7 +57,7 @@ export const DEFAULT_CONFIG: ServeConfig = {
57
57
  diffContext: 3,
58
58
  maxDiffLines: 4000,
59
59
  live: true,
60
- liveIntervalMs: 3000,
60
+ liveIntervalMs: 5000,
61
61
  };
62
62
 
63
63
  /** Base of the per-session port range. */
@@ -66,8 +66,8 @@ export const PORT_BASE = 4780;
66
66
  /** Number of ports in the per-session range. */
67
67
  export const PORT_RANGE = 1000;
68
68
 
69
- /** Fastest live-status cadence a page may be given, short enough to watch and
70
- * long enough that the 2 s provider caches absorb most ticks. */
69
+ /** Fastest live-status cadence a page may be given: short enough to watch, and a
70
+ * floor so a page cannot outrun the git reads behind each frame. */
71
71
  export const LIVE_INTERVAL_MIN = 250;
72
72
 
73
73
  /** Slowest live-status cadence, so a page cannot look abandoned. */
@@ -81,6 +81,15 @@ export const LIVE_INTERVAL_MAX = 60_000;
81
81
  */
82
82
  export const IDLE_STOP_MS = 10_000;
83
83
 
84
+ /**
85
+ * How many pages may hold the status stream at once. One snapshot serves them
86
+ * all, but every attached page keeps the ticker — and the git read behind each
87
+ * frame — running, so a local page cannot open a thousand streams and pin the
88
+ * server alive. The refusal is a `204`, which tells an event source to stop
89
+ * reconnecting instead of retrying for a slot that will not open.
90
+ */
91
+ export const LIVE_MAX_CLIENTS = 8;
92
+
84
93
  /** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
85
94
  export function randomPort(): number {
86
95
  return PORT_BASE + randomInt(PORT_RANGE);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The browse pages' unified-diff body: one line per row, with the gutter, the
3
+ * sign, and the hunk sections.
4
+ *
5
+ * Split from `html.ts` when that module reached its named ceiling: the diff body
6
+ * is the one part of the page renderers with its own rule — old and new sides are
7
+ * highlighted as two documents per hunk, so the tokenizer carries state within a
8
+ * hunk and never across the unchanged lines a hunk does not show.
9
+ */
10
+
11
+ import { escapeHtml } from "../../lib/escape.ts";
12
+ import { highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
13
+ import type { DiffLine } from "./diff.ts";
14
+
15
+ /**
16
+ * The inner HTML per diff line. Hunk bodies are highlighted by rebuilding the
17
+ * old side (context + deleted) and the new side (context + added), so the
18
+ * tokenizer carries state within a hunk; meta, hunk, and note lines stay plain.
19
+ * State does not cross hunks, since the unchanged lines between them are absent.
20
+ */
21
+ function highlightDiffText(lines: DiffLine[], language?: HighlightLanguage): string[] {
22
+ if (!language) return lines.map((line) => escapeHtml(line.text));
23
+ const out: string[] = new Array(lines.length);
24
+ let index = 0;
25
+ while (index < lines.length) {
26
+ const kind = lines[index].kind;
27
+ if (kind !== "ctx" && kind !== "add" && kind !== "del") {
28
+ out[index] = escapeHtml(lines[index].text);
29
+ index++;
30
+ continue;
31
+ }
32
+ const start = index;
33
+ const oldDoc: string[] = [];
34
+ const newDoc: string[] = [];
35
+ while (index < lines.length) {
36
+ const body = lines[index].kind;
37
+ if (body !== "ctx" && body !== "add" && body !== "del") break;
38
+ if (body === "ctx") {
39
+ oldDoc.push(lines[index].text);
40
+ newDoc.push(lines[index].text);
41
+ } else if (body === "add") {
42
+ newDoc.push(lines[index].text);
43
+ } else {
44
+ oldDoc.push(lines[index].text);
45
+ }
46
+ index++;
47
+ }
48
+ const oldHtml = highlightLines(oldDoc, language);
49
+ const newHtml = highlightLines(newDoc, language);
50
+ let oldIndex = 0;
51
+ let newIndex = 0;
52
+ for (let cursor = start; cursor < index; cursor++) {
53
+ const body = lines[cursor].kind;
54
+ if (body === "ctx") {
55
+ out[cursor] = newHtml[newIndex];
56
+ newIndex++;
57
+ oldIndex++;
58
+ } else if (body === "add") {
59
+ out[cursor] = newHtml[newIndex];
60
+ newIndex++;
61
+ } else {
62
+ out[cursor] = oldHtml[oldIndex];
63
+ oldIndex++;
64
+ }
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /** Render unified-diff lines with a two-column gutter and per-kind styling. */
71
+ export function renderDiffLines(lines: DiffLine[], language?: HighlightLanguage): string {
72
+ const html = highlightDiffText(lines, language);
73
+ // Every hunk is a section so its sticky header is bounded by its own body;
74
+ // the section also gives the keyboard a stable, non-sticky scroll target.
75
+ const out: string[] = [];
76
+ let hunks = 0;
77
+ let open = false;
78
+ lines.forEach((line, index) => {
79
+ if (line.kind === "hunk") {
80
+ if (open) out.push("</span>");
81
+ hunks += 1;
82
+ out.push(`<span class="bf-diff-hunk" id="hunk-${hunks}" data-hunk="${hunks}">`);
83
+ open = true;
84
+ }
85
+ const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : "";
86
+ const gutter = `<span class="browse-diff-no" aria-hidden="true">${line.oldNo ?? ""}</span><span class="browse-diff-no" aria-hidden="true">${line.newNo ?? ""}</span>`;
87
+ const marker = `<span class="browse-diff-sign" aria-hidden="true">${sign}</span>`;
88
+ out.push(
89
+ `<span class="bf-code__line bf-diff-line bf-diff-line--${line.kind}">${gutter}${marker}${html[index]}</span>`,
90
+ );
91
+ });
92
+ if (open) out.push("</span>");
93
+ return out.join("");
94
+ }
@@ -8,11 +8,12 @@
8
8
  * the page-only `browse-*` rules from `styles.ts`.
9
9
  */
10
10
 
11
- import { GLYPHS } from "../../lib/tui/glyphs.ts";
12
11
  import { brandLockup, docHead, type KeyHint, keysSheet, themeToggle } from "../../lib/web/index.ts";
13
12
  import { escapeHtml } from "../../lib/escape.ts";
13
+ import { renderChanges, renderGit, renderLiveChrome, renderLiveStatus, type LivePage } from "./chrome.ts";
14
14
  import { formatSize, type DirEntry, type FileView, type Listing, type TreeNode } from "./files.ts";
15
- import { DIFF_MODES, statusLetter, type ChangedFile, type DiffLine, type DiffMode } from "./diff.ts";
15
+ import { DIFF_MODES, statusLetter, type ChangedFile, type DiffMode } from "./diff.ts";
16
+ import { renderDiffLines } from "./diff-html.ts";
16
17
  import type { DiffFileView, GitStatus } from "./git.ts";
17
18
  import type { ChangeIndex } from "./live.ts";
18
19
  import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
@@ -36,16 +37,6 @@ function iconMarkup(spec: IconSpec, hidden = false): string {
36
37
  return `<svg class="browse-cat ${spec.colorClass}"${hidden ? " hidden" : ""} aria-hidden="true" title="${escapeHtml(spec.label)}"><use href="${iconHref(spec.icon)}"/></svg>`;
37
38
  }
38
39
 
39
- /** The header's git chip: the branch, or the detached head; changes are the changes chip's job. */
40
- function renderGit(git: GitStatus | undefined): string {
41
- if (!git) return "";
42
- const name = git.branch ?? git.head;
43
- if (!name) return "";
44
- const glyph = git.branch ? GLYPHS.branch : GLYPHS.detached;
45
- const aria = git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`;
46
- return `<span class="bf-chip browse-git" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span><span class="browse-git__name">${escapeHtml(name)}</span></span>`;
47
- }
48
-
49
40
  /**
50
41
  * The changed-path index for markers, plus the totals shown in the chip. It is
51
42
  * the same index the live status feed sends, so the page and its updates agree
@@ -66,55 +57,6 @@ function changeMark(summary: ChangeSummary | undefined, rel: string, isDir: bool
66
57
  : "";
67
58
  }
68
59
 
69
- /** The live chrome a page renders: how often its stream is sent a frame. */
70
- export interface LivePage {
71
- intervalMs: number;
72
- }
73
-
74
- /**
75
- * The header's connection chip. It starts hidden and says "connecting": the
76
- * client reveals and paints it from the event stream, so a page with JS off — or
77
- * with live status disabled — never shows a state it cannot keep true.
78
- *
79
- * It is deliberately not a `role="status"` region: the header is where a reader
80
- * looks for the page's state, not a place to announce every change to it.
81
- */
82
- function renderLiveChip(live: LivePage | undefined): string {
83
- if (!live) return "";
84
- return `<span class="bf-chip bf-chip--accent browse-live" id="live-state" title="Live status from the browse server" data-interval="${live.intervalMs}" hidden><span class="browse-live__glyph" aria-hidden="true">${escapeHtml(GLYPHS.browse)}</span><span class="browse-live__text">connecting</span></span>`;
85
- }
86
-
87
- /**
88
- * The footer's live line, hidden until the first frame: uptime and requests are
89
- * server facts the rendered page does not know at build time.
90
- */
91
- function renderLiveStatus(): string {
92
- return `<span class="browse-live-line" id="live-status" hidden></span>`;
93
- }
94
-
95
- /**
96
- * The header's changes chip, linking to the diff overview. With live status on,
97
- * a hidden shell is always rendered so the chip can appear and disappear as the
98
- * working tree changes, without the client inserting elements into the header.
99
- */
100
- function renderChanges(summary: ChangeSummary | undefined, live: LivePage | undefined): string {
101
- const count = summary?.count ?? 0;
102
- if (count <= 0) {
103
- return live
104
- ? `<a class="bf-chip browse-diff" id="browse-changes" href="/diff" title="View uncommitted changes" hidden></a>`
105
- : "";
106
- }
107
- const label = `${count} change${count === 1 ? "" : "s"}`;
108
- const insertions = summary?.insertions ?? 0;
109
- const deletions = summary?.deletions ?? 0;
110
- const totals =
111
- insertions + deletions > 0
112
- ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${insertions}</span> <span class="browse-diff-del">−${deletions}</span></span>`
113
- : "";
114
- const aria = `${label} against HEAD${insertions + deletions > 0 ? `, ${insertions} insertions and ${deletions} deletions` : ""}`;
115
- 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>`;
116
- }
117
-
118
60
  function hrefFor(entry: { rel: string; isDir: boolean }): string {
119
61
  return entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`;
120
62
  }
@@ -157,7 +99,7 @@ ${SPRITE}
157
99
  ${renderGit(layout.git)}
158
100
  ${renderChanges(layout.summary, layout.live)}
159
101
  <span class="bf-chip bf-chip--accent">read-only</span>
160
- ${renderLiveChip(layout.live)}
102
+ ${renderLiveChrome(layout.live)}
161
103
  <button type="button" class="bf-btn bf-btn--icon browse-keys-btn" id="keys-open" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
162
104
  ${themeToggle()}
163
105
  </div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
@@ -339,6 +281,11 @@ function pageKeys(...extra: KeyHint[]): KeyHint[] {
339
281
  ];
340
282
  }
341
283
 
284
+ /** Whether the page renders lines with a number beside them: plain text, or raw Markdown. */
285
+ function numberedLines(page: FilePage): boolean {
286
+ return page.file.kind === "text" || (page.file.kind === "markdown" && page.markdownRaw === true);
287
+ }
288
+
342
289
  /** The card-head controls: Markdown mode, line wrapping, and copy actions. */
343
290
  function renderFileActions(page: FilePage): string {
344
291
  const isMarkdown = page.file.kind === "markdown";
@@ -359,7 +306,7 @@ function renderFileActions(page: FilePage): string {
359
306
  : `<a class="bf-segmented__item" href="${viewHref}?raw=1">raw</a>`;
360
307
  groups.push(`<span class="bf-segmented" role="group" aria-label="Markdown view">${rendered}${raw}</span>`);
361
308
  }
362
- if (page.file.kind === "text" || (isMarkdown && page.markdownRaw)) {
309
+ if (numberedLines(page)) {
363
310
  groups.push(
364
311
  `<button type="button" class="bf-btn" id="wrap-toggle" aria-pressed="false" title="Toggle line wrapping">wrap</button>`,
365
312
  );
@@ -372,6 +319,11 @@ function renderFileActions(page: FilePage): string {
372
319
  groups.push(
373
320
  `<button type="button" class="bf-btn" data-copy="path" data-value="${escapeHtml(page.rel)}" title="Copy the root-relative path">copy path</button>`,
374
321
  );
322
+ if (numberedLines(page)) {
323
+ groups.push(
324
+ `<button type="button" class="bf-btn" id="copy-reference" disabled title="Select lines in the file first">copy reference</button>`,
325
+ );
326
+ }
375
327
  return `<span class="bf-card__actions">${groups.join("")}</span>`;
376
328
  }
377
329
 
@@ -388,10 +340,11 @@ export function renderFilePage(page: FilePage): string {
388
340
 
389
341
  const isMarkdown = page.file.kind === "markdown";
390
342
  const rendered = isMarkdown && !page.markdownRaw;
391
- const wrappable = page.file.kind === "text" || (isMarkdown && page.markdownRaw);
343
+ const numbered = numberedLines(page);
392
344
  const keys = pageKeys(
393
345
  ...(isMarkdown ? [{ keys: "r", label: "Toggle rendered and raw" }] : []),
394
- ...(wrappable ? [{ keys: "u", label: "Toggle line wrapping" }] : []),
346
+ ...(numbered ? [{ keys: "u", label: "Toggle line wrapping" }] : []),
347
+ ...(numbered ? [{ keys: "y", label: "Copy a reference to the selected lines" }] : []),
395
348
  );
396
349
 
397
350
  let content: string;
@@ -403,7 +356,7 @@ export function renderFilePage(page: FilePage): string {
403
356
  });
404
357
  content = `<article class="bf-md">${html}</article>`;
405
358
  } else {
406
- content = `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
359
+ content = `<pre class="bf-code bf-code--numbered" data-path="${escapeHtml(page.rel)}"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
407
360
  }
408
361
  if (page.file.truncated)
409
362
  note = `<p class="bf-note">Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.</p>`;
@@ -447,87 +400,6 @@ function statusWord(letter: string): string {
447
400
  return STATUS_LABEL[letter] ?? "changed";
448
401
  }
449
402
 
450
- /**
451
- * The inner HTML per diff line. Hunk bodies are highlighted by rebuilding the
452
- * old side (context + deleted) and the new side (context + added), so the
453
- * tokenizer carries state within a hunk; meta, hunk, and note lines stay plain.
454
- * State does not cross hunks, since the unchanged lines between them are absent.
455
- */
456
- function highlightDiffText(lines: DiffLine[], language?: HighlightLanguage): string[] {
457
- if (!language) return lines.map((line) => escapeHtml(line.text));
458
- const out: string[] = new Array(lines.length);
459
- let index = 0;
460
- while (index < lines.length) {
461
- const kind = lines[index].kind;
462
- if (kind !== "ctx" && kind !== "add" && kind !== "del") {
463
- out[index] = escapeHtml(lines[index].text);
464
- index++;
465
- continue;
466
- }
467
- const start = index;
468
- const oldDoc: string[] = [];
469
- const newDoc: string[] = [];
470
- while (index < lines.length) {
471
- const body = lines[index].kind;
472
- if (body !== "ctx" && body !== "add" && body !== "del") break;
473
- if (body === "ctx") {
474
- oldDoc.push(lines[index].text);
475
- newDoc.push(lines[index].text);
476
- } else if (body === "add") {
477
- newDoc.push(lines[index].text);
478
- } else {
479
- oldDoc.push(lines[index].text);
480
- }
481
- index++;
482
- }
483
- const oldHtml = highlightLines(oldDoc, language);
484
- const newHtml = highlightLines(newDoc, language);
485
- let oldIndex = 0;
486
- let newIndex = 0;
487
- for (let cursor = start; cursor < index; cursor++) {
488
- const body = lines[cursor].kind;
489
- if (body === "ctx") {
490
- out[cursor] = newHtml[newIndex];
491
- newIndex++;
492
- oldIndex++;
493
- } else if (body === "add") {
494
- out[cursor] = newHtml[newIndex];
495
- newIndex++;
496
- } else {
497
- out[cursor] = oldHtml[oldIndex];
498
- oldIndex++;
499
- }
500
- }
501
- }
502
- return out;
503
- }
504
-
505
- /** Render unified-diff lines with a two-column gutter and per-kind styling. */
506
- export function renderDiffLines(lines: DiffLine[], language?: HighlightLanguage): string {
507
- const html = highlightDiffText(lines, language);
508
- // Every hunk is a section so its sticky header is bounded by its own body;
509
- // the section also gives the keyboard a stable, non-sticky scroll target.
510
- const out: string[] = [];
511
- let hunks = 0;
512
- let open = false;
513
- lines.forEach((line, index) => {
514
- if (line.kind === "hunk") {
515
- if (open) out.push("</span>");
516
- hunks += 1;
517
- out.push(`<span class="bf-diff-hunk" id="hunk-${hunks}" data-hunk="${hunks}">`);
518
- open = true;
519
- }
520
- const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : "";
521
- const gutter = `<span class="browse-diff-no" aria-hidden="true">${line.oldNo ?? ""}</span><span class="browse-diff-no" aria-hidden="true">${line.newNo ?? ""}</span>`;
522
- const marker = `<span class="browse-diff-sign" aria-hidden="true">${sign}</span>`;
523
- out.push(
524
- `<span class="bf-code__line bf-diff-line bf-diff-line--${line.kind}">${gutter}${marker}${html[index]}</span>`,
525
- );
526
- });
527
- if (open) out.push("</span>");
528
- return out.join("");
529
- }
530
-
531
403
  /** The scope label for a mode. */
532
404
  function modeScope(mode: DiffMode): string {
533
405
  if (mode === "staged") return "staged vs HEAD";
@@ -9,6 +9,12 @@
9
9
  *
10
10
  * It does nothing at all unless the server rendered the connection chip, which
11
11
  * is what makes `live: false` and JS-off leave the page exactly as it was.
12
+ *
13
+ * The folder check is the other half: the stream is shared by every open page, so
14
+ * it cannot say what *this* page's rows show. A page that lists a folder carries
15
+ * the revision of what it drew, and asks the server for the folder's current one
16
+ * once per interval; a difference reveals a reload control instead of touching
17
+ * the document, because a reload is the only honest way to add a row.
12
18
  */
13
19
 
14
20
  export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the header chips, the tree and listing
@@ -19,9 +25,14 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
19
25
  var liveLine = document.getElementById("live-status");
20
26
  var liveGit = document.querySelector(".browse-git");
21
27
  var liveChanges = document.getElementById("browse-changes");
22
- var liveInterval = Number(liveChip.getAttribute("data-interval")) || 3000;
28
+ var liveInterval = Number(liveChip.getAttribute("data-interval")) || 5000;
23
29
  var liveStale = 0;
24
30
  var liveTone = "";
31
+ var liveRev = "";
32
+ var liveDir = liveChip.getAttribute("data-dir");
33
+ var liveDirRev = liveChip.getAttribute("data-rev");
34
+ var liveReload = document.getElementById("reload-hint");
35
+ var liveDirTimer = 0;
25
36
 
26
37
  function formatSpan(ms) {
27
38
  var seconds = Math.max(0, Math.round(ms / 1000));
@@ -88,7 +99,12 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
88
99
  });
89
100
  }
90
101
 
102
+ // One revision covers the chip and the markers: a frame that did not move the
103
+ // change set is not painted at all, so a tick on an idle tree costs no DOM
104
+ // work however many rows the page drew.
91
105
  function paintLiveChanges(changes) {
106
+ if (changes && changes.rev === liveRev) return;
107
+ liveRev = changes && changes.rev ? changes.rev : "";
92
108
  var count = changes ? changes.count : 0;
93
109
  if (liveChanges) {
94
110
  liveChanges.hidden = count <= 0;
@@ -136,6 +152,32 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
136
152
  paintLiveChanges(snapshot.changes);
137
153
  }
138
154
 
155
+ // The folder the page drew, checked against the disk on the frame cadence. It
156
+ // waits for the first frame, never runs in a hidden tab, and stops as soon as
157
+ // it has something to say: the control is the fix, and a reload ends the
158
+ // question.
159
+ function showLiveReload() {
160
+ window.clearInterval(liveDirTimer);
161
+ liveDirTimer = 0;
162
+ if (!liveReload || !liveReload.hidden) return;
163
+ liveReload.hidden = false;
164
+ liveReload.addEventListener("click", function () { window.location.reload(); });
165
+ }
166
+
167
+ function checkLiveDir() {
168
+ if (document.hidden || liveTone !== "success") return;
169
+ fetch("/api/dir-rev?path=" + encodeURIComponent(liveDir))
170
+ .then(function (response) {
171
+ if (!response.ok) throw new Error("check failed");
172
+ return response.json();
173
+ })
174
+ .then(function (answer) {
175
+ var rev = answer.rev;
176
+ if (typeof rev === "string" && rev !== liveDirRev) showLiveReload();
177
+ })
178
+ .catch(function () { /* the stream already reports a dead server */ });
179
+ }
180
+
139
181
  if (window.EventSource) {
140
182
  var liveStream = new EventSource("/events");
141
183
  liveStream.addEventListener("open", function () {
@@ -152,7 +194,11 @@ export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the heade
152
194
  window.addEventListener("pagehide", function () {
153
195
  liveStream.close();
154
196
  window.clearTimeout(liveStale);
197
+ window.clearInterval(liveDirTimer);
155
198
  });
199
+ if (liveDir !== null && liveDirRev && liveReload) {
200
+ liveDirTimer = window.setInterval(checkLiveDir, liveInterval);
201
+ }
156
202
  }
157
203
  }
158
204
  `;