@gavin-hu/my-pi-agent 0.15.0 → 0.15.1
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 +6 -8
- package/README.zh-CN.md +4 -5
- package/extensions/file-browse/README.md +61 -6
- package/extensions/file-browse/client.ts +3 -1
- package/extensions/file-browse/config.ts +15 -0
- package/extensions/file-browse/files.ts +183 -48
- package/extensions/file-browse/fuzzy.ts +65 -11
- package/extensions/file-browse/html.ts +78 -133
- package/extensions/file-browse/live-client.ts +158 -0
- package/extensions/file-browse/live.ts +222 -0
- package/extensions/file-browse/router.ts +33 -8
- package/extensions/file-browse/server.ts +66 -12
- package/extensions/file-browse/styles.ts +124 -0
- package/extensions/plan/web-router.ts +2 -12
- package/extensions/plan/web.ts +9 -3
- package/extensions/rewind/checklist.ts +10 -3
- package/extensions/rewind/git.ts +27 -14
- package/extensions/rewind/hunk-picker.ts +1 -1
- package/extensions/rewind/path-picker.ts +1 -1
- package/extensions/rewind/review-tui.ts +3 -3
- package/extensions/rewind/snapshot.ts +13 -4
- package/extensions/subagent/background-tui.ts +1 -1
- package/extensions/worktree/status.ts +14 -9
- package/lib/README.md +3 -2
- package/lib/git/read.ts +20 -0
- package/lib/http/headers.ts +20 -0
- package/lib/http/server.ts +22 -4
- package/lib/pool.ts +62 -0
- package/lib/tui/dock-screen.ts +12 -4
- package/package.json +1 -1
|
@@ -4,30 +4,22 @@
|
|
|
4
4
|
* Pure string building: the router reads the filesystem and hands these
|
|
5
5
|
* renderers plain view models. Everything user-controlled is escaped, links are
|
|
6
6
|
* percent-encoded per segment, and the only script is the same-origin
|
|
7
|
-
* `/app.js`. The shared Bytefly shell and components come from `lib/web
|
|
8
|
-
* `browse-*` rules
|
|
7
|
+
* `/app.js`. The shared Bytefly shell and components come from `lib/web`, and
|
|
8
|
+
* the page-only `browse-*` rules from `styles.ts`.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
12
|
-
import {
|
|
13
|
-
brandLockup,
|
|
14
|
-
docHead,
|
|
15
|
-
type KeyHint,
|
|
16
|
-
keysSheet,
|
|
17
|
-
SIDEBAR_COLLAPSE_MAX,
|
|
18
|
-
WEB_BASE_CSS,
|
|
19
|
-
WEB_COMPONENTS_CSS,
|
|
20
|
-
WEB_LAYOUT_CSS,
|
|
21
|
-
WEB_THEME_CSS,
|
|
22
|
-
} from "../../lib/web/index.ts";
|
|
12
|
+
import { brandLockup, docHead, type KeyHint, keysSheet } from "../../lib/web/index.ts";
|
|
23
13
|
import { escapeHtml } from "../../lib/escape.ts";
|
|
24
14
|
import { formatSize, type DirEntry, type FileView, type Listing, type TreeNode } from "./files.ts";
|
|
25
15
|
import { DIFF_MODES, statusLetter, type ChangedFile, type DiffLine, type DiffMode } from "./diff.ts";
|
|
26
16
|
import type { DiffFileView, GitStatus } from "./git.ts";
|
|
17
|
+
import type { ChangeIndex } from "./live.ts";
|
|
27
18
|
import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
|
|
28
19
|
import { FOLDER_ICON, iconHref, languageOf, SPRITE, type IconSpec } from "./icons.ts";
|
|
29
20
|
import { renderMarkdown } from "./markdown.ts";
|
|
30
21
|
import { encodePath } from "./paths.ts";
|
|
22
|
+
import { BROWSE_CSS } from "./styles.ts";
|
|
31
23
|
|
|
32
24
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
33
25
|
|
|
@@ -51,17 +43,15 @@ function renderGit(git: GitStatus | undefined): string {
|
|
|
51
43
|
if (!name) return "";
|
|
52
44
|
const glyph = git.branch ? GLYPHS.branch : GLYPHS.detached;
|
|
53
45
|
const aria = git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`;
|
|
54
|
-
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>${escapeHtml(name)}</span>`;
|
|
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>`;
|
|
55
47
|
}
|
|
56
48
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
dirs: ReadonlySet<string>;
|
|
64
|
-
}
|
|
49
|
+
/**
|
|
50
|
+
* The changed-path index for markers, plus the totals shown in the chip. It is
|
|
51
|
+
* the same index the live status feed sends, so the page and its updates agree
|
|
52
|
+
* on what "changed" means.
|
|
53
|
+
*/
|
|
54
|
+
export type ChangeSummary = ChangeIndex;
|
|
65
55
|
|
|
66
56
|
/** Whether a path carries a change marker: a changed file, or a directory above one. */
|
|
67
57
|
function isChanged(summary: ChangeSummary | undefined, rel: string, isDir: boolean): boolean {
|
|
@@ -76,122 +66,59 @@ function changeMark(summary: ChangeSummary | undefined, rel: string, isDir: bool
|
|
|
76
66
|
: "";
|
|
77
67
|
}
|
|
78
68
|
|
|
79
|
-
/** The
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 age in the label is
|
|
80
|
+
* rewritten every second, and a live region would announce each rewrite.
|
|
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;
|
|
83
110
|
const totals =
|
|
84
|
-
|
|
85
|
-
? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${
|
|
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>`
|
|
86
113
|
: "";
|
|
87
|
-
const aria = `${label} against HEAD${
|
|
88
|
-
return `<a class="bf-chip browse-diff" href="/diff" title="View uncommitted changes" aria-label="${escapeHtml(aria)}"><span class="browse-diff__dot" aria-hidden="true">●</span>${escapeHtml(label)}${totals}</a>`;
|
|
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>`;
|
|
89
116
|
}
|
|
90
117
|
|
|
91
118
|
function hrefFor(entry: { rel: string; isDir: boolean }): string {
|
|
92
119
|
return entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`;
|
|
93
120
|
}
|
|
94
121
|
|
|
95
|
-
/** The shared shell, components, and the page-only `browse-*` rules. */
|
|
96
|
-
const STYLES = `${WEB_BASE_CSS}
|
|
97
|
-
${WEB_THEME_CSS}
|
|
98
|
-
${WEB_LAYOUT_CSS}
|
|
99
|
-
${WEB_COMPONENTS_CSS}
|
|
100
|
-
.browse-side{padding:var(--space-3)}
|
|
101
|
-
.browse-root{flex:1 1 auto;direction:rtl;text-align:left}
|
|
102
|
-
.browse-git{font-family:var(--font-mono)}
|
|
103
|
-
.browse-quick-open{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);width:100%;margin-bottom:var(--space-2);padding:6px var(--space-2);border:1px solid var(--border);border-radius:var(--radius-md);background:var(--canvas);color:var(--muted);font:var(--fs-sm) var(--font-ui);cursor:pointer}
|
|
104
|
-
.browse-quick-open:hover{border-color:var(--accent);color:var(--accent)}
|
|
105
|
-
.browse-quick-open kbd{font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--dim)}
|
|
106
|
-
.browse-side__filters{display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)}
|
|
107
|
-
.browse-side__filters .bf-field{flex:1 1 auto;min-width:0}
|
|
108
|
-
.browse-side__filters .bf-btn{flex:0 0 auto;white-space:nowrap}
|
|
109
|
-
.browse-cat{width:16px;height:16px;flex:0 0 16px;color:var(--accent)}
|
|
110
|
-
.browse-cat.doc{color:var(--muted)}.browse-cat.img{color:var(--success)}.browse-cat.cfg{color:var(--warn)}.browse-cat.arc{color:var(--warn)}
|
|
111
|
-
.browse-heading{margin:0 0 var(--space-3);font-size:var(--fs-lg)}
|
|
112
|
-
.browse-listing th.bf-table__size,.browse-listing th.bf-table__when{text-align:right}
|
|
113
|
-
.browse-listing th.bf-table__size{width:96px}
|
|
114
|
-
.browse-listing th.bf-table__when{width:140px}
|
|
115
|
-
.browse-dotfile .bf-table__name a{opacity:.72}
|
|
116
|
-
.browse-broken{color:var(--muted);text-decoration:line-through}
|
|
117
|
-
.browse-link-mark{color:var(--muted);font-size:var(--fs-xs)}
|
|
118
|
-
.browse-when{display:none}
|
|
119
|
-
body.palette-open{overflow:hidden}
|
|
120
|
-
.browse-diff{font-family:var(--font-mono);gap:5px}
|
|
121
|
-
.browse-diff:hover{color:var(--accent);text-decoration:none}
|
|
122
|
-
.browse-diff__dot{color:var(--warn);font-size:9px;line-height:1}
|
|
123
|
-
.browse-change{color:var(--warn);font-size:9px;line-height:1;flex:0 0 auto}
|
|
124
|
-
.browse-diff-totals{font-family:var(--font-mono)}
|
|
125
|
-
.browse-diff-add{color:var(--success)}
|
|
126
|
-
.browse-diff-del{color:var(--danger)}
|
|
127
|
-
.browse-diff-scope{color:var(--muted);font-size:var(--fs-sm);font-weight:var(--fw-medium)}
|
|
128
|
-
.browse-diff-toolbar{display:flex;align-items:center;gap:var(--space-3);margin:0 0 var(--space-3);flex-wrap:wrap}
|
|
129
|
-
.browse-diff-stat{margin:0 0 var(--space-3);font:var(--fs-xs)/1.5 var(--font-mono);color:var(--muted);white-space:pre-wrap;overflow-x:auto}
|
|
130
|
-
.browse-diff th.browse-diff-status{width:72px}
|
|
131
|
-
.browse-diff th.browse-diff-word{width:130px}
|
|
132
|
-
.browse-diff-badge{display:inline-block;min-width:16px;text-align:center;font:var(--fw-semibold) var(--fs-xs) var(--font-mono);border-radius:var(--radius-sm);padding:1px 5px;border:1px solid var(--border)}
|
|
133
|
-
.browse-diff-badge--M{color:var(--warn);border-color:currentColor}
|
|
134
|
-
.browse-diff-badge--A,.browse-diff-badge--Q{color:var(--success);border-color:currentColor}
|
|
135
|
-
.browse-diff-badge--D{color:var(--danger);border-color:currentColor}
|
|
136
|
-
.browse-diff-badge--R,.browse-diff-badge--C{color:var(--accent);border-color:currentColor}
|
|
137
|
-
.browse-diff-orig{color:var(--muted);font-size:var(--fs-xs)}
|
|
138
|
-
.browse-diff-word{color:var(--muted);font-size:var(--fs-sm)}
|
|
139
|
-
.browse-diff-no{display:inline-block;width:46px;padding-right:var(--space-2);text-align:right;color:var(--dim);user-select:none}
|
|
140
|
-
.browse-diff-sign{display:inline-block;width:16px;text-align:center;color:var(--muted);user-select:none}
|
|
141
|
-
.bf-diff-line--add{background:var(--success-soft)}
|
|
142
|
-
.bf-diff-line--add .browse-diff-sign{color:var(--success)}
|
|
143
|
-
.bf-diff-line--del{background:var(--danger-soft)}
|
|
144
|
-
.bf-diff-line--del .browse-diff-sign{color:var(--danger)}
|
|
145
|
-
.bf-diff-line--hunk{color:var(--accent);background:var(--canvas)}
|
|
146
|
-
.bf-diff-line--meta{color:var(--muted)}
|
|
147
|
-
.bf-diff-line--note{color:var(--muted);font-style:italic}
|
|
148
|
-
/* A hunk header pins to the top of the diff viewport while its body scrolls.
|
|
149
|
-
Each hunk is a section so the pinned header is bounded by its own body and
|
|
150
|
-
cannot stack on the next one. */
|
|
151
|
-
.bf-diff-hunk{display:block}
|
|
152
|
-
.bf-diff .bf-diff-line--hunk{position:sticky;top:0;z-index:1}
|
|
153
|
-
/* Reading progress, pinned to the sticky header's bottom edge. */
|
|
154
|
-
.browse-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
|
|
155
|
-
.browse-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
|
|
156
|
-
/* File and diff pages: a measured reading column inside the card. */
|
|
157
|
-
/* The Markdown body fills the card, so the scroll box is the card's width: its
|
|
158
|
-
scrollbar sits at the card edge. The measure lives on the blocks themselves:
|
|
159
|
-
on .bf-md it would be a flex item with auto inline margins, which is
|
|
160
|
-
shrink-to-fit, so a short document collapsed to its widest line. */
|
|
161
|
-
.browse-measure{--browse-measure:1012px}
|
|
162
|
-
.browse-measure .bf-md{--browse-gutter:max(0px,calc((100% - var(--browse-measure)) / 2));max-width:none;margin-inline:0;padding-inline:var(--space-5);padding-block:var(--space-4) var(--space-6)}
|
|
163
|
-
.browse-measure .bf-md>*{max-width:var(--browse-measure);margin-inline:auto}
|
|
164
|
-
/* Tables start at the prose column and take only the gutter to its right, so a
|
|
165
|
-
wide window widens them without a dead band on their left. Code stays inside
|
|
166
|
-
the measure with the rest of the prose. */
|
|
167
|
-
.browse-measure .bf-md>.md-table-frame{max-width:none;padding-left:var(--browse-gutter)}
|
|
168
|
-
/* A table wider than the pane scrolls, but an overlay scrollbar is invisible
|
|
169
|
-
until it moves; a gradient on the frame marks the columns still out of view.
|
|
170
|
-
It has to be the frame: the scroller clips its own content, so a pseudo-element
|
|
171
|
-
inside it would scroll away with the table. */
|
|
172
|
-
.browse-measure .bf-md>.md-table-frame.browse-table-more::after{content:"";position:absolute;top:0;right:0;bottom:0;width:40px;pointer-events:none;background:linear-gradient(to left,var(--surface) 12%,transparent)}
|
|
173
|
-
.bf-card__meta{font-variant-numeric:tabular-nums}
|
|
174
|
-
.browse-diff-summary{font-family:var(--font-mono);white-space:nowrap}
|
|
175
|
-
.browse-diff-summary .browse-diff-add{color:var(--success)}
|
|
176
|
-
.browse-diff-summary .browse-diff-del{color:var(--danger)}
|
|
177
|
-
.browse-hunks{color:var(--muted)}
|
|
178
|
-
/* Wide screens: pin quick open and the filter, and scroll the tree on its own
|
|
179
|
-
box. The sidebar is a details element, whose content sits in an anonymous
|
|
180
|
-
wrapper with no definite height, so the panel fills the sidebar by absolute
|
|
181
|
-
positioning; below the sidebar breakpoint the disclosure keeps normal page
|
|
182
|
-
scroll. */
|
|
183
|
-
@media not (max-width:${SIDEBAR_COLLAPSE_MAX}px){
|
|
184
|
-
.browse-sidebar{position:relative;overflow:hidden}
|
|
185
|
-
.browse-side{position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden}
|
|
186
|
-
.browse-side .bf-tree{flex:1 1 auto;min-height:0;overflow:auto}
|
|
187
|
-
}
|
|
188
|
-
@media (max-width:820px){
|
|
189
|
-
.browse-root{order:9;flex:1 1 100%;direction:ltr;text-align:left}
|
|
190
|
-
}
|
|
191
|
-
@media (max-width:520px){
|
|
192
|
-
.bf-toolbar .bf-theme-toggle{margin-left:auto}
|
|
193
|
-
}`;
|
|
194
|
-
|
|
195
122
|
interface Layout {
|
|
196
123
|
title: string;
|
|
197
124
|
rootLabel: string;
|
|
@@ -207,6 +134,8 @@ interface Layout {
|
|
|
207
134
|
git?: GitStatus;
|
|
208
135
|
/** Changed-path index for the header chip and the tree markers; omitted when clean. */
|
|
209
136
|
summary?: ChangeSummary;
|
|
137
|
+
/** Live status chrome; omitted when the channel is off. */
|
|
138
|
+
live?: LivePage;
|
|
210
139
|
}
|
|
211
140
|
|
|
212
141
|
/** A full two-pane page. */
|
|
@@ -214,7 +143,7 @@ function renderLayout(layout: Layout): string {
|
|
|
214
143
|
return `<!doctype html>
|
|
215
144
|
<html lang="en">
|
|
216
145
|
<head>
|
|
217
|
-
${docHead({ title: layout.title, css:
|
|
146
|
+
${docHead({ title: layout.title, css: BROWSE_CSS })}
|
|
218
147
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%2358a6ff'/%3E%3C/svg%3E">
|
|
219
148
|
</head>
|
|
220
149
|
<body class="bf-app">
|
|
@@ -226,8 +155,9 @@ ${SPRITE}
|
|
|
226
155
|
${brandLockup("browse")}
|
|
227
156
|
<span class="bf-context browse-root">${escapeHtml(layout.rootLabel)}</span>
|
|
228
157
|
${renderGit(layout.git)}
|
|
229
|
-
${renderChanges(layout.summary)}
|
|
158
|
+
${renderChanges(layout.summary, layout.live)}
|
|
230
159
|
<span class="bf-chip bf-chip--accent">read-only</span>
|
|
160
|
+
${renderLiveChip(layout.live)}
|
|
231
161
|
<button type="button" class="bf-btn bf-btn--icon browse-keys-btn" id="keys-open" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
|
|
232
162
|
<button type="button" class="bf-btn bf-btn--icon bf-theme-toggle" id="theme-toggle" aria-label="Toggle light and dark theme" title="Toggle light and dark theme"><span class="theme-glyph" aria-hidden="true">☾</span></button>
|
|
233
163
|
</div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
|
|
@@ -249,7 +179,7 @@ ${SPRITE}
|
|
|
249
179
|
${layout.main}
|
|
250
180
|
</main>
|
|
251
181
|
</div>
|
|
252
|
-
<footer class="bf-app__footer"
|
|
182
|
+
<footer class="bf-app__footer">${layout.live ? renderLiveStatus() : ""}served from ${escapeHtml(layout.rootLabel)} · read-only · ${escapeHtml(layout.footerNote)}</footer>
|
|
253
183
|
${layout.keys && layout.keys.length > 0 ? keysSheet(layout.keys) : ""}
|
|
254
184
|
<script src="/app.js" defer></script>
|
|
255
185
|
</body>
|
|
@@ -326,6 +256,8 @@ export interface DirectoryPage {
|
|
|
326
256
|
git?: GitStatus;
|
|
327
257
|
/** Changed-path index for the header chip and markers. */
|
|
328
258
|
summary?: ChangeSummary;
|
|
259
|
+
/** Live status chrome; omitted when the channel is off. */
|
|
260
|
+
live?: LivePage;
|
|
329
261
|
}
|
|
330
262
|
|
|
331
263
|
/** Render a directory listing page. */
|
|
@@ -354,6 +286,7 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
|
|
|
354
286
|
footerNote: `${page.listing.total} entr${page.listing.total === 1 ? "y" : "ies"}`,
|
|
355
287
|
git: page.git,
|
|
356
288
|
summary: page.summary,
|
|
289
|
+
live: page.live,
|
|
357
290
|
});
|
|
358
291
|
}
|
|
359
292
|
|
|
@@ -372,6 +305,8 @@ export interface FilePage {
|
|
|
372
305
|
changed?: boolean;
|
|
373
306
|
/** Changed-path index for the header chip and the tree markers. */
|
|
374
307
|
summary?: ChangeSummary;
|
|
308
|
+
/** Live status chrome; omitted when the channel is off. */
|
|
309
|
+
live?: LivePage;
|
|
375
310
|
}
|
|
376
311
|
|
|
377
312
|
/** The root-relative directory of a file, for resolving its Markdown links. */
|
|
@@ -490,6 +425,7 @@ export function renderFilePage(page: FilePage): string {
|
|
|
490
425
|
footerNote: page.rel,
|
|
491
426
|
git: page.git,
|
|
492
427
|
summary: page.summary,
|
|
428
|
+
live: page.live,
|
|
493
429
|
});
|
|
494
430
|
}
|
|
495
431
|
|
|
@@ -614,6 +550,8 @@ export interface DiffPage {
|
|
|
614
550
|
tree: TreeNode;
|
|
615
551
|
git?: GitStatus;
|
|
616
552
|
summary?: ChangeSummary;
|
|
553
|
+
/** Live status chrome; omitted when the channel is off. */
|
|
554
|
+
live?: LivePage;
|
|
617
555
|
}
|
|
618
556
|
|
|
619
557
|
/** The change overview: `git diff --stat` plus every changed path. */
|
|
@@ -641,6 +579,7 @@ ${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
|
|
|
641
579
|
footerNote: `${page.files.length} changed`,
|
|
642
580
|
git: page.git,
|
|
643
581
|
summary: page.summary,
|
|
582
|
+
live: page.live,
|
|
644
583
|
});
|
|
645
584
|
}
|
|
646
585
|
|
|
@@ -661,6 +600,8 @@ export interface FileDiffPage {
|
|
|
661
600
|
tree: TreeNode;
|
|
662
601
|
git?: GitStatus;
|
|
663
602
|
summary?: ChangeSummary;
|
|
603
|
+
/** Live status chrome; omitted when the channel is off. */
|
|
604
|
+
live?: LivePage;
|
|
664
605
|
}
|
|
665
606
|
|
|
666
607
|
/** A single file's unified diff, with the scope control and file actions. */
|
|
@@ -710,6 +651,7 @@ export function renderFileDiffPage(page: FileDiffPage): string {
|
|
|
710
651
|
footerNote: `diff · ${modeScope(page.mode)}`,
|
|
711
652
|
git: page.git,
|
|
712
653
|
summary: page.summary,
|
|
654
|
+
live: page.live,
|
|
713
655
|
});
|
|
714
656
|
}
|
|
715
657
|
|
|
@@ -719,6 +661,8 @@ export interface ErrorPage {
|
|
|
719
661
|
message: string;
|
|
720
662
|
tree: TreeNode;
|
|
721
663
|
git?: GitStatus;
|
|
664
|
+
/** Live status chrome; omitted when the channel is off. */
|
|
665
|
+
live?: LivePage;
|
|
722
666
|
}
|
|
723
667
|
|
|
724
668
|
/** Render a status/error page with the same chrome. */
|
|
@@ -732,5 +676,6 @@ export function renderErrorPage(page: ErrorPage): string {
|
|
|
732
676
|
main: ` <div class="bf-empty"><div class="bf-empty__code">${page.status}</div><p class="bf-empty__title">${escapeHtml(page.message)}</p><p class="bf-empty__sub"><a href="/browse/">back to the served folder</a></p></div>`,
|
|
733
677
|
footerNote: "error",
|
|
734
678
|
git: page.git,
|
|
679
|
+
live: page.live,
|
|
735
680
|
});
|
|
736
681
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live status block of the browse client script.
|
|
3
|
+
*
|
|
4
|
+
* It is a fragment of the same IIFE `client.ts` ships (`LIVE_CLIENT_JS` is
|
|
5
|
+
* interpolated into `CLIENT_JS`), kept in its own module so the client script
|
|
6
|
+
* stays one readable unit per concern. Everything it paints is text it was
|
|
7
|
+
* handed — a branch name, a path, a count — so a repository cannot inject markup
|
|
8
|
+
* into the page through a status frame.
|
|
9
|
+
*
|
|
10
|
+
* It does nothing at all unless the server rendered the connection chip, which
|
|
11
|
+
* is what makes `live: false` and JS-off leave the page exactly as it was.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const LIVE_CLIENT_JS = ` // Live status: one event stream feeds the header chips, the tree and listing
|
|
15
|
+
// markers, and the footer line. The server owns the cadence; this only paints.
|
|
16
|
+
var liveChip = document.getElementById("live-state");
|
|
17
|
+
if (liveChip) {
|
|
18
|
+
var liveText = liveChip.querySelector(".browse-live__text");
|
|
19
|
+
var liveLine = document.getElementById("live-status");
|
|
20
|
+
var liveGit = document.querySelector(".browse-git");
|
|
21
|
+
var liveChanges = document.getElementById("browse-changes");
|
|
22
|
+
var liveInterval = Number(liveChip.getAttribute("data-interval")) || 3000;
|
|
23
|
+
var liveReceived = 0;
|
|
24
|
+
var liveTone = "";
|
|
25
|
+
|
|
26
|
+
function formatSpan(ms) {
|
|
27
|
+
var seconds = Math.max(0, Math.round(ms / 1000));
|
|
28
|
+
if (seconds < 60) return seconds + "s";
|
|
29
|
+
var minutes = Math.floor(seconds / 60);
|
|
30
|
+
if (minutes < 60) return minutes + "m " + String(seconds % 60).padStart(2, "0") + "s";
|
|
31
|
+
return Math.floor(minutes / 60) + "h " + String(minutes % 60).padStart(2, "0") + "m";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setLiveTone(tone, label) {
|
|
35
|
+
liveTone = tone;
|
|
36
|
+
liveChip.className = "bf-chip browse-live" + (tone ? " bf-chip--" + tone : "");
|
|
37
|
+
liveChip.hidden = false;
|
|
38
|
+
if (liveText) liveText.textContent = label;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The age is the local elapsed time since the last frame, so it stays right
|
|
42
|
+
// even when the loopback server's clock and this browser's disagree. Three
|
|
43
|
+
// missed frames on an open connection is a stalled feed, not a live one.
|
|
44
|
+
function paintLiveAge() {
|
|
45
|
+
if (!liveText || (liveTone !== "success" && liveTone !== "warn")) return;
|
|
46
|
+
var idle = Date.now() - liveReceived;
|
|
47
|
+
if (idle > liveInterval * 3) setLiveTone("warn", "stale \\u00B7 " + formatSpan(idle));
|
|
48
|
+
else liveText.textContent = "live \\u00B7 " + formatSpan(idle);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function paintLiveGit(git) {
|
|
52
|
+
if (!liveGit || !git) return;
|
|
53
|
+
var name = git.branch || git.head || "";
|
|
54
|
+
if (!name) return;
|
|
55
|
+
var aria = git.branch ? "git branch " + git.branch : "git detached at " + git.head;
|
|
56
|
+
liveGit.title = aria;
|
|
57
|
+
liveGit.setAttribute("aria-label", aria);
|
|
58
|
+
var glyph = liveGit.querySelector(".browse-git__glyph");
|
|
59
|
+
if (glyph) glyph.textContent = git.branch ? "\\u2387" : "\\u26A0";
|
|
60
|
+
var label = liveGit.querySelector(".browse-git__name");
|
|
61
|
+
if (label) label.textContent = name;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function paintLiveMarks(changes) {
|
|
65
|
+
var files = {};
|
|
66
|
+
var dirs = {};
|
|
67
|
+
((changes && changes.files) || []).forEach(function (path) { files[path] = true; });
|
|
68
|
+
((changes && changes.dirs) || []).forEach(function (path) { dirs[path] = true; });
|
|
69
|
+
var rows = document.querySelectorAll(".browse-listing tbody tr[data-path], nav.bf-tree .bf-tree__row[data-path]");
|
|
70
|
+
Array.prototype.forEach.call(rows, function (row) {
|
|
71
|
+
var path = row.getAttribute("data-path");
|
|
72
|
+
var inTree = row.parentElement && row.parentElement.tagName === "LI";
|
|
73
|
+
var isDir = inTree ? !!row.querySelector(".bf-tree__tw--toggle") : row.getAttribute("data-dir") === "1";
|
|
74
|
+
var marked = isDir ? dirs[path] === true : files[path] === true;
|
|
75
|
+
var mark = row.querySelector(".browse-change");
|
|
76
|
+
if (marked && !mark) {
|
|
77
|
+
mark = document.createElement("span");
|
|
78
|
+
mark.className = "browse-change";
|
|
79
|
+
mark.title = "uncommitted changes";
|
|
80
|
+
mark.setAttribute("aria-label", "uncommitted changes");
|
|
81
|
+
mark.textContent = "\\u25CF";
|
|
82
|
+
var name = row.querySelector(".bf-table__name");
|
|
83
|
+
(name || row).appendChild(mark);
|
|
84
|
+
} else if (!marked && mark && mark.parentNode) {
|
|
85
|
+
mark.parentNode.removeChild(mark);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function paintLiveChanges(changes) {
|
|
91
|
+
var count = changes ? changes.count : 0;
|
|
92
|
+
if (liveChanges) {
|
|
93
|
+
liveChanges.hidden = count <= 0;
|
|
94
|
+
if (count > 0) {
|
|
95
|
+
var label = count + (count === 1 ? " change" : " changes");
|
|
96
|
+
var totals = (changes.insertions || 0) + (changes.deletions || 0);
|
|
97
|
+
liveChanges.textContent = "";
|
|
98
|
+
var dot = document.createElement("span");
|
|
99
|
+
dot.className = "browse-diff__dot";
|
|
100
|
+
dot.setAttribute("aria-hidden", "true");
|
|
101
|
+
dot.textContent = "\\u25CF";
|
|
102
|
+
liveChanges.appendChild(dot);
|
|
103
|
+
liveChanges.appendChild(document.createTextNode(label));
|
|
104
|
+
if (totals > 0) {
|
|
105
|
+
var sum = document.createElement("span");
|
|
106
|
+
sum.className = "browse-diff-totals";
|
|
107
|
+
var add = document.createElement("span");
|
|
108
|
+
add.className = "browse-diff-add";
|
|
109
|
+
add.textContent = "+" + changes.insertions;
|
|
110
|
+
var del = document.createElement("span");
|
|
111
|
+
del.className = "browse-diff-del";
|
|
112
|
+
del.textContent = "\\u2212" + changes.deletions;
|
|
113
|
+
sum.appendChild(add);
|
|
114
|
+
sum.appendChild(document.createTextNode(" "));
|
|
115
|
+
sum.appendChild(del);
|
|
116
|
+
liveChanges.appendChild(sum);
|
|
117
|
+
}
|
|
118
|
+
var aria = label + " against HEAD";
|
|
119
|
+
if (totals > 0) aria += ", " + changes.insertions + " insertions and " + changes.deletions + " deletions";
|
|
120
|
+
liveChanges.setAttribute("aria-label", aria);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
paintLiveMarks(changes);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function paintLiveSnapshot(snapshot) {
|
|
127
|
+
liveReceived = Date.now();
|
|
128
|
+
setLiveTone("success", "live \\u00B7 0s");
|
|
129
|
+
if (liveLine) {
|
|
130
|
+
liveLine.hidden = false;
|
|
131
|
+
var requests = Number(snapshot.requests) || 0;
|
|
132
|
+
liveLine.textContent = "up " + formatSpan(Number(snapshot.uptimeMs) || 0) + " \\u00B7 " + requests + (requests === 1 ? " request" : " requests");
|
|
133
|
+
}
|
|
134
|
+
paintLiveGit(snapshot.git);
|
|
135
|
+
paintLiveChanges(snapshot.changes);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (window.EventSource) {
|
|
139
|
+
var liveStream = new EventSource("/events");
|
|
140
|
+
var liveTicker = window.setInterval(paintLiveAge, 1000);
|
|
141
|
+
liveStream.addEventListener("open", function () {
|
|
142
|
+
if (liveTone !== "success") setLiveTone("accent", "connecting");
|
|
143
|
+
});
|
|
144
|
+
liveStream.addEventListener("status", function (event) {
|
|
145
|
+
var snapshot;
|
|
146
|
+
try { snapshot = JSON.parse(event.data); } catch (error) { return; }
|
|
147
|
+
paintLiveSnapshot(snapshot);
|
|
148
|
+
});
|
|
149
|
+
liveStream.addEventListener("error", function () {
|
|
150
|
+
setLiveTone("danger", "offline");
|
|
151
|
+
});
|
|
152
|
+
window.addEventListener("pagehide", function () {
|
|
153
|
+
liveStream.close();
|
|
154
|
+
window.clearInterval(liveTicker);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
`;
|