@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.
@@ -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`; the
8
- * `browse-*` rules below are the page-only layer.
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
- /** The changed-path index for markers, plus the totals shown in the chip. */
58
- export interface ChangeSummary {
59
- count: number;
60
- insertions: number;
61
- deletions: number;
62
- files: ReadonlySet<string>;
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 header's changes chip, linking to the diff overview when there are changes. */
80
- function renderChanges(summary: ChangeSummary | undefined): string {
81
- if (!summary || summary.count <= 0) return "";
82
- const label = `${summary.count} change${summary.count === 1 ? "" : "s"}`;
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
- summary.insertions + summary.deletions > 0
85
- ? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${summary.insertions}</span> <span class="browse-diff-del">−${summary.deletions}</span></span>`
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${summary.insertions + summary.deletions > 0 ? `, ${summary.insertions} insertions and ${summary.deletions} deletions` : ""}`;
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: STYLES })}
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">served from ${escapeHtml(layout.rootLabel)} · read-only · ${escapeHtml(layout.footerNote)}</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
+ `;