@gavin-hu/my-pi-agent 0.16.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 +29 -15
- package/README.zh-CN.md +27 -15
- package/extensions/bytefly/README.md +195 -115
- package/extensions/bytefly/footer/collect/entries.ts +1 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
- package/extensions/bytefly/footer/price/price.ts +12 -8
- package/extensions/bytefly/footer/register.ts +13 -1
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/footer/unpriced.ts +36 -0
- package/extensions/bytefly/header/component.ts +15 -29
- package/extensions/bytefly/header/identity.ts +71 -0
- package/extensions/bytefly/header/register.ts +9 -3
- package/extensions/bytefly/index.ts +13 -5
- package/extensions/bytefly/message/format.ts +20 -13
- package/extensions/bytefly/message/register.ts +8 -7
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +46 -25
- package/extensions/bytefly/panel/format.ts +49 -14
- package/extensions/bytefly/panel/join.ts +35 -9
- package/extensions/bytefly/panel/settings.ts +29 -39
- package/extensions/bytefly/panel/skills.ts +51 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
- package/extensions/bytefly/panel/tui/frame.ts +9 -1
- package/extensions/bytefly/panel/tui/list-view.ts +72 -0
- package/extensions/bytefly/panel/tui/nav.ts +71 -3
- package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
- package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
- package/extensions/bytefly/panel/tui.ts +223 -331
- package/extensions/bytefly/panel/types.ts +26 -8
- package/extensions/bytefly/picker/discovery.ts +32 -27
- package/extensions/bytefly/picker/resources.ts +16 -8
- package/extensions/bytefly/picker/runtime.ts +62 -34
- package/extensions/bytefly/picker/toggle.ts +16 -10
- package/extensions/bytefly/picker/types.ts +31 -13
- package/extensions/bytefly/update/format.ts +66 -8
- package/extensions/bytefly/update/patch.ts +66 -0
- package/extensions/bytefly/update/register.ts +4 -11
- package/extensions/bytefly/update/release.ts +65 -0
- package/extensions/bytefly/update/state.ts +14 -0
- package/extensions/bytefly/update/suppress.ts +47 -29
- package/extensions/bytefly/update/types.ts +15 -5
- package/extensions/check/tui.ts +2 -0
- package/extensions/context/README.md +21 -11
- package/extensions/context/format.ts +10 -8
- package/extensions/context/report.ts +21 -8
- package/extensions/context/tui.ts +26 -10
- package/extensions/context/types.ts +5 -3
- package/extensions/file-browse/README.md +89 -22
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +74 -459
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +26 -152
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/palette.ts +224 -0
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +27 -6
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +10 -3
- package/extensions/file-browse/tree.ts +381 -0
- package/extensions/file-read/README.md +4 -1
- package/extensions/file-read/extract/odt.ts +7 -5
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/goal/tools.ts +6 -11
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +35 -52
- package/extensions/job/tools.ts +51 -77
- package/extensions/memory/README.md +3 -3
- package/extensions/memory/tools.ts +4 -6
- package/extensions/memory/tui.ts +5 -1
- package/extensions/plan/README.md +333 -84
- package/extensions/plan/commands.ts +73 -7
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +184 -58
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +95 -95
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +123 -107
- package/extensions/plan/tools/cancel.ts +79 -0
- package/extensions/plan/tools/enter.ts +17 -12
- package/extensions/plan/tools/exit.ts +148 -59
- package/extensions/plan/tools/steps.ts +7 -19
- package/extensions/plan/tools/write.ts +29 -31
- package/extensions/plan/tools.ts +6 -3
- package/extensions/plan/tui.ts +60 -15
- package/extensions/plan/types.ts +45 -3
- package/extensions/plan/web-chrome.ts +102 -0
- package/extensions/plan/web-client.ts +177 -446
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-markdown.ts +222 -0
- package/extensions/plan/web-page.ts +131 -19
- package/extensions/plan/web-router.ts +148 -32
- 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/rewind/checklist.ts +4 -0
- package/extensions/rewind/review-tui.ts +3 -1
- package/extensions/rewind/rewind-tui.ts +4 -0
- 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 +26 -13
- package/extensions/subagent/format.ts +72 -25
- package/extensions/subagent/index.ts +21 -47
- package/extensions/subagent/orchestrate.ts +18 -41
- package/extensions/subagent/render.ts +29 -42
- package/extensions/subagent/stream.ts +24 -18
- package/extensions/subagent/types.ts +7 -0
- package/extensions/todo/tools.ts +5 -6
- package/extensions/todo/tui.ts +5 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +25 -44
- package/extensions/watch/tools.ts +31 -59
- package/extensions/wechat/README.md +45 -36
- package/extensions/wechat/bridge.ts +46 -48
- package/extensions/wechat/client.ts +1 -4
- package/extensions/wechat/commands.ts +3 -3
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +54 -18
- package/extensions/wechat/login-screen.ts +2 -3
- package/extensions/wechat/notices.ts +9 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +53 -23
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/wechat/slash.ts +14 -0
- package/extensions/wechat/status-screen.ts +2 -2
- package/extensions/wechat/tools.ts +3 -11
- package/extensions/worktree/README.md +48 -12
- package/extensions/worktree/commands.ts +109 -65
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/exit.ts +11 -1
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/lifecycle.ts +9 -1
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/screen.ts +263 -0
- package/extensions/worktree/status.ts +84 -40
- package/lib/README.md +12 -7
- package/lib/claims/overlap.ts +43 -0
- package/lib/config.ts +12 -0
- package/lib/format.ts +6 -2
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +28 -1
- 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 +117 -25
- package/lib/requirements.ts +1 -2
- package/lib/tool-result.ts +54 -4
- package/lib/tui/README.md +30 -8
- package/lib/tui/chrome.ts +27 -6
- package/lib/tui/dock-screen.ts +91 -19
- package/lib/tui/gauge.ts +9 -5
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/usage.ts +26 -0
- package/lib/web/README.md +131 -0
- package/lib/web/base.ts +19 -2
- package/lib/web/components.ts +10 -6
- package/lib/web/layout.ts +2 -0
- package/lib/web/markup.ts +2 -1
- package/lib/web/runtime.ts +103 -25
- package/lib/web/tokens.ts +23 -7
- package/package.json +5 -2
- package/skills/changelog/SKILL.md +57 -45
- 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 +209 -0
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -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 {
|
|
12
|
-
import { brandLockup, docHead, type KeyHint, keysSheet } from "../../lib/web/index.ts";
|
|
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,9 +99,9 @@ ${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>
|
|
164
106
|
<div class="bf-app__body">
|
|
165
107
|
<details class="bf-app__sidebar browse-sidebar" open>
|
|
@@ -213,7 +155,7 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
|
|
|
213
155
|
const href = hrefFor(node);
|
|
214
156
|
const current = node.rel === currentRel ? ` aria-current="page"` : "";
|
|
215
157
|
const twisty = isDir
|
|
216
|
-
? `<button type="button" class="bf-tree__tw bf-tree__tw--toggle"
|
|
158
|
+
? `<button type="button" class="bf-tree__tw bf-tree__tw--toggle" tabindex="-1" aria-hidden="true">${expanded ? "▾" : "▸"}</button>`
|
|
217
159
|
: `<span class="bf-tree__tw bf-tree__tw--spacer" aria-hidden="true"></span>`;
|
|
218
160
|
const dataPath = ` data-path="${escapeHtml(node.rel)}"`;
|
|
219
161
|
const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name);
|
|
@@ -222,7 +164,9 @@ function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSumm
|
|
|
222
164
|
: "";
|
|
223
165
|
const label = node.broken
|
|
224
166
|
? `<span class="browse-broken" title="broken symbolic link">${name}</span><span class="browse-link-mark">↩</span>`
|
|
225
|
-
:
|
|
167
|
+
: isDir
|
|
168
|
+
? `<a href="${href}" aria-expanded="${expanded}" title="${escapeHtml(node.rel)}">${name}</a>`
|
|
169
|
+
: `<a href="${href}" title="${escapeHtml(node.rel)}">${name}</a>`;
|
|
226
170
|
return `<li data-open="${expanded}"><div class="bf-tree__row"${current}${dataPath}>${twisty}${iconMarkup(spec)}${label}${changeMark(summary, node.rel, isDir)}</div>${children}</li>`;
|
|
227
171
|
}
|
|
228
172
|
|
|
@@ -238,7 +182,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean,
|
|
|
238
182
|
.join(" ");
|
|
239
183
|
const link = entry.broken
|
|
240
184
|
? `<span class="browse-broken" title="broken symbolic link">${name}</span>`
|
|
241
|
-
: `<a href="${hrefFor(entry)}">${name}</a>`;
|
|
185
|
+
: `<a href="${hrefFor(entry)}" title="${escapeHtml(entry.rel)}">${name}</a>`;
|
|
242
186
|
const mark = entry.isSymlink
|
|
243
187
|
? `<span class="browse-link-mark" title="${entry.broken ? "broken symbolic link" : "symbolic link"}">↩</span>`
|
|
244
188
|
: "";
|
|
@@ -337,6 +281,11 @@ function pageKeys(...extra: KeyHint[]): KeyHint[] {
|
|
|
337
281
|
];
|
|
338
282
|
}
|
|
339
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
|
+
|
|
340
289
|
/** The card-head controls: Markdown mode, line wrapping, and copy actions. */
|
|
341
290
|
function renderFileActions(page: FilePage): string {
|
|
342
291
|
const isMarkdown = page.file.kind === "markdown";
|
|
@@ -357,7 +306,7 @@ function renderFileActions(page: FilePage): string {
|
|
|
357
306
|
: `<a class="bf-segmented__item" href="${viewHref}?raw=1">raw</a>`;
|
|
358
307
|
groups.push(`<span class="bf-segmented" role="group" aria-label="Markdown view">${rendered}${raw}</span>`);
|
|
359
308
|
}
|
|
360
|
-
if (
|
|
309
|
+
if (numberedLines(page)) {
|
|
361
310
|
groups.push(
|
|
362
311
|
`<button type="button" class="bf-btn" id="wrap-toggle" aria-pressed="false" title="Toggle line wrapping">wrap</button>`,
|
|
363
312
|
);
|
|
@@ -370,6 +319,11 @@ function renderFileActions(page: FilePage): string {
|
|
|
370
319
|
groups.push(
|
|
371
320
|
`<button type="button" class="bf-btn" data-copy="path" data-value="${escapeHtml(page.rel)}" title="Copy the root-relative path">copy path</button>`,
|
|
372
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
|
+
}
|
|
373
327
|
return `<span class="bf-card__actions">${groups.join("")}</span>`;
|
|
374
328
|
}
|
|
375
329
|
|
|
@@ -386,10 +340,11 @@ export function renderFilePage(page: FilePage): string {
|
|
|
386
340
|
|
|
387
341
|
const isMarkdown = page.file.kind === "markdown";
|
|
388
342
|
const rendered = isMarkdown && !page.markdownRaw;
|
|
389
|
-
const
|
|
343
|
+
const numbered = numberedLines(page);
|
|
390
344
|
const keys = pageKeys(
|
|
391
345
|
...(isMarkdown ? [{ keys: "r", label: "Toggle rendered and raw" }] : []),
|
|
392
|
-
...(
|
|
346
|
+
...(numbered ? [{ keys: "u", label: "Toggle line wrapping" }] : []),
|
|
347
|
+
...(numbered ? [{ keys: "y", label: "Copy a reference to the selected lines" }] : []),
|
|
393
348
|
);
|
|
394
349
|
|
|
395
350
|
let content: string;
|
|
@@ -401,7 +356,7 @@ export function renderFilePage(page: FilePage): string {
|
|
|
401
356
|
});
|
|
402
357
|
content = `<article class="bf-md">${html}</article>`;
|
|
403
358
|
} else {
|
|
404
|
-
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>`;
|
|
405
360
|
}
|
|
406
361
|
if (page.file.truncated)
|
|
407
362
|
note = `<p class="bf-note">Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.</p>`;
|
|
@@ -445,87 +400,6 @@ function statusWord(letter: string): string {
|
|
|
445
400
|
return STATUS_LABEL[letter] ?? "changed";
|
|
446
401
|
}
|
|
447
402
|
|
|
448
|
-
/**
|
|
449
|
-
* The inner HTML per diff line. Hunk bodies are highlighted by rebuilding the
|
|
450
|
-
* old side (context + deleted) and the new side (context + added), so the
|
|
451
|
-
* tokenizer carries state within a hunk; meta, hunk, and note lines stay plain.
|
|
452
|
-
* State does not cross hunks, since the unchanged lines between them are absent.
|
|
453
|
-
*/
|
|
454
|
-
function highlightDiffText(lines: DiffLine[], language?: HighlightLanguage): string[] {
|
|
455
|
-
if (!language) return lines.map((line) => escapeHtml(line.text));
|
|
456
|
-
const out: string[] = new Array(lines.length);
|
|
457
|
-
let index = 0;
|
|
458
|
-
while (index < lines.length) {
|
|
459
|
-
const kind = lines[index].kind;
|
|
460
|
-
if (kind !== "ctx" && kind !== "add" && kind !== "del") {
|
|
461
|
-
out[index] = escapeHtml(lines[index].text);
|
|
462
|
-
index++;
|
|
463
|
-
continue;
|
|
464
|
-
}
|
|
465
|
-
const start = index;
|
|
466
|
-
const oldDoc: string[] = [];
|
|
467
|
-
const newDoc: string[] = [];
|
|
468
|
-
while (index < lines.length) {
|
|
469
|
-
const body = lines[index].kind;
|
|
470
|
-
if (body !== "ctx" && body !== "add" && body !== "del") break;
|
|
471
|
-
if (body === "ctx") {
|
|
472
|
-
oldDoc.push(lines[index].text);
|
|
473
|
-
newDoc.push(lines[index].text);
|
|
474
|
-
} else if (body === "add") {
|
|
475
|
-
newDoc.push(lines[index].text);
|
|
476
|
-
} else {
|
|
477
|
-
oldDoc.push(lines[index].text);
|
|
478
|
-
}
|
|
479
|
-
index++;
|
|
480
|
-
}
|
|
481
|
-
const oldHtml = highlightLines(oldDoc, language);
|
|
482
|
-
const newHtml = highlightLines(newDoc, language);
|
|
483
|
-
let oldIndex = 0;
|
|
484
|
-
let newIndex = 0;
|
|
485
|
-
for (let cursor = start; cursor < index; cursor++) {
|
|
486
|
-
const body = lines[cursor].kind;
|
|
487
|
-
if (body === "ctx") {
|
|
488
|
-
out[cursor] = newHtml[newIndex];
|
|
489
|
-
newIndex++;
|
|
490
|
-
oldIndex++;
|
|
491
|
-
} else if (body === "add") {
|
|
492
|
-
out[cursor] = newHtml[newIndex];
|
|
493
|
-
newIndex++;
|
|
494
|
-
} else {
|
|
495
|
-
out[cursor] = oldHtml[oldIndex];
|
|
496
|
-
oldIndex++;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
return out;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/** Render unified-diff lines with a two-column gutter and per-kind styling. */
|
|
504
|
-
export function renderDiffLines(lines: DiffLine[], language?: HighlightLanguage): string {
|
|
505
|
-
const html = highlightDiffText(lines, language);
|
|
506
|
-
// Every hunk is a section so its sticky header is bounded by its own body;
|
|
507
|
-
// the section also gives the keyboard a stable, non-sticky scroll target.
|
|
508
|
-
const out: string[] = [];
|
|
509
|
-
let hunks = 0;
|
|
510
|
-
let open = false;
|
|
511
|
-
lines.forEach((line, index) => {
|
|
512
|
-
if (line.kind === "hunk") {
|
|
513
|
-
if (open) out.push("</span>");
|
|
514
|
-
hunks += 1;
|
|
515
|
-
out.push(`<span class="bf-diff-hunk" id="hunk-${hunks}" data-hunk="${hunks}">`);
|
|
516
|
-
open = true;
|
|
517
|
-
}
|
|
518
|
-
const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : "";
|
|
519
|
-
const gutter = `<span class="browse-diff-no" aria-hidden="true">${line.oldNo ?? ""}</span><span class="browse-diff-no" aria-hidden="true">${line.newNo ?? ""}</span>`;
|
|
520
|
-
const marker = `<span class="browse-diff-sign" aria-hidden="true">${sign}</span>`;
|
|
521
|
-
out.push(
|
|
522
|
-
`<span class="bf-code__line bf-diff-line bf-diff-line--${line.kind}">${gutter}${marker}${html[index]}</span>`,
|
|
523
|
-
);
|
|
524
|
-
});
|
|
525
|
-
if (open) out.push("</span>");
|
|
526
|
-
return out.join("");
|
|
527
|
-
}
|
|
528
|
-
|
|
529
403
|
/** The scope label for a mode. */
|
|
530
404
|
function modeScope(mode: DiffMode): string {
|
|
531
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
|
`;
|
|
@@ -3,17 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A page that stays open holds one event stream; this module builds the snapshot
|
|
5
5
|
* it is sent, the frame that carries it, and the channel that fans it out on a
|
|
6
|
-
* tick.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* tick. A tick costs one `git status` and one `git diff --stat`: the providers'
|
|
7
|
+
* 2 s memo only coalesces a tick with a page render in the same window, so the
|
|
8
|
+
* cadence is what bounds the reads. Every read failure degrades to `null`: a
|
|
9
|
+
* status feed must never be able to fail a page or the server that serves it.
|
|
9
10
|
*
|
|
10
11
|
* The channel opens no socket of its own — the caller hands it a `write`/`end`
|
|
11
12
|
* pair — so the whole feed is tested without one.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { defaultInterval, defaultTimer } from "../../lib/timer.ts";
|
|
16
|
+
import { LIVE_MAX_CLIENTS } from "./config.ts";
|
|
15
17
|
import { changeDirs } from "./diff.ts";
|
|
16
18
|
import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
|
|
19
|
+
import { changeRevision } from "./rev.ts";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* The changed-path index the pages render from: the totals the changes chip
|
|
@@ -50,12 +53,27 @@ export interface LiveChanges {
|
|
|
50
53
|
deletions: number;
|
|
51
54
|
files: string[];
|
|
52
55
|
dirs: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Identity of this change set, counts included. A page that painted the same
|
|
58
|
+
* revision repaints nothing when the next frame arrives.
|
|
59
|
+
*/
|
|
60
|
+
rev: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A change index as it goes over the wire, with its revision. */
|
|
64
|
+
function changesOf(index: ChangeIndex): LiveChanges {
|
|
65
|
+
const changes = {
|
|
66
|
+
count: index.count,
|
|
67
|
+
insertions: index.insertions,
|
|
68
|
+
deletions: index.deletions,
|
|
69
|
+
files: [...index.files].sort(),
|
|
70
|
+
dirs: [...index.dirs].sort(),
|
|
71
|
+
};
|
|
72
|
+
return { ...changes, rev: changeRevision(changes) };
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
/** One status frame's payload. */
|
|
56
76
|
export interface LiveSnapshot {
|
|
57
|
-
/** Server wall clock when the snapshot was taken, in ms. */
|
|
58
|
-
at: number;
|
|
59
77
|
/** How long the server has been listening, in ms. */
|
|
60
78
|
uptimeMs: number;
|
|
61
79
|
/** Requests the server has answered so far, this one included. */
|
|
@@ -112,15 +130,7 @@ export function createLiveStatus(options: LiveStatusOptions): LiveStatusSource {
|
|
|
112
130
|
uptimeMs: Math.max(0, at - options.startedAt),
|
|
113
131
|
requests: options.requests(),
|
|
114
132
|
git,
|
|
115
|
-
changes: index
|
|
116
|
-
? {
|
|
117
|
-
count: index.count,
|
|
118
|
-
insertions: index.insertions,
|
|
119
|
-
deletions: index.deletions,
|
|
120
|
-
files: [...index.files].sort(),
|
|
121
|
-
dirs: [...index.dirs].sort(),
|
|
122
|
-
}
|
|
123
|
-
: null,
|
|
133
|
+
changes: index ? changesOf(index) : null,
|
|
124
134
|
};
|
|
125
135
|
},
|
|
126
136
|
};
|
|
@@ -158,6 +168,8 @@ export interface LiveChannelOptions {
|
|
|
158
168
|
intervalMs: number;
|
|
159
169
|
/** How long the last page's departure is held before `onIdle` is called, in ms. */
|
|
160
170
|
idleMs: number;
|
|
171
|
+
/** How many pages may attach at once; defaults to `LIVE_MAX_CLIENTS`. */
|
|
172
|
+
maxClients?: number;
|
|
161
173
|
/** Called once the last page has been gone for `idleMs`; omitted, the channel only idles. */
|
|
162
174
|
onIdle?: () => void;
|
|
163
175
|
/** Timer seam; defaults to an unref'd interval. */
|
|
@@ -172,6 +184,11 @@ export interface LiveChannel {
|
|
|
172
184
|
attach(client: LiveClient): () => void;
|
|
173
185
|
/** End every connected page, stop the ticker, and drop a pending idle stop. */
|
|
174
186
|
close(): void;
|
|
187
|
+
/**
|
|
188
|
+
* Whether another page must be refused. The caller asks before it opens the
|
|
189
|
+
* stream, so a refusal never has to take back a head it already wrote.
|
|
190
|
+
*/
|
|
191
|
+
atCapacity(): boolean;
|
|
175
192
|
}
|
|
176
193
|
|
|
177
194
|
/**
|
|
@@ -183,6 +200,7 @@ export interface LiveChannel {
|
|
|
183
200
|
export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
|
|
184
201
|
const timer = options.timer ?? defaultTicker;
|
|
185
202
|
const timeout = options.timeout ?? defaultTimeout;
|
|
203
|
+
const maxClients = options.maxClients ?? LIVE_MAX_CLIENTS;
|
|
186
204
|
const clients = new Set<LiveClient>();
|
|
187
205
|
let cancel: (() => void) | undefined;
|
|
188
206
|
let cancelIdle: (() => void) | undefined;
|
|
@@ -252,5 +270,8 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
|
|
|
252
270
|
for (const client of clients) client.end();
|
|
253
271
|
clients.clear();
|
|
254
272
|
},
|
|
273
|
+
atCapacity() {
|
|
274
|
+
return clients.size >= maxClients;
|
|
275
|
+
},
|
|
255
276
|
};
|
|
256
277
|
}
|