@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
|
@@ -525,7 +525,89 @@ export const CLIENT_JS = `(function () {
|
|
|
525
525
|
if (result) window.location.assign("/view/" + encodeSegments(result.path));
|
|
526
526
|
}
|
|
527
527
|
|
|
528
|
+
// Keyboard: a shortcut sheet, the per-page actions the server rendered, and
|
|
529
|
+
// hunk-to-hunk navigation through a diff.
|
|
530
|
+
var keysDialog = document.getElementById("keys-dialog");
|
|
531
|
+
var keysButton = document.getElementById("keys-open");
|
|
532
|
+
var hunkAnchors = Array.prototype.slice.call(document.querySelectorAll("[data-hunk]"));
|
|
533
|
+
var progressFill = document.getElementById("progress-fill");
|
|
534
|
+
|
|
535
|
+
function toggleKeys(force) {
|
|
536
|
+
if (!keysDialog) return;
|
|
537
|
+
var open = force !== undefined ? force : keysDialog.hidden;
|
|
538
|
+
keysDialog.hidden = !open;
|
|
539
|
+
if (open) {
|
|
540
|
+
var close = document.getElementById("keys-close");
|
|
541
|
+
if (close) close.focus();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function toggleRaw() {
|
|
546
|
+
var next = document.querySelector(".bf-card__actions .bf-segmented a.bf-segmented__item:not([aria-current])");
|
|
547
|
+
if (next) window.location.assign(next.getAttribute("href") || next.href);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// The hunk whose header is pinned (or last passed), then one step either way.
|
|
551
|
+
function moveHunk(delta) {
|
|
552
|
+
if (hunkAnchors.length === 0) return;
|
|
553
|
+
var current = -1;
|
|
554
|
+
for (var index = 0; index < hunkAnchors.length; index++) {
|
|
555
|
+
if (hunkAnchors[index].getBoundingClientRect().top <= 8) current = index;
|
|
556
|
+
}
|
|
557
|
+
var next = Math.max(0, Math.min(hunkAnchors.length - 1, current + delta));
|
|
558
|
+
hunkAnchors[next].scrollIntoView({ block: "start" });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// The page's primary scroller: the card body for a file or diff, the main
|
|
562
|
+
// column otherwise.
|
|
563
|
+
function primaryScroller() {
|
|
564
|
+
var candidates = [
|
|
565
|
+
document.getElementById("main"),
|
|
566
|
+
document.querySelector(".bf-md"),
|
|
567
|
+
document.querySelector("pre.bf-code")
|
|
568
|
+
];
|
|
569
|
+
var best = null;
|
|
570
|
+
var bestRange = 0;
|
|
571
|
+
candidates.forEach(function (node) {
|
|
572
|
+
if (!node) return;
|
|
573
|
+
var range = node.scrollHeight - node.clientHeight;
|
|
574
|
+
if (range > bestRange) { bestRange = range; best = node; }
|
|
575
|
+
});
|
|
576
|
+
return bestRange > 0 ? best : null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function updateProgress() {
|
|
580
|
+
if (!progressFill) return;
|
|
581
|
+
var scroller = primaryScroller();
|
|
582
|
+
var doc = document.scrollingElement || document.documentElement;
|
|
583
|
+
var top = doc ? doc.scrollTop : 0;
|
|
584
|
+
var height = doc ? doc.scrollHeight : 0;
|
|
585
|
+
var view = doc ? doc.clientHeight : 0;
|
|
586
|
+
if (scroller) { top = scroller.scrollTop; height = scroller.scrollHeight; view = scroller.clientHeight; }
|
|
587
|
+
var max = height - view;
|
|
588
|
+
var percent = max > 0 ? Math.min(100, Math.max(0, (top / max) * 100)) : 0;
|
|
589
|
+
progressFill.style.width = percent + "%";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (keysButton) keysButton.addEventListener("click", function () { toggleKeys(); });
|
|
593
|
+
if (keysDialog) {
|
|
594
|
+
keysDialog.addEventListener("click", function (event) {
|
|
595
|
+
if (event.target === keysDialog) toggleKeys(false);
|
|
596
|
+
});
|
|
597
|
+
var keysClose = document.getElementById("keys-close");
|
|
598
|
+
if (keysClose) keysClose.addEventListener("click", function () { toggleKeys(false); });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
window.addEventListener("scroll", updateProgress, true);
|
|
602
|
+
window.addEventListener("resize", updateProgress);
|
|
603
|
+
updateProgress();
|
|
604
|
+
|
|
528
605
|
document.addEventListener("keydown", function (event) {
|
|
606
|
+
if (event.key === "Escape" && keysDialog && !keysDialog.hidden) {
|
|
607
|
+
event.preventDefault();
|
|
608
|
+
toggleKeys(false);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
529
611
|
if (paletteOpen()) {
|
|
530
612
|
if (event.key === "Escape") {
|
|
531
613
|
event.preventDefault();
|
|
@@ -533,15 +615,34 @@ export const CLIENT_JS = `(function () {
|
|
|
533
615
|
}
|
|
534
616
|
return;
|
|
535
617
|
}
|
|
618
|
+
if (keysDialog && !keysDialog.hidden) return;
|
|
536
619
|
var target = event.target;
|
|
537
620
|
var editable = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
|
|
538
621
|
if (editable) return;
|
|
539
|
-
if (event.
|
|
622
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
|
540
623
|
event.preventDefault();
|
|
541
624
|
openPalette();
|
|
542
|
-
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
628
|
+
if (event.key === "/") {
|
|
543
629
|
event.preventDefault();
|
|
544
630
|
openPalette();
|
|
631
|
+
} else if (event.key === "?") {
|
|
632
|
+
event.preventDefault();
|
|
633
|
+
toggleKeys();
|
|
634
|
+
} else if (event.key === "u" && wrapButton) {
|
|
635
|
+
event.preventDefault();
|
|
636
|
+
wrapButton.click();
|
|
637
|
+
} else if (event.key === "r" && document.querySelector(".bf-card__actions .bf-segmented")) {
|
|
638
|
+
event.preventDefault();
|
|
639
|
+
toggleRaw();
|
|
640
|
+
} else if (event.key === "n" && hunkAnchors.length > 0) {
|
|
641
|
+
event.preventDefault();
|
|
642
|
+
moveHunk(1);
|
|
643
|
+
} else if (event.key === "p" && hunkAnchors.length > 0) {
|
|
644
|
+
event.preventDefault();
|
|
645
|
+
moveHunk(-1);
|
|
545
646
|
}
|
|
546
647
|
});
|
|
547
648
|
|
|
@@ -28,6 +28,12 @@ export interface ServeConfig {
|
|
|
28
28
|
markdown: boolean;
|
|
29
29
|
/** Files collected for the quick-open index before it is truncated. */
|
|
30
30
|
maxIndexEntries: number;
|
|
31
|
+
/** Offer the read-only git diff view. */
|
|
32
|
+
gitDiff: boolean;
|
|
33
|
+
/** Unified diff context lines. */
|
|
34
|
+
diffContext: number;
|
|
35
|
+
/** Diff lines rendered before the view is truncated. */
|
|
36
|
+
maxDiffLines: number;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export const DEFAULT_CONFIG: ServeConfig = {
|
|
@@ -40,6 +46,9 @@ export const DEFAULT_CONFIG: ServeConfig = {
|
|
|
40
46
|
thumbnails: true,
|
|
41
47
|
markdown: true,
|
|
42
48
|
maxIndexEntries: 20000,
|
|
49
|
+
gitDiff: true,
|
|
50
|
+
diffContext: 3,
|
|
51
|
+
maxDiffLines: 4000,
|
|
43
52
|
};
|
|
44
53
|
|
|
45
54
|
/** Base of the per-session port range. */
|
|
@@ -73,6 +82,9 @@ export function normalizeConfig(
|
|
|
73
82
|
thumbnails: bool(raw.thumbnails, base.thumbnails),
|
|
74
83
|
markdown: bool(raw.markdown, base.markdown),
|
|
75
84
|
maxIndexEntries: clampInteger(raw.maxIndexEntries, base.maxIndexEntries, 1, 1_000_000),
|
|
85
|
+
gitDiff: bool(raw.gitDiff, base.gitDiff),
|
|
86
|
+
diffContext: clampInteger(raw.diffContext, base.diffContext, 0, 100),
|
|
87
|
+
maxDiffLines: clampInteger(raw.maxDiffLines, base.maxDiffLines, 1, 1_000_000),
|
|
76
88
|
};
|
|
77
89
|
}
|
|
78
90
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsing for the git diff view.
|
|
3
|
+
*
|
|
4
|
+
* Turns unified-diff text and `git status --porcelain=v1 -z` output into plain
|
|
5
|
+
* view models. No git, no filesystem, no HTML: `git.ts` runs the commands and
|
|
6
|
+
* `html.ts` renders the result, so the parser can be tested in isolation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The three comparison scopes the diff view offers. */
|
|
10
|
+
export type DiffMode = "head" | "staged" | "worktree";
|
|
11
|
+
|
|
12
|
+
/** Every valid mode, in segmented-control order. */
|
|
13
|
+
export const DIFF_MODES: readonly DiffMode[] = ["head", "staged", "worktree"];
|
|
14
|
+
|
|
15
|
+
/** Whether an untrusted query value names a mode. */
|
|
16
|
+
export function isDiffMode(value: string | null | undefined): value is DiffMode {
|
|
17
|
+
return value === "head" || value === "staged" || value === "worktree";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Revision arguments for `git diff` for a mode, placed before `--`.
|
|
22
|
+
* `head` compares the working tree and index against the commit; `staged`
|
|
23
|
+
* compares the index against the commit; `worktree` compares the working tree
|
|
24
|
+
* against the index.
|
|
25
|
+
*/
|
|
26
|
+
export function diffArgs(mode: DiffMode): string[] {
|
|
27
|
+
if (mode === "staged") return ["--cached"];
|
|
28
|
+
if (mode === "worktree") return [];
|
|
29
|
+
return ["HEAD"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A line's role in a unified diff. */
|
|
33
|
+
export type DiffLineKind = "meta" | "hunk" | "add" | "del" | "ctx" | "note";
|
|
34
|
+
|
|
35
|
+
export interface DiffLine {
|
|
36
|
+
kind: DiffLineKind;
|
|
37
|
+
/** Content without the leading marker for add/del/ctx; raw text otherwise. */
|
|
38
|
+
text: string;
|
|
39
|
+
/** Old-file line number, when the line exists on that side. */
|
|
40
|
+
oldNo?: number;
|
|
41
|
+
/** New-file line number, when the line exists on that side. */
|
|
42
|
+
newNo?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ParsedDiff {
|
|
46
|
+
lines: DiffLine[];
|
|
47
|
+
/** True when `maxLines` cut the diff short. */
|
|
48
|
+
truncated: boolean;
|
|
49
|
+
/** True for a binary diff (`Binary files … differ` / `GIT binary patch`). */
|
|
50
|
+
binary: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
54
|
+
|
|
55
|
+
/** A line git emits for a binary change; there is no text diff to show. */
|
|
56
|
+
const BINARY_LINE = /^(?:Binary files .* differ|GIT binary patch)$/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse unified-diff text into lines tagged by role and line number.
|
|
60
|
+
*
|
|
61
|
+
* `---` / `+++` are treated as file metadata only before the first hunk, so a
|
|
62
|
+
* removed line whose content itself begins with `--` still reads as a deletion.
|
|
63
|
+
* A trailing newline is not a line; `maxLines` bounds the result and sets
|
|
64
|
+
* `truncated`.
|
|
65
|
+
*/
|
|
66
|
+
export function parseUnifiedDiff(text: string, maxLines: number): ParsedDiff {
|
|
67
|
+
const raw = text.split("\n");
|
|
68
|
+
if (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
|
|
69
|
+
const truncated = raw.length > maxLines;
|
|
70
|
+
const lines: DiffLine[] = [];
|
|
71
|
+
let binary = false;
|
|
72
|
+
let inHunk = false;
|
|
73
|
+
let oldNo = 0;
|
|
74
|
+
let newNo = 0;
|
|
75
|
+
|
|
76
|
+
for (let line of truncated ? raw.slice(0, maxLines) : raw) {
|
|
77
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
78
|
+
|
|
79
|
+
const hunk = HUNK_HEADER.exec(line);
|
|
80
|
+
if (hunk) {
|
|
81
|
+
oldNo = Number(hunk[1]);
|
|
82
|
+
newNo = Number(hunk[2]);
|
|
83
|
+
inHunk = true;
|
|
84
|
+
lines.push({ kind: "hunk", text: line });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (inHunk) {
|
|
89
|
+
if (line.startsWith("\\ ")) {
|
|
90
|
+
lines.push({ kind: "note", text: line });
|
|
91
|
+
} else if (line.startsWith("+")) {
|
|
92
|
+
lines.push({ kind: "add", text: line.slice(1), newNo: newNo++ });
|
|
93
|
+
} else if (line.startsWith("-")) {
|
|
94
|
+
lines.push({ kind: "del", text: line.slice(1), oldNo: oldNo++ });
|
|
95
|
+
} else if (line.startsWith(" ") || line === "") {
|
|
96
|
+
lines.push({
|
|
97
|
+
kind: "ctx",
|
|
98
|
+
text: line.startsWith(" ") ? line.slice(1) : "",
|
|
99
|
+
oldNo: oldNo++,
|
|
100
|
+
newNo: newNo++,
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
// A new file block (`diff --git …`) closes the hunk.
|
|
104
|
+
inHunk = false;
|
|
105
|
+
if (BINARY_LINE.test(line)) binary = true;
|
|
106
|
+
lines.push({ kind: line.startsWith("\\ ") ? "note" : "meta", text: line });
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (BINARY_LINE.test(line)) binary = true;
|
|
112
|
+
lines.push({ kind: line.startsWith("\\ ") ? "note" : "meta", text: line });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { lines, truncated, binary };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One changed path from `git status --porcelain=v1 -z`. */
|
|
119
|
+
export interface ChangedFile {
|
|
120
|
+
/** Root-relative, posix-style path. */
|
|
121
|
+
rel: string;
|
|
122
|
+
/** Original path for a rename or copy. */
|
|
123
|
+
origRel?: string;
|
|
124
|
+
/** Index (staged) status character; `" "` when unchanged there. */
|
|
125
|
+
index: string;
|
|
126
|
+
/** Worktree (unstaged) status character; `" "` when unchanged there. */
|
|
127
|
+
worktree: string;
|
|
128
|
+
/** Both status characters are `?`, so the path is untracked. */
|
|
129
|
+
untracked: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse `git status --porcelain=v1 -z`.
|
|
134
|
+
*
|
|
135
|
+
* NUL-delimited records survive spaces and newlines in a path. A rename or copy
|
|
136
|
+
* (`R`/`C` in either column) carries the new path first and the original path as
|
|
137
|
+
* the next field; the destination is the entry's `rel`.
|
|
138
|
+
*/
|
|
139
|
+
export function parseStatusZ(stdout: string): ChangedFile[] {
|
|
140
|
+
const tokens = stdout.split("\0");
|
|
141
|
+
const files: ChangedFile[] = [];
|
|
142
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
143
|
+
const entry = tokens[index];
|
|
144
|
+
if (entry.length < 3 || entry[2] !== " ") continue;
|
|
145
|
+
const x = entry[0];
|
|
146
|
+
const y = entry[1];
|
|
147
|
+
const untracked = x === "?" && y === "?";
|
|
148
|
+
const file: ChangedFile = { rel: entry.slice(3), index: x, worktree: y, untracked };
|
|
149
|
+
if (x === "R" || x === "C" || y === "R" || y === "C") {
|
|
150
|
+
const original = tokens[++index] ?? "";
|
|
151
|
+
if (original) file.origRel = original;
|
|
152
|
+
}
|
|
153
|
+
files.push(file);
|
|
154
|
+
}
|
|
155
|
+
return files;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The single status letter shown in the change list. */
|
|
159
|
+
export function statusLetter(file: ChangedFile): string {
|
|
160
|
+
if (file.untracked) return "?";
|
|
161
|
+
return file.index !== " " ? file.index : file.worktree;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Whether a changed path belongs in a mode's list. */
|
|
165
|
+
export function inMode(file: ChangedFile, mode: DiffMode): boolean {
|
|
166
|
+
if (mode === "staged") return !file.untracked && file.index !== " " && file.index !== "?";
|
|
167
|
+
if (mode === "worktree") return file.untracked || (file.worktree !== " " && file.worktree !== "?");
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The ancestor directories of every changed path, root-relative. A directory in
|
|
173
|
+
* this set contains a change, which lets a listing or tree mark it without
|
|
174
|
+
* walking its subtree.
|
|
175
|
+
*/
|
|
176
|
+
export function changeDirs(files: readonly ChangedFile[]): Set<string> {
|
|
177
|
+
const dirs = new Set<string>();
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
const parts = file.rel.split("/");
|
|
180
|
+
let prefix = "";
|
|
181
|
+
for (let index = 0; index < parts.length - 1; index++) {
|
|
182
|
+
prefix = prefix ? `${prefix}/${parts[index]}` : parts[index];
|
|
183
|
+
dirs.add(prefix);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return dirs;
|
|
187
|
+
}
|
|
@@ -8,7 +8,17 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { createExecRunner, currentBranch, repoRoot, revParse, type RunGit } from "../../lib/git/index.ts";
|
|
11
|
+
import { createExecRunner, currentBranch, hasCommits, repoRoot, revParse, type RunGit } from "../../lib/git/index.ts";
|
|
12
|
+
import {
|
|
13
|
+
diffArgs,
|
|
14
|
+
inMode,
|
|
15
|
+
parseStatusZ,
|
|
16
|
+
parseUnifiedDiff,
|
|
17
|
+
type ChangedFile,
|
|
18
|
+
type DiffLine,
|
|
19
|
+
type DiffMode,
|
|
20
|
+
} from "./diff.ts";
|
|
21
|
+
import { readFileView } from "./files.ts";
|
|
12
22
|
|
|
13
23
|
export interface GitStatus {
|
|
14
24
|
/** Checked-out branch, when HEAD is attached. */
|
|
@@ -72,3 +82,168 @@ export function createGitStatus(
|
|
|
72
82
|
return pending;
|
|
73
83
|
};
|
|
74
84
|
}
|
|
85
|
+
|
|
86
|
+
/** One comparison scope's change list. */
|
|
87
|
+
export interface DiffList {
|
|
88
|
+
mode: DiffMode;
|
|
89
|
+
files: ChangedFile[];
|
|
90
|
+
/** `git diff --stat` output for the scope, or an empty string. */
|
|
91
|
+
stat: string;
|
|
92
|
+
/** Added lines in the scope. */
|
|
93
|
+
insertions: number;
|
|
94
|
+
/** Removed lines in the scope. */
|
|
95
|
+
deletions: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A single file's diff for a scope. */
|
|
99
|
+
export interface DiffFileView {
|
|
100
|
+
mode: DiffMode;
|
|
101
|
+
rel: string;
|
|
102
|
+
lines: DiffLine[];
|
|
103
|
+
/** True for a binary or unreadable change; `lines` is then empty. */
|
|
104
|
+
binary: boolean;
|
|
105
|
+
truncated: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Change list and per-file diffs for the browse pages. Like
|
|
110
|
+
* {@link createGitStatus}, it never throws: a non-repository, a failed git
|
|
111
|
+
* call, or a timeout yields `undefined`, and the page omits the diff UI.
|
|
112
|
+
*/
|
|
113
|
+
export interface GitDiffProvider {
|
|
114
|
+
list(mode: DiffMode): Promise<DiffList | undefined>;
|
|
115
|
+
file(rel: string, mode: DiffMode): Promise<DiffFileView | undefined>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface GitDiffOptions {
|
|
119
|
+
/** How long the status list is reused, in ms (default 2000). */
|
|
120
|
+
ttlMs?: number;
|
|
121
|
+
/** Unified context lines (default 3). */
|
|
122
|
+
context?: number;
|
|
123
|
+
/** Rendered diff lines before truncation (default 4000). */
|
|
124
|
+
maxLines?: number;
|
|
125
|
+
/** Per-call git timeout in ms (default 4000). */
|
|
126
|
+
timeoutMs?: number;
|
|
127
|
+
/** Max bytes read for an untracked file's all-add diff (default 1 MiB). */
|
|
128
|
+
maxFileBytes?: number;
|
|
129
|
+
/** Max lines read for an untracked file's all-add diff (default 5000). */
|
|
130
|
+
maxTextLines?: number;
|
|
131
|
+
/** Wall-clock seam; defaults to the real clock. */
|
|
132
|
+
now?: () => number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface RepoState {
|
|
136
|
+
files: ChangedFile[];
|
|
137
|
+
hasCommits: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const INSERTIONS = /(\d+) insertions?\(\+\)/;
|
|
141
|
+
const DELETIONS = /(\d+) deletions?\(-\)/;
|
|
142
|
+
|
|
143
|
+
/** A count from a `--shortstat` line, or `0` when the line does not carry it. */
|
|
144
|
+
function shortstatCount(text: string, pattern: RegExp): number {
|
|
145
|
+
const match = pattern.exec(text);
|
|
146
|
+
return match ? Number(match[1]) : 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build a read-only diff provider for `root`, with a TTL-cached status list. */
|
|
150
|
+
export function createGitDiff(
|
|
151
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
152
|
+
root: string,
|
|
153
|
+
options: GitDiffOptions = {},
|
|
154
|
+
): GitDiffProvider {
|
|
155
|
+
const exec = createExecRunner(pi);
|
|
156
|
+
const run: RunGit = (args, opts) => exec(args, { timeoutMs: options.timeoutMs ?? 4000, ...opts });
|
|
157
|
+
const ttlMs = options.ttlMs ?? 2000;
|
|
158
|
+
const context = Math.max(0, options.context ?? 3);
|
|
159
|
+
const maxLines = Math.max(1, options.maxLines ?? 4000);
|
|
160
|
+
const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
|
|
161
|
+
const maxTextLines = options.maxTextLines ?? 5000;
|
|
162
|
+
const now = options.now ?? (() => Date.now());
|
|
163
|
+
|
|
164
|
+
let cached: RepoState | undefined;
|
|
165
|
+
let checkedAt = 0;
|
|
166
|
+
let pending: Promise<RepoState | undefined> | undefined;
|
|
167
|
+
|
|
168
|
+
async function read(): Promise<RepoState | undefined> {
|
|
169
|
+
if (!(await repoRoot(run, root))) return undefined;
|
|
170
|
+
const status = await run(["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root });
|
|
171
|
+
if (status.code !== 0) return undefined;
|
|
172
|
+
return { files: parseStatusZ(status.stdout), hasCommits: await hasCommits(run, root) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The cached repository state, or `undefined` outside a repository. */
|
|
176
|
+
function state(): Promise<RepoState | undefined> {
|
|
177
|
+
if (pending) return pending;
|
|
178
|
+
const t = now();
|
|
179
|
+
if (checkedAt !== 0 && t - checkedAt < ttlMs) return Promise.resolve(cached);
|
|
180
|
+
checkedAt = t;
|
|
181
|
+
pending = read()
|
|
182
|
+
.catch(() => undefined)
|
|
183
|
+
.then((next) => {
|
|
184
|
+
cached = next;
|
|
185
|
+
return next;
|
|
186
|
+
})
|
|
187
|
+
.finally(() => {
|
|
188
|
+
pending = undefined;
|
|
189
|
+
});
|
|
190
|
+
return pending;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `head` needs a commit; before the first one, compare the index instead. */
|
|
194
|
+
const effectiveMode = (mode: DiffMode, has: boolean): DiffMode => (mode === "head" && !has ? "staged" : mode);
|
|
195
|
+
|
|
196
|
+
/** An untracked file as an all-add diff, read through the shared file view. */
|
|
197
|
+
async function untracked(rel: string, mode: DiffMode): Promise<DiffFileView> {
|
|
198
|
+
try {
|
|
199
|
+
const view = await readFileView(root, rel, maxFileBytes, maxTextLines);
|
|
200
|
+
if (view.kind === "text" || view.kind === "markdown") {
|
|
201
|
+
// Drop the empty element a trailing newline produces, so the added
|
|
202
|
+
// lines match the count git shows for the same file.
|
|
203
|
+
const last = view.lines.length - 1;
|
|
204
|
+
const source = last >= 0 && view.lines[last] === "" ? view.lines.slice(0, last) : view.lines;
|
|
205
|
+
return {
|
|
206
|
+
mode,
|
|
207
|
+
rel,
|
|
208
|
+
lines: source.map((text, index) => ({ kind: "add" as const, text, newNo: index + 1 })),
|
|
209
|
+
binary: false,
|
|
210
|
+
truncated: view.truncated,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return { mode, rel, lines: [], binary: true, truncated: false };
|
|
214
|
+
} catch {
|
|
215
|
+
return { mode, rel, lines: [], binary: false, truncated: false };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
async list(mode) {
|
|
221
|
+
const current = await state();
|
|
222
|
+
if (!current) return undefined;
|
|
223
|
+
const args = diffArgs(effectiveMode(mode, current.hasCommits));
|
|
224
|
+
const [stat, shortstat] = await Promise.all([
|
|
225
|
+
run(["diff", "--no-color", "--stat", ...args, "--"], { cwd: root }),
|
|
226
|
+
run(["diff", "--no-color", "--shortstat", ...args, "--"], { cwd: root }),
|
|
227
|
+
]);
|
|
228
|
+
const summary = shortstat.code === 0 ? shortstat.stdout : "";
|
|
229
|
+
return {
|
|
230
|
+
mode,
|
|
231
|
+
files: current.files.filter((file) => inMode(file, mode)),
|
|
232
|
+
stat: stat.code === 0 ? stat.stdout.trim() : "",
|
|
233
|
+
insertions: shortstatCount(summary, INSERTIONS),
|
|
234
|
+
deletions: shortstatCount(summary, DELETIONS),
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
async file(rel, mode) {
|
|
238
|
+
const current = await state();
|
|
239
|
+
if (!current) return undefined;
|
|
240
|
+
if (current.files.some((file) => file.rel === rel && file.untracked)) return untracked(rel, mode);
|
|
241
|
+
const result = await run(
|
|
242
|
+
["diff", "--no-color", `-U${context}`, "-M", ...diffArgs(effectiveMode(mode, current.hasCommits)), "--", rel],
|
|
243
|
+
{ cwd: root, env: { GIT_LITERAL_PATHSPECS: "1" } },
|
|
244
|
+
);
|
|
245
|
+
if (result.code !== 0) return undefined;
|
|
246
|
+
return { mode, rel, ...parseUnifiedDiff(result.stdout, maxLines) };
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|