@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.
- package/README.md +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- 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:
|
|
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
|
|
70
|
-
*
|
|
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
|
|
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
|
-
${
|
|
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 (
|
|
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
|
|
343
|
+
const numbered = numberedLines(page);
|
|
392
344
|
const keys = pageKeys(
|
|
393
345
|
...(isMarkdown ? [{ keys: "r", label: "Toggle rendered and raw" }] : []),
|
|
394
|
-
...(
|
|
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")) ||
|
|
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
|
`;
|