@gavin-hu/my-pi-agent 0.8.0 → 0.9.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 +9 -8
- package/README.zh-CN.md +8 -8
- package/assets/file-browse.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +49 -36
- package/extensions/bytefly/doctor/format.ts +36 -10
- package/extensions/bytefly/doctor/tools.ts +6 -5
- package/extensions/bytefly/footer/config.ts +1 -1
- package/extensions/bytefly/footer/render/format.ts +5 -18
- package/extensions/bytefly/footer/render/layout.ts +2 -1
- package/extensions/bytefly/footer/render/segments.ts +51 -78
- package/extensions/bytefly/header/component.ts +33 -16
- package/extensions/bytefly/header/config.ts +28 -18
- package/extensions/bytefly/index.ts +1 -1
- package/extensions/bytefly/panel/commands.ts +69 -35
- package/extensions/bytefly/panel/format.ts +8 -8
- package/extensions/bytefly/panel/tui.ts +45 -70
- package/extensions/bytefly/separator/config.ts +16 -8
- package/extensions/bytefly/separator/format.ts +13 -40
- package/extensions/bytefly/separator/types.ts +0 -10
- package/extensions/document/README.md +1 -1
- package/extensions/document/render.ts +11 -10
- package/extensions/document/tool.ts +1 -1
- package/extensions/file-browse/README.md +62 -14
- package/extensions/file-browse/client.ts +103 -2
- package/extensions/file-browse/config.ts +12 -0
- package/extensions/file-browse/diff.ts +187 -0
- package/extensions/file-browse/git.ts +176 -1
- package/extensions/file-browse/html.ts +412 -23
- package/extensions/file-browse/index.ts +15 -4
- package/extensions/file-browse/markdown.ts +98 -5
- package/extensions/file-browse/router.ts +94 -2
- package/extensions/file-browse/server.ts +4 -1
- package/extensions/goal/format.ts +11 -10
- package/extensions/goal/tools.ts +6 -5
- package/extensions/goal/tui.ts +1 -1
- package/extensions/job/README.md +2 -2
- package/extensions/job/format.ts +36 -13
- package/extensions/job/index.ts +1 -1
- package/extensions/job/tools.ts +11 -10
- package/extensions/job/tui.ts +141 -220
- package/extensions/job/ui.ts +24 -49
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/tools.ts +4 -3
- package/extensions/memory/tui.ts +32 -36
- package/extensions/plan/README.md +64 -34
- package/extensions/plan/blocks.ts +44 -2
- package/extensions/plan/feedback.ts +20 -5
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/runtime.ts +3 -2
- package/extensions/plan/tools.ts +23 -22
- package/extensions/plan/tui.ts +21 -27
- package/extensions/plan/web-client.ts +335 -30
- package/extensions/plan/web-page.ts +110 -30
- package/extensions/plan/web-router.ts +6 -4
- package/extensions/plan/web.ts +41 -10
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +12 -11
- package/extensions/question/tui.ts +132 -76
- package/extensions/rewind/README.md +1 -1
- package/extensions/rewind/format.ts +29 -1
- package/extensions/rewind/rewind-tui.ts +66 -62
- package/extensions/rewind/status.ts +3 -2
- package/extensions/subagent/render.ts +62 -47
- package/extensions/todo/README.md +2 -2
- package/extensions/todo/format.ts +31 -10
- package/extensions/todo/tools.ts +5 -4
- package/extensions/todo/tui.ts +45 -26
- package/extensions/watch/README.md +2 -2
- package/extensions/watch/format.ts +33 -13
- package/extensions/watch/index.ts +1 -1
- package/extensions/watch/tools.ts +12 -7
- package/extensions/watch/tui.ts +135 -194
- package/extensions/watch/ui.ts +24 -46
- package/extensions/web-access/fetch/tool.ts +12 -11
- package/extensions/web-access/search/tool.ts +16 -13
- package/extensions/wechat/README.md +2 -2
- package/extensions/wechat/index.ts +4 -3
- package/extensions/wechat/login-screen.ts +8 -5
- package/extensions/wechat/status-screen.ts +41 -22
- package/extensions/wechat/tools.ts +6 -5
- package/extensions/worktree/enter.ts +1 -1
- package/extensions/worktree/index.ts +1 -1
- package/extensions/worktree/runtime.ts +1 -1
- package/lib/README.md +10 -7
- package/lib/requirements.ts +2 -2
- package/lib/{tui.ts → tui/chrome.ts} +9 -30
- package/lib/tui/columns.ts +96 -0
- package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
- package/lib/tui/gauge.ts +78 -0
- package/lib/tui/index.ts +25 -0
- package/lib/tui/line-pane.ts +173 -0
- package/lib/tui/rows.ts +109 -0
- package/lib/tui/rules.ts +88 -0
- package/lib/tui/status-controller.ts +107 -0
- package/lib/tui/tokens.ts +58 -0
- package/package.json +2 -2
- /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* `browse-*` rules below are the page-only layer.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { GLYPHS } from "../../lib/
|
|
11
|
+
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
12
12
|
import {
|
|
13
13
|
brandLockup,
|
|
14
14
|
docHead,
|
|
@@ -19,10 +19,11 @@ import {
|
|
|
19
19
|
} from "../../lib/web/index.ts";
|
|
20
20
|
import { escapeHtml } from "../../lib/escape.ts";
|
|
21
21
|
import { formatSize, type DirEntry, type FileView, type Listing, type TreeNode } from "./files.ts";
|
|
22
|
-
import type
|
|
22
|
+
import { DIFF_MODES, statusLetter, type ChangedFile, type DiffLine, type DiffMode } from "./diff.ts";
|
|
23
|
+
import type { DiffFileView, GitStatus } from "./git.ts";
|
|
23
24
|
import { detectLanguage, highlightLines, type HighlightLanguage } from "../../lib/highlight.ts";
|
|
24
25
|
import { FOLDER_ICON, iconHref, languageOf, SPRITE, type IconSpec } from "./icons.ts";
|
|
25
|
-
import {
|
|
26
|
+
import { renderMarkdownDocument, type MarkdownHeading } from "./markdown.ts";
|
|
26
27
|
import { encodePath } from "./paths.ts";
|
|
27
28
|
|
|
28
29
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
@@ -51,6 +52,40 @@ function renderGit(git: GitStatus | undefined): string {
|
|
|
51
52
|
return `<span class="bf-chip browse-git${git.dirty ? " browse-git--dirty" : ""}" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span>${escapeHtml(name)}${dot}</span>`;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/** The changed-path index for markers, plus the totals shown in the chip. */
|
|
56
|
+
export interface ChangeSummary {
|
|
57
|
+
count: number;
|
|
58
|
+
insertions: number;
|
|
59
|
+
deletions: number;
|
|
60
|
+
files: ReadonlySet<string>;
|
|
61
|
+
dirs: ReadonlySet<string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether a path carries a change marker: a changed file, or a directory above one. */
|
|
65
|
+
export function isChanged(summary: ChangeSummary | undefined, rel: string, isDir: boolean): boolean {
|
|
66
|
+
if (!summary || !rel) return false;
|
|
67
|
+
return isDir ? summary.dirs.has(rel) : summary.files.has(rel);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A small dot marking a changed file or a directory that contains one. */
|
|
71
|
+
function changeMark(summary: ChangeSummary | undefined, rel: string, isDir: boolean): string {
|
|
72
|
+
return isChanged(summary, rel, isDir)
|
|
73
|
+
? `<span class="browse-change" title="uncommitted changes" aria-label="uncommitted changes">●</span>`
|
|
74
|
+
: "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The header's changes chip, linking to the diff overview when there are changes. */
|
|
78
|
+
function renderChanges(summary: ChangeSummary | undefined): string {
|
|
79
|
+
if (!summary || summary.count <= 0) return "";
|
|
80
|
+
const label = `${summary.count} change${summary.count === 1 ? "" : "s"}`;
|
|
81
|
+
const totals =
|
|
82
|
+
summary.insertions + summary.deletions > 0
|
|
83
|
+
? ` <span class="browse-diff-totals"><span class="browse-diff-add">+${summary.insertions}</span> <span class="browse-diff-del">−${summary.deletions}</span></span>`
|
|
84
|
+
: "";
|
|
85
|
+
const aria = `${label} against HEAD${summary.insertions + summary.deletions > 0 ? `, ${summary.insertions} insertions and ${summary.deletions} deletions` : ""}`;
|
|
86
|
+
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>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
54
89
|
function hrefFor(entry: { rel: string; isDir: boolean }): string {
|
|
55
90
|
return entry.isDir ? `/browse/${encodePath(entry.rel)}` : `/view/${encodePath(entry.rel)}`;
|
|
56
91
|
}
|
|
@@ -78,11 +113,75 @@ ${WEB_COMPONENTS_CSS}
|
|
|
78
113
|
.browse-broken{color:var(--muted);text-decoration:line-through}
|
|
79
114
|
.browse-link-mark{color:var(--muted);font-size:var(--fs-xs)}
|
|
80
115
|
.browse-when{display:none}
|
|
116
|
+
.browse-md-outline{max-width:80ch;margin:0 0 var(--space-3) var(--space-4);border:1px solid var(--border);border-radius:var(--radius-md);font-size:var(--fs-sm)}
|
|
117
|
+
.browse-md-outline>summary{padding:var(--space-2) var(--space-3);cursor:pointer;color:var(--muted);font-family:var(--font-mono);font-size:var(--fs-xs)}
|
|
118
|
+
.browse-md-outline ul{list-style:none;margin:0;padding:0 var(--space-3) var(--space-2)}
|
|
119
|
+
.browse-md-outline a{display:block;padding:2px 0}
|
|
120
|
+
.browse-md-outline a.lvl1{padding-left:var(--space-3)}
|
|
121
|
+
.browse-md-outline a.lvl2{padding-left:var(--space-5)}
|
|
122
|
+
.browse-md-outline a.lvl3{padding-left:var(--space-7)}
|
|
81
123
|
body.palette-open{overflow:hidden}
|
|
124
|
+
.browse-diff{font-family:var(--font-mono);gap:5px}
|
|
125
|
+
.browse-diff:hover{color:var(--accent);text-decoration:none}
|
|
126
|
+
.browse-diff__dot{color:var(--warn);font-size:9px;line-height:1}
|
|
127
|
+
.browse-change{color:var(--warn);font-size:9px;line-height:1;flex:0 0 auto}
|
|
128
|
+
.browse-diff-totals{font-family:var(--font-mono)}
|
|
129
|
+
.browse-diff-add{color:var(--success)}
|
|
130
|
+
.browse-diff-del{color:var(--danger)}
|
|
131
|
+
.browse-diff-scope{color:var(--muted);font-size:var(--fs-sm);font-weight:var(--fw-medium)}
|
|
132
|
+
.browse-diff-toolbar{display:flex;align-items:center;gap:var(--space-3);margin:0 0 var(--space-3);flex-wrap:wrap}
|
|
133
|
+
.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}
|
|
134
|
+
.browse-diff th.browse-diff-status{width:72px}
|
|
135
|
+
.browse-diff th.browse-diff-word{width:130px}
|
|
136
|
+
.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)}
|
|
137
|
+
.browse-diff-badge--M{color:var(--warn);border-color:currentColor}
|
|
138
|
+
.browse-diff-badge--A,.browse-diff-badge--Q{color:var(--success);border-color:currentColor}
|
|
139
|
+
.browse-diff-badge--D{color:var(--danger);border-color:currentColor}
|
|
140
|
+
.browse-diff-badge--R,.browse-diff-badge--C{color:var(--accent);border-color:currentColor}
|
|
141
|
+
.browse-diff-orig{color:var(--muted);font-size:var(--fs-xs)}
|
|
142
|
+
.browse-diff-word{color:var(--muted);font-size:var(--fs-sm)}
|
|
143
|
+
.browse-diff-no{display:inline-block;width:46px;padding-right:var(--space-2);text-align:right;color:var(--dim);user-select:none}
|
|
144
|
+
.browse-diff-sign{display:inline-block;width:16px;text-align:center;color:var(--muted);user-select:none}
|
|
145
|
+
.bf-diff-line--add{background:var(--success-soft)}
|
|
146
|
+
.bf-diff-line--add .browse-diff-sign{color:var(--success)}
|
|
147
|
+
.bf-diff-line--del{background:var(--danger-soft)}
|
|
148
|
+
.bf-diff-line--del .browse-diff-sign{color:var(--danger)}
|
|
149
|
+
.bf-diff-line--hunk{color:var(--accent);background:var(--canvas)}
|
|
150
|
+
.bf-diff-line--meta{color:var(--muted)}
|
|
151
|
+
.bf-diff-line--note{color:var(--muted);font-style:italic}
|
|
152
|
+
/* A hunk header pins to the top of the diff viewport while its body scrolls. */
|
|
153
|
+
.bf-diff .bf-diff-line--hunk{position:sticky;top:0;z-index:1}
|
|
154
|
+
/* Reading progress, pinned to the sticky header's bottom edge. */
|
|
155
|
+
.browse-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
|
|
156
|
+
.browse-progress__fill{display:block;height:100%;width:0;background:var(--accent);transition:width var(--motion-fast) linear}
|
|
157
|
+
/* File and diff pages: a measured reading column inside the card. */
|
|
158
|
+
/* GitHub's markdown body width: one content column that headings, prose,
|
|
159
|
+
tables, code, and images all live in, with wide tables scrolling inside it. */
|
|
160
|
+
.browse-measure{--browse-measure:1012px}
|
|
161
|
+
.browse-measure .bf-md{max-width:var(--browse-measure);margin-inline:auto;padding-inline:0;padding-block:var(--space-4) var(--space-6)}
|
|
162
|
+
.browse-measure .browse-md-outline{max-width:var(--browse-measure);margin:0 auto var(--space-3)}
|
|
163
|
+
.bf-card__meta{font-variant-numeric:tabular-nums}
|
|
164
|
+
.browse-diff-summary{font-family:var(--font-mono);white-space:nowrap}
|
|
165
|
+
.browse-diff-summary .browse-diff-add{color:var(--success)}
|
|
166
|
+
.browse-diff-summary .browse-diff-del{color:var(--danger)}
|
|
167
|
+
.browse-hunks{color:var(--muted)}
|
|
168
|
+
.browse-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
|
|
169
|
+
.browse-keys dt{white-space:nowrap}
|
|
170
|
+
.browse-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
|
|
171
|
+
.browse-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
|
|
172
|
+
@media (max-width:820px){
|
|
173
|
+
.browse-root{order:9;flex:1 1 100%;direction:ltr;text-align:left}
|
|
174
|
+
}
|
|
82
175
|
@media (max-width:520px){
|
|
83
176
|
.bf-toolbar .bf-theme-toggle{margin-left:auto}
|
|
84
177
|
}`;
|
|
85
178
|
|
|
179
|
+
/** One row of the keyboard sheet: the key or chord, and what it does. */
|
|
180
|
+
export interface KeyHint {
|
|
181
|
+
keys: string;
|
|
182
|
+
label: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
86
185
|
interface Layout {
|
|
87
186
|
title: string;
|
|
88
187
|
rootLabel: string;
|
|
@@ -91,7 +190,29 @@ interface Layout {
|
|
|
91
190
|
breadcrumbs: string;
|
|
92
191
|
main: string;
|
|
93
192
|
footerNote: string;
|
|
193
|
+
/** Extra classes on `main`, for page-scoped rules. */
|
|
194
|
+
mainClass?: string;
|
|
195
|
+
/** Rows for the `?` keyboard sheet; omitted when the page has no shortcuts. */
|
|
196
|
+
keys?: KeyHint[];
|
|
94
197
|
git?: GitStatus;
|
|
198
|
+
/** Changed-path index for the header chip and the tree markers; omitted when clean. */
|
|
199
|
+
summary?: ChangeSummary;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The `?` keyboard sheet, hidden until the client opens it. */
|
|
203
|
+
function renderKeysSheet(keys: KeyHint[]): string {
|
|
204
|
+
const rows = keys
|
|
205
|
+
.map(
|
|
206
|
+
(key) =>
|
|
207
|
+
`<div><dt><kbd class="browse-kbd">${escapeHtml(key.keys)}</kbd></dt><dd>${escapeHtml(key.label)}</dd></div>`,
|
|
208
|
+
)
|
|
209
|
+
.join("");
|
|
210
|
+
return `<div class="bf-dialog" id="keys-dialog" hidden>
|
|
211
|
+
<div class="bf-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="keys-title">
|
|
212
|
+
<div class="bf-dialog__head"><h2 id="keys-title">Keyboard shortcuts</h2><span class="bf-spacer"></span><button type="button" class="bf-btn" id="keys-close">Close</button></div>
|
|
213
|
+
<dl class="browse-keys">${rows}</dl>
|
|
214
|
+
</div>
|
|
215
|
+
</div>`;
|
|
95
216
|
}
|
|
96
217
|
|
|
97
218
|
/** A full two-pane page. */
|
|
@@ -111,25 +232,28 @@ ${SPRITE}
|
|
|
111
232
|
${brandLockup("browse")}
|
|
112
233
|
<span class="bf-context browse-root">${escapeHtml(layout.rootLabel)}</span>
|
|
113
234
|
${renderGit(layout.git)}
|
|
235
|
+
${renderChanges(layout.summary)}
|
|
114
236
|
<span class="bf-chip bf-chip--accent">read-only</span>
|
|
237
|
+
<button type="button" class="bf-btn bf-btn--icon browse-keys-btn" id="keys-open" aria-label="Keyboard shortcuts" title="Keyboard shortcuts">?</button>
|
|
115
238
|
<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>
|
|
116
|
-
</div></header>
|
|
239
|
+
</div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
|
|
117
240
|
<div class="bf-app__body">
|
|
118
241
|
<details class="bf-app__sidebar" open>
|
|
119
242
|
<summary class="bf-panel__summary">Files</summary>
|
|
120
243
|
<aside class="bf-panel browse-side">
|
|
121
244
|
<button type="button" class="browse-quick-open" id="quick-open" aria-haspopup="dialog"><span>quick open</span><kbd>/</kbd></button>
|
|
122
245
|
<label class="bf-field">⌕ <input class="bf-input" id="filter" type="search" placeholder="filter paths…" aria-label="Filter paths"></label>
|
|
123
|
-
<nav class="bf-tree" aria-label="File tree">${renderTree(layout.tree, layout.currentRel)}</nav>
|
|
246
|
+
<nav class="bf-tree" aria-label="File tree">${renderTree(layout.tree, layout.currentRel, layout.summary)}</nav>
|
|
124
247
|
<p class="bf-note" id="tree-empty" hidden>No matches.</p>
|
|
125
248
|
</aside>
|
|
126
249
|
</details>
|
|
127
|
-
<main class="bf-app__main" id="main" tabindex="-1">
|
|
250
|
+
<main class="bf-app__main${layout.mainClass ? ` ${layout.mainClass}` : ""}" id="main" tabindex="-1">
|
|
128
251
|
<nav class="bf-crumbs" aria-label="Breadcrumb">${layout.breadcrumbs}</nav>
|
|
129
252
|
${layout.main}
|
|
130
253
|
</main>
|
|
131
254
|
</div>
|
|
132
255
|
<footer class="bf-app__footer">served from ${escapeHtml(layout.rootLabel)} · read-only · ${layout.footerNote}</footer>
|
|
256
|
+
${layout.keys && layout.keys.length > 0 ? renderKeysSheet(layout.keys) : ""}
|
|
133
257
|
<script src="/app.js" defer></script>
|
|
134
258
|
</body>
|
|
135
259
|
</html>
|
|
@@ -151,11 +275,11 @@ export function renderBreadcrumbs(rel: string): string {
|
|
|
151
275
|
return parts.join(`<span class="bf-crumbs__sep">/</span>`);
|
|
152
276
|
}
|
|
153
277
|
|
|
154
|
-
function renderTree(node: TreeNode, currentRel: string): string {
|
|
155
|
-
return `<ul>${renderTreeNode(node, currentRel)}</ul>`;
|
|
278
|
+
function renderTree(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
|
|
279
|
+
return `<ul>${renderTreeNode(node, currentRel, summary)}</ul>`;
|
|
156
280
|
}
|
|
157
281
|
|
|
158
|
-
function renderTreeNode(node: TreeNode, currentRel: string): string {
|
|
282
|
+
function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
|
|
159
283
|
const expanded = node.children.length > 0;
|
|
160
284
|
const isDir = node.isDir;
|
|
161
285
|
const spec = isDir ? FOLDER_ICON : languageOf(node.name);
|
|
@@ -167,15 +291,15 @@ function renderTreeNode(node: TreeNode, currentRel: string): string {
|
|
|
167
291
|
const dataPath = ` data-path="${escapeHtml(node.rel)}"`;
|
|
168
292
|
const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name);
|
|
169
293
|
const children = expanded
|
|
170
|
-
? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel)).join("")}</ul>`
|
|
294
|
+
? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel, summary)).join("")}</ul>`
|
|
171
295
|
: "";
|
|
172
296
|
const label = node.broken
|
|
173
297
|
? `<span class="browse-broken" title="broken symbolic link">${name}</span><span class="browse-link-mark">↩</span>`
|
|
174
298
|
: `<a href="${href}">${name}</a>`;
|
|
175
|
-
return `<li data-open="${expanded}"><div class="bf-tree__row"${current}${dataPath}>${twisty}${iconMarkup(spec)}${label}</div>${children}</li>`;
|
|
299
|
+
return `<li data-open="${expanded}"><div class="bf-tree__row"${current}${dataPath}>${twisty}${iconMarkup(spec)}${label}${changeMark(summary, node.rel, isDir)}</div>${children}</li>`;
|
|
176
300
|
}
|
|
177
301
|
|
|
178
|
-
function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean): string {
|
|
302
|
+
function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean, summary?: ChangeSummary): string {
|
|
179
303
|
const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
|
|
180
304
|
const showThumb = thumbnails && entry.isImage && !entry.broken && entry.size <= maxThumbBytes;
|
|
181
305
|
const visual = showThumb
|
|
@@ -192,7 +316,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean):
|
|
|
192
316
|
? `<span class="browse-link-mark" title="${entry.broken ? "broken symbolic link" : "symbolic link"}">↩</span>`
|
|
193
317
|
: "";
|
|
194
318
|
const attrs = `data-name="${escapeHtml(entry.name)}" data-path="${escapeHtml(entry.rel)}" data-dir="${entry.isDir ? "1" : "0"}" data-size="${entry.size}" data-mtime="${Math.round(entry.mtimeMs)}"`;
|
|
195
|
-
return `<tr${classes ? ` class="${classes}"` : ""} ${attrs}><td><span class="bf-table__name">${visual}${link}${mark}</span></td><td class="bf-table__size">${entry.isDir ? "—" : escapeHtml(formatSize(entry.size))}</td><td class="bf-table__when">${entry.isDir ? "—" : escapeHtml(formatDate(entry.mtimeMs))}</td></tr>`;
|
|
319
|
+
return `<tr${classes ? ` class="${classes}"` : ""} ${attrs}><td><span class="bf-table__name">${visual}${link}${changeMark(summary, entry.rel, entry.isDir)}${mark}</span></td><td class="bf-table__size">${entry.isDir ? "—" : escapeHtml(formatSize(entry.size))}</td><td class="bf-table__when">${entry.isDir ? "—" : escapeHtml(formatDate(entry.mtimeMs))}</td></tr>`;
|
|
196
320
|
}
|
|
197
321
|
|
|
198
322
|
export interface DirectoryPage {
|
|
@@ -203,16 +327,19 @@ export interface DirectoryPage {
|
|
|
203
327
|
thumbnails: boolean;
|
|
204
328
|
maxThumbBytes: number;
|
|
205
329
|
git?: GitStatus;
|
|
330
|
+
/** Changed-path index for the header chip and markers. */
|
|
331
|
+
summary?: ChangeSummary;
|
|
206
332
|
}
|
|
207
333
|
|
|
208
334
|
/** Render a directory listing page. */
|
|
209
335
|
export function renderDirectoryPage(page: DirectoryPage): string {
|
|
210
|
-
|
|
336
|
+
// At the root there is no folder name to show, so the served folder names it.
|
|
337
|
+
const folderName = page.rel ? lastSegment(page.rel) : lastSegment(page.rootLabel) || "/";
|
|
211
338
|
const heading = `<h1 class="bf-title browse-heading" id="page-title">${escapeHtml(folderName)}</h1>`;
|
|
212
339
|
let body = `<table class="bf-table browse-listing" aria-labelledby="page-title">
|
|
213
340
|
<thead><tr><th scope="col" aria-sort="ascending"><button type="button" class="bf-sort" data-sort="name" title="Sort by name">Name</button></th><th scope="col" class="bf-table__size"><button type="button" class="bf-sort" data-sort="size" title="Sort by size">Size</button></th><th scope="col" class="bf-table__when"><button type="button" class="bf-sort" data-sort="mtime" title="Sort by modified time">Modified</button></th></tr></thead>
|
|
214
341
|
<tbody>
|
|
215
|
-
${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.thumbnails)).join("\n")}
|
|
342
|
+
${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.thumbnails, page.summary)).join("\n")}
|
|
216
343
|
</tbody>
|
|
217
344
|
</table>`;
|
|
218
345
|
if (page.listing.entries.length === 0) body = `<div class="bf-empty">This folder is empty.</div>`;
|
|
@@ -220,14 +347,16 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
|
|
|
220
347
|
body += `<p class="bf-note">Showing the first ${page.listing.entries.length} of ${page.listing.total} entries.</p>`;
|
|
221
348
|
|
|
222
349
|
return renderLayout({
|
|
223
|
-
title: `browse · ${page.rel ||
|
|
350
|
+
title: `browse · ${page.rel || folderName}`,
|
|
224
351
|
rootLabel: page.rootLabel,
|
|
225
352
|
tree: page.tree,
|
|
226
353
|
currentRel: page.rel,
|
|
227
354
|
breadcrumbs: renderBreadcrumbs(page.rel),
|
|
355
|
+
keys: pageKeys(),
|
|
228
356
|
main: ` ${heading}\n ${body}`,
|
|
229
357
|
footerNote: `${page.listing.total} entr${page.listing.total === 1 ? "y" : "ies"}`,
|
|
230
358
|
git: page.git,
|
|
359
|
+
summary: page.summary,
|
|
231
360
|
});
|
|
232
361
|
}
|
|
233
362
|
|
|
@@ -240,6 +369,10 @@ export interface FilePage {
|
|
|
240
369
|
git?: GitStatus;
|
|
241
370
|
/** Show the Markdown source instead of the rendered HTML. */
|
|
242
371
|
markdownRaw?: boolean;
|
|
372
|
+
/** True when this file has uncommitted changes, for the changes action. */
|
|
373
|
+
changed?: boolean;
|
|
374
|
+
/** Changed-path index for the header chip and the tree markers. */
|
|
375
|
+
summary?: ChangeSummary;
|
|
243
376
|
}
|
|
244
377
|
|
|
245
378
|
/** The root-relative directory of a file, for resolving its Markdown links. */
|
|
@@ -253,12 +386,43 @@ function renderCodeLines(lines: string[], language?: HighlightLanguage): string
|
|
|
253
386
|
return rendered.map((html) => `<span class="bf-code__line">${html.length > 0 ? html : " "}</span>`).join("");
|
|
254
387
|
}
|
|
255
388
|
|
|
389
|
+
/** The last segment of a root-relative path, or the path itself when it has none. */
|
|
390
|
+
function lastSegment(path: string): string {
|
|
391
|
+
const trimmed = path.replace(/[/\\]+$/, "");
|
|
392
|
+
const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
|
393
|
+
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The shortcuts every page shares, plus whatever the page adds. */
|
|
397
|
+
function pageKeys(...extra: KeyHint[]): KeyHint[] {
|
|
398
|
+
return [{ keys: "/ ⌘K", label: "Quick open a file" }, ...extra, { keys: "?", label: "This sheet" }];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** An "on this page" outline from the headings the Markdown renderer emitted. */
|
|
402
|
+
function renderOutline(headings: MarkdownHeading[]): string {
|
|
403
|
+
if (headings.length < 4) return "";
|
|
404
|
+
const base = Math.min(...headings.map((heading) => heading.level));
|
|
405
|
+
const items = headings
|
|
406
|
+
.map((heading) => {
|
|
407
|
+
const depth = Math.min(heading.level - base, 3);
|
|
408
|
+
const label = heading.text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*_~]/g, "");
|
|
409
|
+
return `<li><a class="lvl${depth}" href="#${heading.id}">${escapeHtml(label)}</a></li>`;
|
|
410
|
+
})
|
|
411
|
+
.join("");
|
|
412
|
+
return `<details class="browse-md-outline"><summary>On this page</summary><ul>${items}</ul></details>`;
|
|
413
|
+
}
|
|
414
|
+
|
|
256
415
|
/** The card-head controls: Markdown mode, line wrapping, and copy actions. */
|
|
257
416
|
function renderFileActions(page: FilePage): string {
|
|
258
417
|
const isMarkdown = page.file.kind === "markdown";
|
|
259
418
|
const rawHref = `/raw/${encodePath(page.rel)}`;
|
|
260
419
|
const viewHref = `/view/${encodePath(page.rel)}`;
|
|
261
420
|
const groups: string[] = [];
|
|
421
|
+
if (page.changed) {
|
|
422
|
+
groups.push(
|
|
423
|
+
`<a class="bf-btn" href="/diff/${encodePath(page.rel)}" title="View this file's uncommitted changes">changes</a>`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
262
426
|
if (isMarkdown) {
|
|
263
427
|
const rendered = page.markdownRaw
|
|
264
428
|
? `<a class="bf-segmented__item" href="${viewHref}">rendered</a>`
|
|
@@ -275,7 +439,7 @@ function renderFileActions(page: FilePage): string {
|
|
|
275
439
|
}
|
|
276
440
|
if (page.file.kind === "text" || isMarkdown) {
|
|
277
441
|
groups.push(
|
|
278
|
-
`<button type="button" class="bf-btn" data-copy="content" data-href="${escapeHtml(rawHref)}" title="Copy the file contents">copy</button>`,
|
|
442
|
+
`<button type="button" class="bf-btn" data-copy="content" data-href="${escapeHtml(rawHref)}" title="Copy the file contents">copy contents</button>`,
|
|
279
443
|
);
|
|
280
444
|
}
|
|
281
445
|
groups.push(
|
|
@@ -289,17 +453,29 @@ export function renderFilePage(page: FilePage): string {
|
|
|
289
453
|
const name = page.rel.slice(page.rel.lastIndexOf("/") + 1);
|
|
290
454
|
const spec = languageOf(name);
|
|
291
455
|
const language = page.file.kind === "text" ? detectLanguage(name) : undefined;
|
|
292
|
-
|
|
456
|
+
// Text is always served as octet-stream, so the response type says nothing
|
|
457
|
+
// about the file; the language label, size, and date are the useful metadata.
|
|
458
|
+
const meta = `${spec.label} · ${formatSize(page.file.size)} · ${formatDate(page.file.mtimeMs)}`;
|
|
293
459
|
const download = `<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`;
|
|
294
460
|
const head = `<div class="bf-card__head">${iconMarkup(spec)}<h1 class="bf-card__title">${escapeHtml(name)}</h1><span class="bf-card__meta">${escapeHtml(meta)}</span><span class="bf-spacer"></span>${renderFileActions(page)}${download}</div>`;
|
|
295
461
|
|
|
462
|
+
const isMarkdown = page.file.kind === "markdown";
|
|
463
|
+
const rendered = isMarkdown && !page.markdownRaw;
|
|
464
|
+
const wrappable = page.file.kind === "text" || (isMarkdown && page.markdownRaw);
|
|
465
|
+
const keys = pageKeys(
|
|
466
|
+
...(isMarkdown ? [{ keys: "r", label: "Toggle rendered and raw" }] : []),
|
|
467
|
+
...(wrappable ? [{ keys: "u", label: "Toggle line wrapping" }] : []),
|
|
468
|
+
);
|
|
469
|
+
|
|
296
470
|
let content: string;
|
|
297
471
|
let note = "";
|
|
298
472
|
if (page.file.kind === "text" || page.file.kind === "markdown") {
|
|
299
|
-
|
|
300
|
-
page.file.
|
|
301
|
-
|
|
302
|
-
|
|
473
|
+
if (page.file.kind === "markdown" && !page.markdownRaw) {
|
|
474
|
+
const document = renderMarkdownDocument(page.file.lines.join("\n"), markdownBaseDir(page.rel));
|
|
475
|
+
content = `${renderOutline(document.headings)}<article class="bf-md">${document.html}</article>`;
|
|
476
|
+
} else {
|
|
477
|
+
content = `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
|
|
478
|
+
}
|
|
303
479
|
if (page.file.truncated)
|
|
304
480
|
note = `<p class="bf-note">Showing the first ${page.file.lines.length} of ${page.file.totalLines} lines.</p>`;
|
|
305
481
|
} else if (page.file.kind === "image") {
|
|
@@ -316,9 +492,222 @@ export function renderFilePage(page: FilePage): string {
|
|
|
316
492
|
tree: page.tree,
|
|
317
493
|
currentRel: page.rel,
|
|
318
494
|
breadcrumbs: renderBreadcrumbs(page.rel),
|
|
495
|
+
mainClass: rendered ? "browse-page browse-measure" : "browse-page",
|
|
496
|
+
keys,
|
|
497
|
+
main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
|
|
498
|
+
footerNote: escapeHtml(page.rel),
|
|
499
|
+
git: page.git,
|
|
500
|
+
summary: page.summary,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Map a status letter to an accessible label. */
|
|
505
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
506
|
+
M: "modified",
|
|
507
|
+
A: "added",
|
|
508
|
+
D: "deleted",
|
|
509
|
+
R: "renamed",
|
|
510
|
+
C: "copied",
|
|
511
|
+
T: "type changed",
|
|
512
|
+
U: "unmerged",
|
|
513
|
+
"?": "untracked",
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
function statusWord(letter: string): string {
|
|
517
|
+
return STATUS_LABEL[letter] ?? "changed";
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The inner HTML per diff line. Hunk bodies are highlighted by rebuilding the
|
|
522
|
+
* old side (context + deleted) and the new side (context + added), so the
|
|
523
|
+
* tokenizer carries state within a hunk; meta, hunk, and note lines stay plain.
|
|
524
|
+
* State does not cross hunks, since the unchanged lines between them are absent.
|
|
525
|
+
*/
|
|
526
|
+
function highlightDiffText(lines: DiffLine[], language?: HighlightLanguage): string[] {
|
|
527
|
+
if (!language) return lines.map((line) => escapeHtml(line.text));
|
|
528
|
+
const out: string[] = new Array(lines.length);
|
|
529
|
+
let index = 0;
|
|
530
|
+
while (index < lines.length) {
|
|
531
|
+
const kind = lines[index].kind;
|
|
532
|
+
if (kind !== "ctx" && kind !== "add" && kind !== "del") {
|
|
533
|
+
out[index] = escapeHtml(lines[index].text);
|
|
534
|
+
index++;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const start = index;
|
|
538
|
+
const oldDoc: string[] = [];
|
|
539
|
+
const newDoc: string[] = [];
|
|
540
|
+
while (index < lines.length) {
|
|
541
|
+
const body = lines[index].kind;
|
|
542
|
+
if (body !== "ctx" && body !== "add" && body !== "del") break;
|
|
543
|
+
if (body === "ctx") {
|
|
544
|
+
oldDoc.push(lines[index].text);
|
|
545
|
+
newDoc.push(lines[index].text);
|
|
546
|
+
} else if (body === "add") {
|
|
547
|
+
newDoc.push(lines[index].text);
|
|
548
|
+
} else {
|
|
549
|
+
oldDoc.push(lines[index].text);
|
|
550
|
+
}
|
|
551
|
+
index++;
|
|
552
|
+
}
|
|
553
|
+
const oldHtml = highlightLines(oldDoc, language);
|
|
554
|
+
const newHtml = highlightLines(newDoc, language);
|
|
555
|
+
let oldIndex = 0;
|
|
556
|
+
let newIndex = 0;
|
|
557
|
+
for (let cursor = start; cursor < index; cursor++) {
|
|
558
|
+
const body = lines[cursor].kind;
|
|
559
|
+
if (body === "ctx") {
|
|
560
|
+
out[cursor] = newHtml[newIndex];
|
|
561
|
+
newIndex++;
|
|
562
|
+
oldIndex++;
|
|
563
|
+
} else if (body === "add") {
|
|
564
|
+
out[cursor] = newHtml[newIndex];
|
|
565
|
+
newIndex++;
|
|
566
|
+
} else {
|
|
567
|
+
out[cursor] = oldHtml[oldIndex];
|
|
568
|
+
oldIndex++;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Render unified-diff lines with a two-column gutter and per-kind styling. */
|
|
576
|
+
export function renderDiffLines(lines: DiffLine[], language?: HighlightLanguage): string {
|
|
577
|
+
const html = highlightDiffText(lines, language);
|
|
578
|
+
let hunks = 0;
|
|
579
|
+
return lines
|
|
580
|
+
.map((line, index) => {
|
|
581
|
+
const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : "";
|
|
582
|
+
const gutter = `<span class="browse-diff-no" aria-hidden="true">${line.oldNo ?? ""}</span><span class="browse-diff-no" aria-hidden="true">${line.newNo ?? ""}</span>`;
|
|
583
|
+
const marker = `<span class="browse-diff-sign" aria-hidden="true">${sign}</span>`;
|
|
584
|
+
// Hunk headers carry an anchor so the keyboard can jump between them.
|
|
585
|
+
const anchor = line.kind === "hunk" ? ` id="hunk-${++hunks}" data-hunk="${hunks}"` : "";
|
|
586
|
+
return `<span class="bf-code__line bf-diff-line bf-diff-line--${line.kind}"${anchor}>${gutter}${marker}${html[index]}</span>`;
|
|
587
|
+
})
|
|
588
|
+
.join("");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The scope label for a mode. */
|
|
592
|
+
function modeScope(mode: DiffMode): string {
|
|
593
|
+
if (mode === "staged") return "staged vs HEAD";
|
|
594
|
+
if (mode === "worktree") return "unstaged";
|
|
595
|
+
return "vs HEAD";
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The `head | staged | worktree` segmented control; `build` returns each href. */
|
|
599
|
+
function renderModeControl(mode: DiffMode, build: (mode: DiffMode) => string): string {
|
|
600
|
+
const items = DIFF_MODES.map((candidate) => {
|
|
601
|
+
const current = candidate === mode ? ` aria-current="page"` : "";
|
|
602
|
+
return `<a class="bf-segmented__item" href="${escapeHtml(build(candidate))}"${current}>${candidate}</a>`;
|
|
603
|
+
}).join("");
|
|
604
|
+
return `<span class="bf-segmented" role="group" aria-label="Diff mode">${items}</span>`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export interface DiffPage {
|
|
608
|
+
rootLabel: string;
|
|
609
|
+
mode: DiffMode;
|
|
610
|
+
files: ChangedFile[];
|
|
611
|
+
stat: string;
|
|
612
|
+
tree: TreeNode;
|
|
613
|
+
git?: GitStatus;
|
|
614
|
+
summary?: ChangeSummary;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The change overview: `git diff --stat` plus every changed path. */
|
|
618
|
+
export function renderDiffPage(page: DiffPage): string {
|
|
619
|
+
const modeHref = (mode: DiffMode) => (mode === "head" ? "/diff" : `/diff?mode=${mode}`);
|
|
620
|
+
const body =
|
|
621
|
+
page.files.length === 0
|
|
622
|
+
? `<div class="bf-empty"><p class="bf-empty__title">No changes ${escapeHtml(modeScope(page.mode))}.</p><p class="bf-empty__sub">The working tree matches this scope.</p></div>`
|
|
623
|
+
: `<table class="bf-table browse-diff" aria-labelledby="page-title">
|
|
624
|
+
<thead><tr><th scope="col" class="browse-diff-status">Status</th><th scope="col">Path</th><th scope="col" class="browse-diff-word">Changes</th></tr></thead>
|
|
625
|
+
<tbody>
|
|
626
|
+
${page.files.map((file) => renderChangedRow(file, page.mode)).join("\n")}
|
|
627
|
+
</tbody>
|
|
628
|
+
</table>`;
|
|
629
|
+
const stat = page.stat ? `<pre class="browse-diff-stat">${escapeHtml(page.stat)}</pre>` : "";
|
|
630
|
+
return renderLayout({
|
|
631
|
+
title: "browse · changes",
|
|
632
|
+
rootLabel: page.rootLabel,
|
|
633
|
+
tree: page.tree,
|
|
634
|
+
currentRel: "",
|
|
635
|
+
breadcrumbs: renderBreadcrumbs(""),
|
|
636
|
+
mainClass: "browse-page",
|
|
637
|
+
keys: pageKeys(),
|
|
638
|
+
main: ` <h1 class="bf-title browse-heading" id="page-title">Changes <span class="browse-diff-scope">${escapeHtml(modeScope(page.mode))}</span></h1>\n <div class="browse-diff-toolbar">${renderModeControl(page.mode, modeHref)}</div>\n ${stat}\n ${body}`,
|
|
639
|
+
footerNote: `${page.files.length} changed`,
|
|
640
|
+
git: page.git,
|
|
641
|
+
summary: page.summary,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function renderChangedRow(file: ChangedFile, mode: DiffMode): string {
|
|
646
|
+
const letter = statusLetter(file);
|
|
647
|
+
const label = statusWord(letter);
|
|
648
|
+
const badge = `browse-diff-badge browse-diff-badge--${letter === "?" ? "Q" : letter}`;
|
|
649
|
+
const orig = file.origRel ? ` <span class="browse-diff-orig">from ${escapeHtml(file.origRel)}</span>` : "";
|
|
650
|
+
const href = `/diff/${encodePath(file.rel)}${mode === "head" ? "" : `?mode=${mode}`}`;
|
|
651
|
+
return `<tr data-path="${escapeHtml(file.rel)}"><td><span class="${badge}" aria-label="${escapeHtml(label)}">${escapeHtml(letter)}</span></td><td><span class="bf-table__name">${iconMarkup(languageOf(file.rel))}<a href="${href}">${escapeHtml(file.rel)}</a>${orig}</span></td><td class="browse-diff-word">${escapeHtml(label)}</td></tr>`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export interface FileDiffPage {
|
|
655
|
+
rootLabel: string;
|
|
656
|
+
rel: string;
|
|
657
|
+
mode: DiffMode;
|
|
658
|
+
diff: DiffFileView;
|
|
659
|
+
tree: TreeNode;
|
|
660
|
+
git?: GitStatus;
|
|
661
|
+
summary?: ChangeSummary;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** A single file's unified diff, with the scope control and file actions. */
|
|
665
|
+
export function renderFileDiffPage(page: FileDiffPage): string {
|
|
666
|
+
const name = page.rel.slice(page.rel.lastIndexOf("/") + 1);
|
|
667
|
+
const spec = languageOf(name);
|
|
668
|
+
const base = `/diff/${encodePath(page.rel)}`;
|
|
669
|
+
const actions = [
|
|
670
|
+
renderModeControl(page.mode, (mode) => (mode === "head" ? base : `${base}?mode=${mode}`)),
|
|
671
|
+
`<a class="bf-btn" href="/view/${encodePath(page.rel)}" title="Open the file">view file</a>`,
|
|
672
|
+
`<button type="button" class="bf-btn" id="wrap-toggle" aria-pressed="false" title="Toggle line wrapping">wrap</button>`,
|
|
673
|
+
`<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`,
|
|
674
|
+
`<button type="button" class="bf-btn" data-copy="path" data-value="${escapeHtml(page.rel)}" title="Copy the root-relative path">copy path</button>`,
|
|
675
|
+
];
|
|
676
|
+
// A per-file change summary and hunk count, so the scope is visible before scrolling.
|
|
677
|
+
const adds = page.diff.lines.filter((line) => line.kind === "add").length;
|
|
678
|
+
const dels = page.diff.lines.filter((line) => line.kind === "del").length;
|
|
679
|
+
const hunks = page.diff.lines.filter((line) => line.kind === "hunk").length;
|
|
680
|
+
const summary =
|
|
681
|
+
hunks > 0
|
|
682
|
+
? `<span class="bf-card__meta browse-diff-summary"><span class="browse-diff-add">+${adds}</span> <span class="browse-diff-del">−${dels}</span></span><span class="bf-card__meta browse-hunks">${hunks} hunk${hunks === 1 ? "" : "s"}</span>`
|
|
683
|
+
: "";
|
|
684
|
+
const keys = pageKeys(
|
|
685
|
+
{ keys: "u", label: "Toggle line wrapping" },
|
|
686
|
+
...(hunks > 0 ? [{ keys: "n p", label: "Next and previous change" }] : []),
|
|
687
|
+
);
|
|
688
|
+
const head = `<div class="bf-card__head">${iconMarkup(spec)}<h1 class="bf-card__title">${escapeHtml(name)}</h1><span class="bf-card__meta">${escapeHtml(modeScope(page.mode))}</span>${summary}<span class="bf-spacer"></span><span class="bf-card__actions">${actions.join("")}</span></div>`;
|
|
689
|
+
let content: string;
|
|
690
|
+
let note = "";
|
|
691
|
+
if (page.diff.binary) {
|
|
692
|
+
content = `<div class="bf-empty"><p class="bf-empty__title">Binary or unreadable change.</p><p class="bf-empty__sub">No text diff is shown for this file.</p><a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a></div>`;
|
|
693
|
+
} else if (page.diff.lines.length === 0) {
|
|
694
|
+
content = `<div class="bf-empty"><p class="bf-empty__title">No changes for this file.</p><p class="bf-empty__sub">It is unchanged in the selected scope.</p></div>`;
|
|
695
|
+
} else {
|
|
696
|
+
content = `<pre class="bf-code bf-diff"><code>${renderDiffLines(page.diff.lines, detectLanguage(name))}</code></pre>`;
|
|
697
|
+
if (page.diff.truncated) note = `<p class="bf-note">Showing the first ${page.diff.lines.length} diff lines.</p>`;
|
|
698
|
+
}
|
|
699
|
+
return renderLayout({
|
|
700
|
+
title: `browse · ${page.rel}`,
|
|
701
|
+
rootLabel: page.rootLabel,
|
|
702
|
+
tree: page.tree,
|
|
703
|
+
currentRel: page.rel,
|
|
704
|
+
breadcrumbs: renderBreadcrumbs(page.rel),
|
|
705
|
+
mainClass: "browse-page",
|
|
706
|
+
keys,
|
|
319
707
|
main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
|
|
320
|
-
footerNote: escapeHtml(page.
|
|
708
|
+
footerNote: `diff · ${escapeHtml(modeScope(page.mode))}`,
|
|
321
709
|
git: page.git,
|
|
710
|
+
summary: page.summary,
|
|
322
711
|
});
|
|
323
712
|
}
|
|
324
713
|
|
|
@@ -13,9 +13,10 @@ import { realpathSync } from "node:fs";
|
|
|
13
13
|
import { stat } from "node:fs/promises";
|
|
14
14
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
|
|
16
|
-
import { GLYPHS, STATUS_KEYS } from "../../lib/
|
|
16
|
+
import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
17
|
+
import { paint } from "../../lib/tui/tokens.ts";
|
|
17
18
|
import { loadConfig, randomPort } from "./config.ts";
|
|
18
|
-
import { createGitStatus } from "./git.ts";
|
|
19
|
+
import { createGitDiff, createGitStatus } from "./git.ts";
|
|
19
20
|
import { openInBrowser } from "../../lib/browser.ts";
|
|
20
21
|
import { HttpError, resolveRequestPath } from "./paths.ts";
|
|
21
22
|
import { createFileServer, type FileServer } from "./server.ts";
|
|
@@ -45,7 +46,7 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
45
46
|
const value =
|
|
46
47
|
port === undefined
|
|
47
48
|
? undefined
|
|
48
|
-
: `${ctx.ui.theme
|
|
49
|
+
: `${paint(ctx.ui.theme, "ok", GLYPHS.browse)} ${paint(ctx.ui.theme, "accent", String(port))}`;
|
|
49
50
|
ctx.ui.setStatus(STATUS_KEYS.browse, value);
|
|
50
51
|
} catch {
|
|
51
52
|
// A UI without a theme or setStatus must not break the command.
|
|
@@ -110,7 +111,17 @@ export default function fileBrowser(pi: ExtensionAPI, deps: FileBrowserDeps = {}
|
|
|
110
111
|
await stop(ctx);
|
|
111
112
|
const create = deps.createServer ?? createFileServer;
|
|
112
113
|
try {
|
|
113
|
-
const server = await create({
|
|
114
|
+
const server = await create({
|
|
115
|
+
root,
|
|
116
|
+
config: { ...config, port },
|
|
117
|
+
git: createGitStatus(pi, root),
|
|
118
|
+
diff: createGitDiff(pi, root, {
|
|
119
|
+
context: config.diffContext,
|
|
120
|
+
maxLines: config.maxDiffLines,
|
|
121
|
+
maxFileBytes: config.maxFileBytes,
|
|
122
|
+
maxTextLines: config.maxTextLines,
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
114
125
|
if (unpinned) sessionPort = port;
|
|
115
126
|
running = { server };
|
|
116
127
|
setBrowseStatus(ctx, server.port);
|