@gavin-hu/my-pi-agent 0.8.1 → 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 +8 -7
- package/README.zh-CN.md +7 -7
- 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 +51 -9
- 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 +385 -19
- package/extensions/file-browse/index.ts +15 -4
- package/extensions/file-browse/markdown.ts +33 -0
- 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 +1 -1
- /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,7 +19,8 @@ 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
26
|
import { renderMarkdownDocument, type MarkdownHeading } from "./markdown.ts";
|
|
@@ -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
|
}
|
|
@@ -86,10 +121,67 @@ ${WEB_COMPONENTS_CSS}
|
|
|
86
121
|
.browse-md-outline a.lvl2{padding-left:var(--space-5)}
|
|
87
122
|
.browse-md-outline a.lvl3{padding-left:var(--space-7)}
|
|
88
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
|
+
}
|
|
89
175
|
@media (max-width:520px){
|
|
90
176
|
.bf-toolbar .bf-theme-toggle{margin-left:auto}
|
|
91
177
|
}`;
|
|
92
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
|
+
|
|
93
185
|
interface Layout {
|
|
94
186
|
title: string;
|
|
95
187
|
rootLabel: string;
|
|
@@ -98,7 +190,29 @@ interface Layout {
|
|
|
98
190
|
breadcrumbs: string;
|
|
99
191
|
main: string;
|
|
100
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[];
|
|
101
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>`;
|
|
102
216
|
}
|
|
103
217
|
|
|
104
218
|
/** A full two-pane page. */
|
|
@@ -118,25 +232,28 @@ ${SPRITE}
|
|
|
118
232
|
${brandLockup("browse")}
|
|
119
233
|
<span class="bf-context browse-root">${escapeHtml(layout.rootLabel)}</span>
|
|
120
234
|
${renderGit(layout.git)}
|
|
235
|
+
${renderChanges(layout.summary)}
|
|
121
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>
|
|
122
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>
|
|
123
|
-
</div></header>
|
|
239
|
+
</div><div class="browse-progress" aria-hidden="true"><span class="browse-progress__fill" id="progress-fill"></span></div></header>
|
|
124
240
|
<div class="bf-app__body">
|
|
125
241
|
<details class="bf-app__sidebar" open>
|
|
126
242
|
<summary class="bf-panel__summary">Files</summary>
|
|
127
243
|
<aside class="bf-panel browse-side">
|
|
128
244
|
<button type="button" class="browse-quick-open" id="quick-open" aria-haspopup="dialog"><span>quick open</span><kbd>/</kbd></button>
|
|
129
245
|
<label class="bf-field">⌕ <input class="bf-input" id="filter" type="search" placeholder="filter paths…" aria-label="Filter paths"></label>
|
|
130
|
-
<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>
|
|
131
247
|
<p class="bf-note" id="tree-empty" hidden>No matches.</p>
|
|
132
248
|
</aside>
|
|
133
249
|
</details>
|
|
134
|
-
<main class="bf-app__main" id="main" tabindex="-1">
|
|
250
|
+
<main class="bf-app__main${layout.mainClass ? ` ${layout.mainClass}` : ""}" id="main" tabindex="-1">
|
|
135
251
|
<nav class="bf-crumbs" aria-label="Breadcrumb">${layout.breadcrumbs}</nav>
|
|
136
252
|
${layout.main}
|
|
137
253
|
</main>
|
|
138
254
|
</div>
|
|
139
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) : ""}
|
|
140
257
|
<script src="/app.js" defer></script>
|
|
141
258
|
</body>
|
|
142
259
|
</html>
|
|
@@ -158,11 +275,11 @@ export function renderBreadcrumbs(rel: string): string {
|
|
|
158
275
|
return parts.join(`<span class="bf-crumbs__sep">/</span>`);
|
|
159
276
|
}
|
|
160
277
|
|
|
161
|
-
function renderTree(node: TreeNode, currentRel: string): string {
|
|
162
|
-
return `<ul>${renderTreeNode(node, currentRel)}</ul>`;
|
|
278
|
+
function renderTree(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
|
|
279
|
+
return `<ul>${renderTreeNode(node, currentRel, summary)}</ul>`;
|
|
163
280
|
}
|
|
164
281
|
|
|
165
|
-
function renderTreeNode(node: TreeNode, currentRel: string): string {
|
|
282
|
+
function renderTreeNode(node: TreeNode, currentRel: string, summary?: ChangeSummary): string {
|
|
166
283
|
const expanded = node.children.length > 0;
|
|
167
284
|
const isDir = node.isDir;
|
|
168
285
|
const spec = isDir ? FOLDER_ICON : languageOf(node.name);
|
|
@@ -174,15 +291,15 @@ function renderTreeNode(node: TreeNode, currentRel: string): string {
|
|
|
174
291
|
const dataPath = ` data-path="${escapeHtml(node.rel)}"`;
|
|
175
292
|
const name = isDir ? `${escapeHtml(node.name)}/` : escapeHtml(node.name);
|
|
176
293
|
const children = expanded
|
|
177
|
-
? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel)).join("")}</ul>`
|
|
294
|
+
? `<ul>${node.children.map((child) => renderTreeNode(child, currentRel, summary)).join("")}</ul>`
|
|
178
295
|
: "";
|
|
179
296
|
const label = node.broken
|
|
180
297
|
? `<span class="browse-broken" title="broken symbolic link">${name}</span><span class="browse-link-mark">↩</span>`
|
|
181
298
|
: `<a href="${href}">${name}</a>`;
|
|
182
|
-
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>`;
|
|
183
300
|
}
|
|
184
301
|
|
|
185
|
-
function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean): string {
|
|
302
|
+
function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean, summary?: ChangeSummary): string {
|
|
186
303
|
const spec = entry.isDir ? FOLDER_ICON : languageOf(entry.name);
|
|
187
304
|
const showThumb = thumbnails && entry.isImage && !entry.broken && entry.size <= maxThumbBytes;
|
|
188
305
|
const visual = showThumb
|
|
@@ -199,7 +316,7 @@ function renderRow(entry: DirEntry, maxThumbBytes: number, thumbnails: boolean):
|
|
|
199
316
|
? `<span class="browse-link-mark" title="${entry.broken ? "broken symbolic link" : "symbolic link"}">↩</span>`
|
|
200
317
|
: "";
|
|
201
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)}"`;
|
|
202
|
-
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>`;
|
|
203
320
|
}
|
|
204
321
|
|
|
205
322
|
export interface DirectoryPage {
|
|
@@ -210,16 +327,19 @@ export interface DirectoryPage {
|
|
|
210
327
|
thumbnails: boolean;
|
|
211
328
|
maxThumbBytes: number;
|
|
212
329
|
git?: GitStatus;
|
|
330
|
+
/** Changed-path index for the header chip and markers. */
|
|
331
|
+
summary?: ChangeSummary;
|
|
213
332
|
}
|
|
214
333
|
|
|
215
334
|
/** Render a directory listing page. */
|
|
216
335
|
export function renderDirectoryPage(page: DirectoryPage): string {
|
|
217
|
-
|
|
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) || "/";
|
|
218
338
|
const heading = `<h1 class="bf-title browse-heading" id="page-title">${escapeHtml(folderName)}</h1>`;
|
|
219
339
|
let body = `<table class="bf-table browse-listing" aria-labelledby="page-title">
|
|
220
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>
|
|
221
341
|
<tbody>
|
|
222
|
-
${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")}
|
|
223
343
|
</tbody>
|
|
224
344
|
</table>`;
|
|
225
345
|
if (page.listing.entries.length === 0) body = `<div class="bf-empty">This folder is empty.</div>`;
|
|
@@ -227,14 +347,16 @@ ${page.listing.entries.map((entry) => renderRow(entry, page.maxThumbBytes, page.
|
|
|
227
347
|
body += `<p class="bf-note">Showing the first ${page.listing.entries.length} of ${page.listing.total} entries.</p>`;
|
|
228
348
|
|
|
229
349
|
return renderLayout({
|
|
230
|
-
title: `browse · ${page.rel ||
|
|
350
|
+
title: `browse · ${page.rel || folderName}`,
|
|
231
351
|
rootLabel: page.rootLabel,
|
|
232
352
|
tree: page.tree,
|
|
233
353
|
currentRel: page.rel,
|
|
234
354
|
breadcrumbs: renderBreadcrumbs(page.rel),
|
|
355
|
+
keys: pageKeys(),
|
|
235
356
|
main: ` ${heading}\n ${body}`,
|
|
236
357
|
footerNote: `${page.listing.total} entr${page.listing.total === 1 ? "y" : "ies"}`,
|
|
237
358
|
git: page.git,
|
|
359
|
+
summary: page.summary,
|
|
238
360
|
});
|
|
239
361
|
}
|
|
240
362
|
|
|
@@ -247,6 +369,10 @@ export interface FilePage {
|
|
|
247
369
|
git?: GitStatus;
|
|
248
370
|
/** Show the Markdown source instead of the rendered HTML. */
|
|
249
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;
|
|
250
376
|
}
|
|
251
377
|
|
|
252
378
|
/** The root-relative directory of a file, for resolving its Markdown links. */
|
|
@@ -260,6 +386,18 @@ function renderCodeLines(lines: string[], language?: HighlightLanguage): string
|
|
|
260
386
|
return rendered.map((html) => `<span class="bf-code__line">${html.length > 0 ? html : " "}</span>`).join("");
|
|
261
387
|
}
|
|
262
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
|
+
|
|
263
401
|
/** An "on this page" outline from the headings the Markdown renderer emitted. */
|
|
264
402
|
function renderOutline(headings: MarkdownHeading[]): string {
|
|
265
403
|
if (headings.length < 4) return "";
|
|
@@ -280,6 +418,11 @@ function renderFileActions(page: FilePage): string {
|
|
|
280
418
|
const rawHref = `/raw/${encodePath(page.rel)}`;
|
|
281
419
|
const viewHref = `/view/${encodePath(page.rel)}`;
|
|
282
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
|
+
}
|
|
283
426
|
if (isMarkdown) {
|
|
284
427
|
const rendered = page.markdownRaw
|
|
285
428
|
? `<a class="bf-segmented__item" href="${viewHref}">rendered</a>`
|
|
@@ -310,16 +453,26 @@ export function renderFilePage(page: FilePage): string {
|
|
|
310
453
|
const name = page.rel.slice(page.rel.lastIndexOf("/") + 1);
|
|
311
454
|
const spec = languageOf(name);
|
|
312
455
|
const language = page.file.kind === "text" ? detectLanguage(name) : undefined;
|
|
313
|
-
|
|
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)}`;
|
|
314
459
|
const download = `<a class="bf-btn" href="/raw/${encodePath(page.rel)}" download>download</a>`;
|
|
315
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>`;
|
|
316
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
|
+
|
|
317
470
|
let content: string;
|
|
318
471
|
let note = "";
|
|
319
472
|
if (page.file.kind === "text" || page.file.kind === "markdown") {
|
|
320
473
|
if (page.file.kind === "markdown" && !page.markdownRaw) {
|
|
321
|
-
const
|
|
322
|
-
content = `${renderOutline(
|
|
474
|
+
const document = renderMarkdownDocument(page.file.lines.join("\n"), markdownBaseDir(page.rel));
|
|
475
|
+
content = `${renderOutline(document.headings)}<article class="bf-md">${document.html}</article>`;
|
|
323
476
|
} else {
|
|
324
477
|
content = `<pre class="bf-code bf-code--numbered"><code>${renderCodeLines(page.file.lines, language)}</code></pre>`;
|
|
325
478
|
}
|
|
@@ -339,9 +492,222 @@ export function renderFilePage(page: FilePage): string {
|
|
|
339
492
|
tree: page.tree,
|
|
340
493
|
currentRel: page.rel,
|
|
341
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,
|
|
342
707
|
main: ` <div class="bf-card">${head}${content}</div>\n ${note}`,
|
|
343
|
-
footerNote: escapeHtml(page.
|
|
708
|
+
footerNote: `diff · ${escapeHtml(modeScope(page.mode))}`,
|
|
344
709
|
git: page.git,
|
|
710
|
+
summary: page.summary,
|
|
345
711
|
});
|
|
346
712
|
}
|
|
347
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);
|
|
@@ -31,6 +31,22 @@ import { encodePath } from "./paths.ts";
|
|
|
31
31
|
const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
32
32
|
const SAFE_LINK_SCHEME = /^(?:https?|mailto):/i;
|
|
33
33
|
const HTTP_SCHEME = /^https?:/i;
|
|
34
|
+
/** A GFM extended autolink: `http(s)://` or `www.` followed by a domain. */
|
|
35
|
+
const AUTOLINK = /^(?:https?:\/\/|www\.)[^\s<>]*/i;
|
|
36
|
+
|
|
37
|
+
/** GFM starts an autolink at the line start or after whitespace or one of `*_~(`. */
|
|
38
|
+
function autolinkBoundary(text: string, index: number): boolean {
|
|
39
|
+
return index === 0 || /[\s*_~(]/.test(text[index - 1]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Drop the trailing punctuation GFM excludes, and an unclosed `)`. */
|
|
43
|
+
function trimAutolink(url: string): string {
|
|
44
|
+
let end = url.replace(/[.,;:!?*_~]+$/, "");
|
|
45
|
+
while (end.endsWith(")") && (end.match(/\(/g)?.length ?? 0) < (end.match(/\)/g)?.length ?? 0)) {
|
|
46
|
+
end = end.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
|
|
47
|
+
}
|
|
48
|
+
return end;
|
|
49
|
+
}
|
|
34
50
|
/** A link reference definition: `[id]: dest "title"`. */
|
|
35
51
|
const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]*)["')])?\s*$/;
|
|
36
52
|
|
|
@@ -800,6 +816,23 @@ function renderInline(text: string, ctx: Context): string {
|
|
|
800
816
|
}
|
|
801
817
|
}
|
|
802
818
|
|
|
819
|
+
// GFM extended autolink: a bare `https://` or `www.` URL in running text.
|
|
820
|
+
if ((char === "h" || char === "H" || char === "w" || char === "W") && autolinkBoundary(text, i)) {
|
|
821
|
+
const match = AUTOLINK.exec(text.slice(i));
|
|
822
|
+
if (match) {
|
|
823
|
+
const label = trimAutolink(match[0]);
|
|
824
|
+
const dots = label.indexOf(".");
|
|
825
|
+
// Require a domain, so a bare scheme is not a link.
|
|
826
|
+
if (dots > 0 && /\.[^./]/.test(label.slice(dots))) {
|
|
827
|
+
const href = /^www\./i.test(label) ? `http://${label}` : label;
|
|
828
|
+
const external = HTTP_SCHEME.test(href) ? ` target="_blank" rel="noopener noreferrer"` : "";
|
|
829
|
+
out += `<a href="${escapeHtml(href)}"${external}>${escapeHtml(label)}</a>`;
|
|
830
|
+
i += label.length;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
803
836
|
if ((char === "*" || char === "_") && text[i + 1] === char) {
|
|
804
837
|
const close = text.indexOf(char + char, i + 2);
|
|
805
838
|
if (close > i + 2) {
|