@gavin-hu/my-pi-agent 0.17.0 → 0.19.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 +61 -45
- package/README.zh-CN.md +54 -42
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +554 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
- package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
- package/docs/extensions/plan.md +976 -0
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
- package/docs/extensions/team.md +226 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +11 -21
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
- package/extensions/file-browse/live/rev.ts +42 -0
- package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/view/chrome.ts +83 -0
- package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
- package/extensions/file-browse/view/diff-html.ts +94 -0
- package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +77 -21
- package/extensions/plan/feedback.ts +409 -63
- package/extensions/plan/index.ts +7 -5
- package/extensions/plan/plan-index.ts +44 -10
- package/extensions/plan/plan-text.ts +51 -24
- package/extensions/plan/plans.ts +162 -57
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +51 -95
- package/extensions/plan/runtime.ts +22 -10
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +63 -95
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +13 -19
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +29 -15
- package/extensions/plan/types.ts +29 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +122 -178
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +96 -17
- package/extensions/plan/web-router.ts +109 -47
- package/extensions/plan/web-share-session.ts +191 -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 +245 -151
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +113 -0
- package/extensions/session-bus/format.ts +101 -0
- package/extensions/session-bus/index.ts +75 -0
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +86 -0
- package/extensions/session-bus/overlap.ts +36 -0
- package/extensions/session-bus/protocol.ts +104 -0
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +208 -0
- package/extensions/session-bus/rows.ts +171 -0
- package/extensions/session-bus/runtime.ts +466 -0
- package/extensions/session-bus/store.ts +365 -0
- package/extensions/session-bus/tools.ts +192 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- package/extensions/watch/index.ts +15 -9
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +76 -92
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
- package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/guard.ts +3 -5
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +12 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/router.ts +40 -13
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +29 -13
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/policy.ts +12 -2
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +7 -9
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/glyphs.ts +2 -0
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +6 -1
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +27 -14
- 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 +77 -51
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/file-browse/README.md +0 -467
- package/extensions/file-browse/server.ts +0 -134
- package/extensions/plan/README.md +0 -709
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/outbound.ts +0 -100
- package/extensions/wechat/qrcode.ts +0 -46
- package/skills/changelog/SKILL.md +0 -148
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
|
@@ -3,17 +3,21 @@
|
|
|
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
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
15
|
+
import type { EventSink } from "../../../lib/http/index.ts";
|
|
16
|
+
import { defaultInterval, type RepeatTimer } from "../../../lib/timer.ts";
|
|
17
|
+
import { LIVE_MAX_CLIENTS } from "../config/config.ts";
|
|
18
|
+
import { changeDirs } from "../text/diff.ts";
|
|
19
|
+
import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "../git/git.ts";
|
|
20
|
+
import { changeRevision } from "./rev.ts";
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* The changed-path index the pages render from: the totals the changes chip
|
|
@@ -50,12 +54,27 @@ export interface LiveChanges {
|
|
|
50
54
|
deletions: number;
|
|
51
55
|
files: string[];
|
|
52
56
|
dirs: string[];
|
|
57
|
+
/**
|
|
58
|
+
* Identity of this change set, counts included. A page that painted the same
|
|
59
|
+
* revision repaints nothing when the next frame arrives.
|
|
60
|
+
*/
|
|
61
|
+
rev: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A change index as it goes over the wire, with its revision. */
|
|
65
|
+
function changesOf(index: ChangeIndex): LiveChanges {
|
|
66
|
+
const changes = {
|
|
67
|
+
count: index.count,
|
|
68
|
+
insertions: index.insertions,
|
|
69
|
+
deletions: index.deletions,
|
|
70
|
+
files: [...index.files].sort(),
|
|
71
|
+
dirs: [...index.dirs].sort(),
|
|
72
|
+
};
|
|
73
|
+
return { ...changes, rev: changeRevision(changes) };
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
/** One status frame's payload. */
|
|
56
77
|
export interface LiveSnapshot {
|
|
57
|
-
/** Server wall clock when the snapshot was taken, in ms. */
|
|
58
|
-
at: number;
|
|
59
78
|
/** How long the server has been listening, in ms. */
|
|
60
79
|
uptimeMs: number;
|
|
61
80
|
/** Requests the server has answered so far, this one included. */
|
|
@@ -112,15 +131,7 @@ export function createLiveStatus(options: LiveStatusOptions): LiveStatusSource {
|
|
|
112
131
|
uptimeMs: Math.max(0, at - options.startedAt),
|
|
113
132
|
requests: options.requests(),
|
|
114
133
|
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,
|
|
134
|
+
changes: index ? changesOf(index) : null,
|
|
124
135
|
};
|
|
125
136
|
},
|
|
126
137
|
};
|
|
@@ -131,61 +142,46 @@ export function frameOf(snapshot: LiveSnapshot): string {
|
|
|
131
142
|
return `event: status\ndata: ${JSON.stringify(snapshot)}\n\n`;
|
|
132
143
|
}
|
|
133
144
|
|
|
134
|
-
/** A connected page
|
|
135
|
-
export
|
|
136
|
-
/** Write one frame. */
|
|
137
|
-
write(frame: string): void;
|
|
138
|
-
/** End the stream. */
|
|
139
|
-
end(): void;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Starts a repeating tick and returns its cancel. */
|
|
143
|
-
export type LiveTimer = (tick: () => void, intervalMs: number) => () => void;
|
|
144
|
-
|
|
145
|
-
/** Starts a one-shot delay and returns its cancel. */
|
|
146
|
-
export type LiveTimeout = (run: () => void, delayMs: number) => () => void;
|
|
147
|
-
|
|
148
|
-
/** The default ticker: a repeating timer that cannot hold the process open. */
|
|
149
|
-
const defaultTicker: LiveTimer = (tick, intervalMs) => defaultInterval(intervalMs, tick);
|
|
150
|
-
|
|
151
|
-
/** The default grace timer: one shot, and it cannot hold the process open either. */
|
|
152
|
-
const defaultTimeout: LiveTimeout = (run, delayMs) => defaultTimer(delayMs, run);
|
|
145
|
+
/** A connected page, as the channel writes it: the sink's two verbs. */
|
|
146
|
+
export type LiveClient = EventSink;
|
|
153
147
|
|
|
154
148
|
export interface LiveChannelOptions {
|
|
155
149
|
/** What each frame carries. */
|
|
156
150
|
status: LiveStatusSource;
|
|
157
151
|
/** How far apart the frames are, in ms. */
|
|
158
152
|
intervalMs: number;
|
|
159
|
-
/** How
|
|
160
|
-
|
|
161
|
-
/** Called once the last page has been gone for `idleMs`; omitted, the channel only idles. */
|
|
162
|
-
onIdle?: () => void;
|
|
153
|
+
/** How many pages may attach at once; defaults to `LIVE_MAX_CLIENTS`. */
|
|
154
|
+
maxClients?: number;
|
|
163
155
|
/** Timer seam; defaults to an unref'd interval. */
|
|
164
|
-
timer?:
|
|
165
|
-
/** Grace seam; defaults to an unref'd timeout. */
|
|
166
|
-
timeout?: LiveTimeout;
|
|
156
|
+
timer?: RepeatTimer;
|
|
167
157
|
}
|
|
168
158
|
|
|
169
159
|
/** The fan-out for one server: pages attach, ticks publish, close ends them. */
|
|
170
160
|
export interface LiveChannel {
|
|
171
161
|
/** Add a connected page, send it the current status, and return its detach. */
|
|
172
162
|
attach(client: LiveClient): () => void;
|
|
173
|
-
/** End every connected page, stop the ticker
|
|
163
|
+
/** End every connected page, and stop the ticker. */
|
|
174
164
|
close(): void;
|
|
165
|
+
/**
|
|
166
|
+
* Whether another page must be refused. The caller asks before it opens the
|
|
167
|
+
* stream, so a refusal never has to take back a head it already wrote.
|
|
168
|
+
*/
|
|
169
|
+
atCapacity(): boolean;
|
|
175
170
|
}
|
|
176
171
|
|
|
177
172
|
/**
|
|
178
|
-
* Build a channel for one server. The ticker runs only while at least one page
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
173
|
+
* Build a channel for one server. The ticker runs only while at least one page is
|
|
174
|
+
* attached, so an idle server does no status work at all; the last page leaving
|
|
175
|
+
* parks it and the next page starts it again. What happens to a server nobody is
|
|
176
|
+
* reading — the idle grace and the close it leads to — is the server's policy, so
|
|
177
|
+
* the caller hands it to `lib/http`'s `serveLoopback` instead of configuring it
|
|
178
|
+
* here.
|
|
182
179
|
*/
|
|
183
180
|
export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
|
|
184
|
-
const timer = options.timer ??
|
|
185
|
-
const
|
|
181
|
+
const timer = options.timer ?? defaultInterval;
|
|
182
|
+
const maxClients = options.maxClients ?? LIVE_MAX_CLIENTS;
|
|
186
183
|
const clients = new Set<LiveClient>();
|
|
187
184
|
let cancel: (() => void) | undefined;
|
|
188
|
-
let cancelIdle: (() => void) | undefined;
|
|
189
185
|
let inFlight = false;
|
|
190
186
|
|
|
191
187
|
const stop = (): void => {
|
|
@@ -193,34 +189,9 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
|
|
|
193
189
|
cancel = undefined;
|
|
194
190
|
};
|
|
195
191
|
|
|
196
|
-
const cancelIdleStop = (): void => {
|
|
197
|
-
cancelIdle?.();
|
|
198
|
-
cancelIdle = undefined;
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const armIdleStop = (): void => {
|
|
202
|
-
if (!options.onIdle || cancelIdle) return;
|
|
203
|
-
cancelIdle = timeout(() => {
|
|
204
|
-
cancelIdle = undefined;
|
|
205
|
-
options.onIdle?.();
|
|
206
|
-
}, options.idleMs);
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
/** Nobody is reading a frame any more: park the ticker and start the grace. */
|
|
210
|
-
const pageLeft = (): void => {
|
|
211
|
-
stop();
|
|
212
|
-
armIdleStop();
|
|
213
|
-
};
|
|
214
|
-
|
|
215
192
|
const send = (frame: string): void => {
|
|
216
|
-
for (const client of clients)
|
|
217
|
-
|
|
218
|
-
client.write(frame);
|
|
219
|
-
} catch {
|
|
220
|
-
clients.delete(client);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
if (clients.size === 0) pageLeft();
|
|
193
|
+
for (const client of clients) client.write(frame);
|
|
194
|
+
if (clients.size === 0) stop();
|
|
224
195
|
};
|
|
225
196
|
|
|
226
197
|
// One snapshot serves every attached page, so a tick is one git read and one
|
|
@@ -237,20 +208,21 @@ export function createLiveChannel(options: LiveChannelOptions): LiveChannel {
|
|
|
237
208
|
|
|
238
209
|
return {
|
|
239
210
|
attach(client) {
|
|
240
|
-
cancelIdleStop();
|
|
241
211
|
clients.add(client);
|
|
242
|
-
cancel ??= timer(() => void publish()
|
|
212
|
+
cancel ??= timer(options.intervalMs, () => void publish());
|
|
243
213
|
void publish();
|
|
244
214
|
return () => {
|
|
245
215
|
if (!clients.delete(client)) return;
|
|
246
|
-
if (clients.size === 0)
|
|
216
|
+
if (clients.size === 0) stop();
|
|
247
217
|
};
|
|
248
218
|
},
|
|
249
219
|
close() {
|
|
250
|
-
cancelIdleStop();
|
|
251
220
|
stop();
|
|
252
221
|
for (const client of clients) client.end();
|
|
253
222
|
clients.clear();
|
|
254
223
|
},
|
|
224
|
+
atCapacity() {
|
|
225
|
+
return clients.size >= maxClients;
|
|
226
|
+
},
|
|
255
227
|
};
|
|
256
228
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revisions for the browse live status.
|
|
3
|
+
*
|
|
4
|
+
* A revision is a short digest of something a page already drew — the change set
|
|
5
|
+
* a frame carries, or the names a listing holds — so a client can tell "nothing
|
|
6
|
+
* moved" from "paint again" without comparing the payload it was sent. A
|
|
7
|
+
* collision costs one skipped repaint and is corrected by the next frame or a
|
|
8
|
+
* reload, which is the trade a 12-character digest makes for a small frame.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A digest of the parts, cut to 12 hex characters. The parts are joined with NUL,
|
|
15
|
+
* which no filename can contain, so two different lists cannot run together into
|
|
16
|
+
* the same text.
|
|
17
|
+
*/
|
|
18
|
+
function digest(parts: readonly string[]): string {
|
|
19
|
+
return createHash("sha1").update(parts.join("\u0000")).digest("hex").slice(0, 12);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The change set a frame carries: its totals and paths, in a stable order. */
|
|
23
|
+
export interface RevisionInput {
|
|
24
|
+
count: number;
|
|
25
|
+
insertions: number;
|
|
26
|
+
deletions: number;
|
|
27
|
+
files: readonly string[];
|
|
28
|
+
dirs: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The revision of a change set. The totals are part of it, so an edit that moves
|
|
33
|
+
* no path still moves the revision and the chip's `+A −D` stays honest.
|
|
34
|
+
*/
|
|
35
|
+
export function changeRevision(changes: RevisionInput): string {
|
|
36
|
+
return digest([`${changes.count}:${changes.insertions}:${changes.deletions}`, ...changes.files, ...changes.dirs]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The revision of a listing's names, independent of the order they arrived in. */
|
|
40
|
+
export function listingRevision(names: readonly string[]): string {
|
|
41
|
+
return digest([...names].sort());
|
|
42
|
+
}
|
|
@@ -19,13 +19,14 @@ import {
|
|
|
19
19
|
parseRequestUrl,
|
|
20
20
|
scriptHeaders,
|
|
21
21
|
type ServeResponse,
|
|
22
|
-
} from "
|
|
23
|
-
import { THEME_JS, THEME_JS_PATH } from "
|
|
24
|
-
import { CLIENT_JS } from "
|
|
25
|
-
import type {
|
|
26
|
-
import {
|
|
27
|
-
import
|
|
28
|
-
import {
|
|
22
|
+
} from "../../../lib/http/index.ts";
|
|
23
|
+
import { THEME_JS, THEME_JS_PATH } from "../../../lib/web/index.ts";
|
|
24
|
+
import { CLIENT_JS } from "../view/client.ts";
|
|
25
|
+
import type { LivePage } from "../view/chrome.ts";
|
|
26
|
+
import type { ServeConfig } from "../config/config.ts";
|
|
27
|
+
import { isDiffMode, type DiffMode } from "../text/diff.ts";
|
|
28
|
+
import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "../git/git.ts";
|
|
29
|
+
import { changeIndexOf, type LiveStatusSource } from "../live/live.ts";
|
|
29
30
|
import {
|
|
30
31
|
buildFileIndex,
|
|
31
32
|
buildTree,
|
|
@@ -37,12 +38,19 @@ import {
|
|
|
37
38
|
type DirEntry,
|
|
38
39
|
type FileIndexProvider,
|
|
39
40
|
type TreeNode,
|
|
40
|
-
} from "
|
|
41
|
-
import { FOLDER_ICON, iconHref, languageOf } from "
|
|
42
|
-
import { fuzzySearch } from "
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
} from "../files/files.ts";
|
|
42
|
+
import { FOLDER_ICON, iconHref, languageOf } from "../view/icons.ts";
|
|
43
|
+
import { fuzzySearch } from "../text/fuzzy.ts";
|
|
44
|
+
import {
|
|
45
|
+
renderDirectoryPage,
|
|
46
|
+
renderDiffPage,
|
|
47
|
+
renderErrorPage,
|
|
48
|
+
renderFileDiffPage,
|
|
49
|
+
renderFilePage,
|
|
50
|
+
} from "../view/html.ts";
|
|
51
|
+
import type { ChangeSummary } from "../view/html.ts";
|
|
52
|
+
import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "../files/paths.ts";
|
|
53
|
+
import { listingRevision } from "../live/rev.ts";
|
|
46
54
|
|
|
47
55
|
export interface ServeContext {
|
|
48
56
|
root: string;
|
|
@@ -108,9 +116,13 @@ function summaryOf(list: DiffList): ChangeSummary {
|
|
|
108
116
|
};
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
/**
|
|
120
|
+
* The live chrome a page renders, or `undefined` when live status is off. A
|
|
121
|
+
* listing page also passes the revision of what it drew, which is what its
|
|
122
|
+
* reload control compares against; every other page has nothing to compare.
|
|
123
|
+
*/
|
|
124
|
+
function liveOf(ctx: ServeContext, folder?: { dir: string; rev: string }): LivePage | undefined {
|
|
125
|
+
return ctx.config.live && ctx.live ? { intervalMs: ctx.config.liveIntervalMs, ...folder } : undefined;
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
/** The `head` change summary, for the header chip, tree markers, and file action. */
|
|
@@ -184,7 +196,10 @@ async function directoryResponse(ctx: ServeContext, rel: string): Promise<ServeR
|
|
|
184
196
|
maxThumbBytes: ctx.config.maxThumbBytes,
|
|
185
197
|
git,
|
|
186
198
|
summary: changes,
|
|
187
|
-
live: liveOf(
|
|
199
|
+
live: liveOf(
|
|
200
|
+
ctx,
|
|
201
|
+
listing.truncated ? undefined : { dir: rel, rev: listingRevision(listing.entries.map((entry) => entry.name)) },
|
|
202
|
+
),
|
|
188
203
|
}),
|
|
189
204
|
);
|
|
190
205
|
}
|
|
@@ -312,6 +327,17 @@ async function treeApi(ctx: ServeContext, decodedPath: string): Promise<ServeRes
|
|
|
312
327
|
return jsonResponse(200, { path: rel, entries: listing.entries.map(treeApiEntry) });
|
|
313
328
|
}
|
|
314
329
|
|
|
330
|
+
/** The revision of one folder's listing: what a page's reload control compares against. */
|
|
331
|
+
async function dirRevApi(ctx: ServeContext, decodedPath: string): Promise<ServeResponse> {
|
|
332
|
+
const abs = await resolveDecodedPath(ctx.root, decodedPath);
|
|
333
|
+
const rel = relativePath(ctx.root, abs);
|
|
334
|
+
const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
|
|
335
|
+
// A truncated listing is a prefix whose boundary moves, so its revision is not
|
|
336
|
+
// comparable with what the page drew: `null` says "cannot tell".
|
|
337
|
+
const names = listing.entries.map((entry) => entry.name);
|
|
338
|
+
return jsonResponse(200, { path: rel, rev: listing.truncated ? null : listingRevision(names) });
|
|
339
|
+
}
|
|
340
|
+
|
|
315
341
|
/** Fuzzy file search for the quick-open palette, over the cached index. */
|
|
316
342
|
async function filesApi(ctx: ServeContext, query: string): Promise<ServeResponse> {
|
|
317
343
|
const index = ctx.index ? await ctx.index() : await buildFileIndex(ctx.root, ctx.config.maxIndexEntries);
|
|
@@ -355,6 +381,7 @@ export async function handleRequest(
|
|
|
355
381
|
if (pathname === THEME_JS_PATH) return { status: 200, headers: scriptHeaders(), body: THEME_JS };
|
|
356
382
|
if (pathname === "/favicon.ico") return { status: 204, headers: pageHeaders(), body: "" };
|
|
357
383
|
if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
|
|
384
|
+
if (pathname === "/api/dir-rev") return await dirRevApi(ctx, search.get("path") ?? "");
|
|
358
385
|
if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
|
|
359
386
|
if (pathname === "/events") return liveEvents(ctx);
|
|
360
387
|
const rawMode = search.get("mode");
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP server for the browse extension.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the pure `handleRequest` in `lib/http`'s loopback server: it resolves the
|
|
5
|
+
* root once, feeds the live status channel, and exposes an idempotent `close()`.
|
|
6
|
+
* The request handler is injectable so tests can avoid a socket when they only
|
|
7
|
+
* need routing.
|
|
8
|
+
*
|
|
9
|
+
* What stays here is this extension's own policy: the request handler, its
|
|
10
|
+
* request count, the catch that turns a thrown handler into a plain-text 500, the
|
|
11
|
+
* cap that refuses a page rather than opening a stream for it, and how long a
|
|
12
|
+
* server with nobody reading it is worth keeping.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { realpathSync } from "node:fs";
|
|
16
|
+
import { type ServeResponse, serveLoopback, textResponse } from "../../../lib/http/index.ts";
|
|
17
|
+
import { IDLE_STOP_MS, type ServeConfig } from "../config/config.ts";
|
|
18
|
+
import { createFileIndex } from "../files/files.ts";
|
|
19
|
+
import type { GitDiffProvider, GitStatusProvider } from "../git/git.ts";
|
|
20
|
+
import { createLiveChannel, createLiveStatus } from "../live/live.ts";
|
|
21
|
+
import { handleRequest, type ServeContext } from "./router.ts";
|
|
22
|
+
|
|
23
|
+
export interface FileServer {
|
|
24
|
+
/** Origin URL, e.g. `http://127.0.0.1:49152/`. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** The bound port. */
|
|
27
|
+
port: number;
|
|
28
|
+
/** Resolved absolute root that is actually served. */
|
|
29
|
+
root: string;
|
|
30
|
+
/** Stop listening. Safe to call more than once or before `listen` settles. */
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FileServerOptions {
|
|
35
|
+
root: string;
|
|
36
|
+
config: ServeConfig;
|
|
37
|
+
/** Read-only git context for the page header; omitted when unavailable. */
|
|
38
|
+
git?: GitStatusProvider;
|
|
39
|
+
/** Read-only diff provider for the change and diff views; omitted when unavailable. */
|
|
40
|
+
diff?: GitDiffProvider;
|
|
41
|
+
/** Called once the last page has been gone for `idleStopMs`; omitted, the server waits for `close()`. */
|
|
42
|
+
onIdle?: () => void;
|
|
43
|
+
/** Grace before `onIdle`, in ms; defaults to `IDLE_STOP_MS`. */
|
|
44
|
+
idleStopMs?: number;
|
|
45
|
+
/** Override the request handler (tests). */
|
|
46
|
+
handler?: typeof handleRequest;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Start a read-only server rooted at `options.root` on 127.0.0.1. */
|
|
50
|
+
export async function createFileServer(options: FileServerOptions): Promise<FileServer> {
|
|
51
|
+
const root = realpathSync.native(options.root);
|
|
52
|
+
const { config } = options;
|
|
53
|
+
// The status feed's two server-owned facts: how long it has been up, and how
|
|
54
|
+
// many requests it has answered. Both are read per frame, never cached.
|
|
55
|
+
let requests = 0;
|
|
56
|
+
const status = config.live
|
|
57
|
+
? createLiveStatus({
|
|
58
|
+
git: options.git,
|
|
59
|
+
diff: config.gitDiff ? options.diff : undefined,
|
|
60
|
+
startedAt: Date.now(),
|
|
61
|
+
requests: () => requests,
|
|
62
|
+
})
|
|
63
|
+
: undefined;
|
|
64
|
+
const live = status ? createLiveChannel({ status, intervalMs: config.liveIntervalMs }) : undefined;
|
|
65
|
+
const context: ServeContext = {
|
|
66
|
+
root,
|
|
67
|
+
config,
|
|
68
|
+
git: options.git,
|
|
69
|
+
diff: options.diff,
|
|
70
|
+
index: createFileIndex(root, config.maxIndexEntries),
|
|
71
|
+
live: status,
|
|
72
|
+
};
|
|
73
|
+
const handler = options.handler ?? handleRequest;
|
|
74
|
+
|
|
75
|
+
const server = await serveLoopback({
|
|
76
|
+
ports: [config.port],
|
|
77
|
+
idle: options.onIdle === undefined ? undefined : { ms: options.idleStopMs ?? IDLE_STOP_MS, onIdle: options.onIdle },
|
|
78
|
+
handler: async (request) => {
|
|
79
|
+
requests += 1;
|
|
80
|
+
let result: ServeResponse;
|
|
81
|
+
try {
|
|
82
|
+
result = await handler(context, request.method, request.url, request.headers);
|
|
83
|
+
} catch {
|
|
84
|
+
return { result: textResponse(500, "Internal error") };
|
|
85
|
+
}
|
|
86
|
+
if (!result.stream || !live || request.method.toUpperCase() === "HEAD") return { result };
|
|
87
|
+
// Checked before the head is written: a refusal must not leave a stream the
|
|
88
|
+
// client would wait on. `204` is the one status an event source does not
|
|
89
|
+
// reconnect after, so a page beyond the cap goes quiet instead of retrying.
|
|
90
|
+
if (live.atCapacity()) {
|
|
91
|
+
return { result: { status: 204, headers: { "Cache-Control": "no-store" }, body: "" } };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
result,
|
|
95
|
+
openSink: (sink) => live.attach({ write: (frame) => sink.write(frame), end: () => sink.end() }),
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
url: `${server.origin}/`,
|
|
102
|
+
port: server.port,
|
|
103
|
+
root,
|
|
104
|
+
close: async () => {
|
|
105
|
+
// End the streams first, so a connected page does not hold the close open.
|
|
106
|
+
live?.close();
|
|
107
|
+
await server.close();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -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/git.ts";
|
|
14
|
+
import type { ChangeIndex } from "../live/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
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* and `bun build --no-bundle` needs no separate asset.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { keysDialogScript, THEME_TOGGLE_JS } from "
|
|
15
|
+
import { keysDialogScript, THEME_TOGGLE_JS } from "../../../lib/web/index.ts";
|
|
16
16
|
import { LIVE_CLIENT_JS } from "./live-client.ts";
|
|
17
17
|
import { PALETTE_CLIENT_JS } from "./palette.ts";
|
|
18
18
|
import { TREE_CLIENT_JS } from "./tree.ts";
|
|
@@ -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();
|