pi-diff-review 0.1.11 → 0.1.13
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 +56 -2
- package/package.json +20 -5
- package/src/comment-manager.ts +73 -0
- package/src/diff-source.ts +36 -10
- package/src/explanation-controller.ts +167 -0
- package/src/index.ts +70 -0
- package/src/render-utils.ts +11 -0
- package/src/review-component.ts +130 -597
- package/src/review-navigation.ts +94 -0
- package/src/review-panes.ts +286 -0
- package/src/split-diff.ts +61 -0
- package/src/types.ts +0 -1
package/README.md
CHANGED
|
@@ -20,14 +20,53 @@ Or install directly from GitHub:
|
|
|
20
20
|
pi install https://github.com/cmpadden/pi-diff-review
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
For local development, clone the repository and install from the local path:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/cmpadden/pi-diff-review
|
|
27
|
+
pi install ./pi-diff-review
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Start a review with `/diff`. By default, this reviews your current unstaged changes:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
/diff
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Review staged changes with `--cached`:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
/diff --cached
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Review a branch or commit range by passing any `git diff` arguments after `/diff`:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
/diff main...HEAD
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`/diff <git-diff-args>` is passed through to `git diff`, so these examples are equivalent to running `git diff`, `git diff --cached`, and `git diff main...HEAD` locally before opening the review UI.
|
|
51
|
+
|
|
52
|
+
### Staged vs. unstaged changes
|
|
53
|
+
|
|
54
|
+
- `/diff` shows unstaged working-tree changes only.
|
|
55
|
+
- `/diff --cached` shows staged changes only.
|
|
56
|
+
- If you have both staged and unstaged edits, run both commands separately to review each set.
|
|
57
|
+
- To review everything relative to a base branch, use a range such as `/diff main...HEAD`.
|
|
58
|
+
|
|
23
59
|
## Features
|
|
24
60
|
|
|
25
61
|
- `/diff` reviews the current unstaged `git diff`
|
|
26
|
-
- `/diff
|
|
62
|
+
- `/diff --cached` reviews staged changes
|
|
63
|
+
- `/diff main...HEAD` reviews changes on the current branch relative to `main`
|
|
64
|
+
- `/diff <git-diff-args>` passes arguments through to `git diff`
|
|
27
65
|
- `j/k` or arrow keys to move
|
|
28
66
|
- `g/G` to jump to the top or bottom of the diff
|
|
29
67
|
- `ctrl-u` / `ctrl-d` to move up/down by half a page
|
|
30
|
-
- `t` toggles the
|
|
68
|
+
- `t` toggles the comments/explanation sidebar
|
|
69
|
+
- `v` toggles the diff between unified and side-by-side split rendering
|
|
31
70
|
- `?` toggles an AI-generated explanation for the current hunk
|
|
32
71
|
- `J/K` to extend a highlighted selection into a comment range
|
|
33
72
|
- `esc` clears the active selection, or exits review when no selection is active
|
|
@@ -39,6 +78,21 @@ pi install https://github.com/cmpadden/pi-diff-review
|
|
|
39
78
|
- Comments are cached per session and restored when reopening the same diff
|
|
40
79
|
- `q` to exit
|
|
41
80
|
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
Install [pre-commit](https://pre-commit.com/) and enable the repository hooks to run typechecking, tests, and formatting checks before each commit:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pre-commit install
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Run the same checks manually with either:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pre-commit run --all-files
|
|
93
|
+
npm run precommit
|
|
94
|
+
```
|
|
95
|
+
|
|
42
96
|
## Release
|
|
43
97
|
|
|
44
98
|
See [RELEASE.md](./RELEASE.md).
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-diff-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Local diff review TUI extension for pi",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "git+https://github.com/cmpadden/pi-diff-review.git"
|
|
@@ -26,16 +27,30 @@
|
|
|
26
27
|
"LICENSE"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
29
|
-
"
|
|
30
|
+
"check": "npm run typecheck && npm test",
|
|
31
|
+
"format": "prettier --write .",
|
|
32
|
+
"precommit": "npm run check && npx prettier --check .",
|
|
33
|
+
"test": "node --import tsx --test test/**/*.test.ts",
|
|
34
|
+
"typecheck": "tsc --noEmit"
|
|
30
35
|
},
|
|
31
36
|
"devDependencies": {
|
|
32
|
-
"
|
|
37
|
+
"@earendil-works/pi-ai": "^0.74.0",
|
|
38
|
+
"@earendil-works/pi-coding-agent": "^0.74.1",
|
|
39
|
+
"@earendil-works/pi-tui": "^0.74.1",
|
|
40
|
+
"@types/node": "^24.12.4",
|
|
41
|
+
"prettier": "^3.5.3",
|
|
42
|
+
"tsx": "^4.22.1",
|
|
43
|
+
"typescript": "^6.0.3"
|
|
33
44
|
},
|
|
34
45
|
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
35
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
36
48
|
"@earendil-works/pi-tui": "*"
|
|
37
49
|
},
|
|
38
50
|
"peerDependenciesMeta": {
|
|
51
|
+
"@earendil-works/pi-ai": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
39
54
|
"@earendil-works/pi-coding-agent": {
|
|
40
55
|
"optional": true
|
|
41
56
|
},
|
|
@@ -46,10 +61,10 @@
|
|
|
46
61
|
"pi": {
|
|
47
62
|
"extensions": [
|
|
48
63
|
"./extensions"
|
|
49
|
-
]
|
|
64
|
+
],
|
|
65
|
+
"image": "https://github.com/user-attachments/assets/3fd00163-5d19-489b-94ed-3d4816c6cad3"
|
|
50
66
|
},
|
|
51
67
|
"dependencies": {
|
|
52
|
-
"@earendil-works/pi-ai": "^0.74.0",
|
|
53
68
|
"@pierre/diffs": "^1.1.21"
|
|
54
69
|
}
|
|
55
70
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ReviewComment, ReviewLine, SelectionBounds } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const GLOBAL_COMMENT_KEY = "__global_diff_comment__";
|
|
4
|
+
|
|
5
|
+
export function getSelectionKey(
|
|
6
|
+
lines: ReviewLine[],
|
|
7
|
+
start: number,
|
|
8
|
+
end: number,
|
|
9
|
+
): string {
|
|
10
|
+
return `${lines[start]?.id ?? start}:${lines[end]?.id ?? end}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function buildGlobalComment(text: string): ReviewComment {
|
|
14
|
+
return {
|
|
15
|
+
id: GLOBAL_COMMENT_KEY,
|
|
16
|
+
filePath: "Overall diff",
|
|
17
|
+
text,
|
|
18
|
+
global: true,
|
|
19
|
+
startLineId: GLOBAL_COMMENT_KEY,
|
|
20
|
+
endLineId: GLOBAL_COMMENT_KEY,
|
|
21
|
+
lineText: "",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildCommentFromSelection(
|
|
26
|
+
lines: ReviewLine[],
|
|
27
|
+
selection: SelectionBounds,
|
|
28
|
+
text: string,
|
|
29
|
+
): ReviewComment {
|
|
30
|
+
const startLine = lines[selection.start]!;
|
|
31
|
+
const endLine = lines[selection.end]!;
|
|
32
|
+
const excerpt = lines
|
|
33
|
+
.slice(selection.start, selection.end + 1)
|
|
34
|
+
.map((line) => line.text)
|
|
35
|
+
.join("\n");
|
|
36
|
+
return {
|
|
37
|
+
id: getSelectionKey(lines, selection.start, selection.end),
|
|
38
|
+
filePath: startLine.filePath ?? endLine.filePath ?? "(unknown file)",
|
|
39
|
+
text,
|
|
40
|
+
startLineId: startLine.id,
|
|
41
|
+
endLineId: endLine.id,
|
|
42
|
+
startOldLineNumber: startLine.oldLineNumber,
|
|
43
|
+
startNewLineNumber: startLine.newLineNumber,
|
|
44
|
+
endOldLineNumber: endLine.oldLineNumber,
|
|
45
|
+
endNewLineNumber: endLine.newLineNumber,
|
|
46
|
+
lineText: excerpt,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildCommentLineKeys(
|
|
51
|
+
comments: Map<string, ReviewComment>,
|
|
52
|
+
lineIndexById: Map<string, number>,
|
|
53
|
+
): Map<number, string[]> {
|
|
54
|
+
const commentLineKeys = new Map<number, string[]>();
|
|
55
|
+
|
|
56
|
+
for (const [key, comment] of comments) {
|
|
57
|
+
const start = lineIndexById.get(comment.startLineId);
|
|
58
|
+
const end = lineIndexById.get(comment.endLineId);
|
|
59
|
+
if (start == null || end == null) continue;
|
|
60
|
+
const from = Math.min(start, end);
|
|
61
|
+
const to = Math.max(start, end);
|
|
62
|
+
for (let index = from; index <= to; index++) {
|
|
63
|
+
const keys = commentLineKeys.get(index);
|
|
64
|
+
if (keys) {
|
|
65
|
+
keys.push(key);
|
|
66
|
+
} else {
|
|
67
|
+
commentLineKeys.set(index, [key]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return commentLineKeys;
|
|
73
|
+
}
|
package/src/diff-source.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
2
|
import type { DiffSource } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
export function parseDiffSource(args: string): DiffSource {
|
|
@@ -64,14 +64,40 @@ function tokenizeDiffArgs(input: string): string[] {
|
|
|
64
64
|
return args;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export const DIFF_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
|
|
68
|
+
|
|
67
69
|
export function getDiff(cwd: string, source: DiffSource): string {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
)
|
|
70
|
+
const args = ["diff", "--no-color", "--unified=3", ...source.args];
|
|
71
|
+
const result = spawnSync("git", args, {
|
|
72
|
+
cwd,
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
maxBuffer: DIFF_MAX_BUFFER_BYTES,
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (result.error) {
|
|
79
|
+
throw new Error(formatSpawnError(result.error));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (result.status !== 0) {
|
|
83
|
+
const stderr = result.stderr.trim();
|
|
84
|
+
throw new Error(
|
|
85
|
+
stderr || `git ${args.join(" ")} exited with status ${result.status}.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result.stdout;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatSpawnError(error: Error & { code?: string }): string {
|
|
93
|
+
if (error.code === "ENOBUFS") {
|
|
94
|
+
return `Diff output exceeded the ${formatBytes(DIFF_MAX_BUFFER_BYTES)} safety limit. Try reviewing a smaller diff or narrowing with git diff pathspecs.`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return error.message;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatBytes(bytes: number): string {
|
|
101
|
+
const mib = bytes / 1024 / 1024;
|
|
102
|
+
return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`;
|
|
77
103
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiffExplainer,
|
|
3
|
+
ExplanationScope,
|
|
4
|
+
ExplanationState,
|
|
5
|
+
} from "./explain.ts";
|
|
6
|
+
import type { ReviewLine, ReviewTui } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
|
+
|
|
10
|
+
export function getCurrentHunkScope(
|
|
11
|
+
lines: ReviewLine[],
|
|
12
|
+
selected: number,
|
|
13
|
+
): ExplanationScope | undefined {
|
|
14
|
+
const selectedLine = lines[selected];
|
|
15
|
+
if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
|
|
16
|
+
|
|
17
|
+
let start = selected;
|
|
18
|
+
while (
|
|
19
|
+
start > 0 &&
|
|
20
|
+
lines[start - 1]?.filePath === selectedLine.filePath &&
|
|
21
|
+
lines[start - 1]?.hunkLabel === selectedLine.hunkLabel
|
|
22
|
+
) {
|
|
23
|
+
start--;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let end = selected;
|
|
27
|
+
while (
|
|
28
|
+
end + 1 < lines.length &&
|
|
29
|
+
lines[end + 1]?.filePath === selectedLine.filePath &&
|
|
30
|
+
lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
|
|
31
|
+
) {
|
|
32
|
+
end++;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const diffText = lines
|
|
36
|
+
.slice(start, end + 1)
|
|
37
|
+
.map((line) => line.text)
|
|
38
|
+
.join("\n");
|
|
39
|
+
return {
|
|
40
|
+
key: `hunk:${selectedLine.filePath}:${selectedLine.hunkLabel}:${start}:${end}`,
|
|
41
|
+
kind: "hunk",
|
|
42
|
+
title: `${selectedLine.filePath} ${selectedLine.hunkLabel}`,
|
|
43
|
+
filePath: selectedLine.filePath,
|
|
44
|
+
diffText,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class ExplanationController {
|
|
49
|
+
readonly explanations = new Map<string, ExplanationState>();
|
|
50
|
+
private abortController?: AbortController;
|
|
51
|
+
private requestId = 0;
|
|
52
|
+
private loadingFrame = 0;
|
|
53
|
+
private loadingTimer?: ReturnType<typeof setInterval>;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
private readonly tui: ReviewTui,
|
|
57
|
+
private readonly explainer?: DiffExplainer,
|
|
58
|
+
cachedExplanations: Map<string, string> = new Map(),
|
|
59
|
+
private readonly onExplanationsChanged?: (
|
|
60
|
+
explanations: Map<string, string>,
|
|
61
|
+
) => void,
|
|
62
|
+
) {
|
|
63
|
+
for (const [key, text] of cachedExplanations) {
|
|
64
|
+
const trimmed = text.trim();
|
|
65
|
+
if (trimmed)
|
|
66
|
+
this.explanations.set(key, { status: "ready", text: trimmed });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get isAvailable(): boolean {
|
|
71
|
+
return this.explainer != null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getState(scope: ExplanationScope | undefined): ExplanationState | undefined {
|
|
75
|
+
return scope ? this.explanations.get(scope.key) : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getLoadingFrame(): string {
|
|
79
|
+
return LOADING_FRAMES[this.loadingFrame % LOADING_FRAMES.length] ?? "⠋";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ensure(scope: ExplanationScope | undefined): void {
|
|
83
|
+
if (!scope || !this.explainer) return;
|
|
84
|
+
if (this.explanations.has(scope.key)) return;
|
|
85
|
+
|
|
86
|
+
this.abortController?.abort();
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
this.abortController = controller;
|
|
89
|
+
const requestId = ++this.requestId;
|
|
90
|
+
let text = "";
|
|
91
|
+
|
|
92
|
+
this.explanations.set(scope.key, { status: "loading", text });
|
|
93
|
+
this.startLoadingTimer();
|
|
94
|
+
|
|
95
|
+
void this.explainer
|
|
96
|
+
.explain(scope, {
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
onDelta: (delta) => {
|
|
99
|
+
if (requestId !== this.requestId) return;
|
|
100
|
+
text += delta;
|
|
101
|
+
this.explanations.set(scope.key, { status: "loading", text });
|
|
102
|
+
this.tui.requestRender();
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
.then((finalText) => {
|
|
106
|
+
if (requestId !== this.requestId) return;
|
|
107
|
+
this.explanations.set(scope.key, {
|
|
108
|
+
status: "ready",
|
|
109
|
+
text: finalText.trim() || text.trim() || "No explanation returned.",
|
|
110
|
+
});
|
|
111
|
+
this.emitExplanationsChanged();
|
|
112
|
+
})
|
|
113
|
+
.catch((error) => {
|
|
114
|
+
if (requestId !== this.requestId) return;
|
|
115
|
+
if (controller.signal.aborted) return;
|
|
116
|
+
this.explanations.set(scope.key, {
|
|
117
|
+
status: "error",
|
|
118
|
+
message: error instanceof Error ? error.message : String(error),
|
|
119
|
+
});
|
|
120
|
+
})
|
|
121
|
+
.finally(() => {
|
|
122
|
+
if (requestId !== this.requestId) return;
|
|
123
|
+
this.stopLoadingTimerIfIdle();
|
|
124
|
+
this.tui.requestRender();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.tui.requestRender();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
dispose(): void {
|
|
131
|
+
this.abortController?.abort();
|
|
132
|
+
this.stopLoadingTimer();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private emitExplanationsChanged(): void {
|
|
136
|
+
if (!this.onExplanationsChanged) return;
|
|
137
|
+
|
|
138
|
+
const readyExplanations = new Map<string, string>();
|
|
139
|
+
for (const [key, explanation] of this.explanations) {
|
|
140
|
+
if (explanation.status === "ready") {
|
|
141
|
+
readyExplanations.set(key, explanation.text);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
this.onExplanationsChanged(readyExplanations);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private startLoadingTimer(): void {
|
|
148
|
+
if (this.loadingTimer) return;
|
|
149
|
+
this.loadingTimer = setInterval(() => {
|
|
150
|
+
this.loadingFrame++;
|
|
151
|
+
this.tui.requestRender();
|
|
152
|
+
}, 120);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private stopLoadingTimerIfIdle(): void {
|
|
156
|
+
const hasLoading = [...this.explanations.values()].some(
|
|
157
|
+
(explanation) => explanation.status === "loading",
|
|
158
|
+
);
|
|
159
|
+
if (!hasLoading) this.stopLoadingTimer();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private stopLoadingTimer(): void {
|
|
163
|
+
if (!this.loadingTimer) return;
|
|
164
|
+
clearInterval(this.loadingTimer);
|
|
165
|
+
this.loadingTimer = undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { ReviewComponent } from "./review-component.ts";
|
|
|
11
11
|
import type { DiffSource, ReviewComment, ReviewResult } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
const DIFF_REVIEW_CACHE_ENTRY = "pi-diff-review-cache";
|
|
14
|
+
const DIFF_REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
|
|
14
15
|
|
|
15
16
|
type DiffReviewCacheEntry = {
|
|
16
17
|
cacheKey: string;
|
|
@@ -18,6 +19,12 @@ type DiffReviewCacheEntry = {
|
|
|
18
19
|
updatedAt: number;
|
|
19
20
|
};
|
|
20
21
|
|
|
22
|
+
type DiffExplanationCacheEntry = {
|
|
23
|
+
cacheKey: string;
|
|
24
|
+
explanations: Record<string, string>;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
21
28
|
function getDiffCacheKey(
|
|
22
29
|
cwd: string,
|
|
23
30
|
source: DiffSource,
|
|
@@ -71,6 +78,58 @@ function persistCachedComments(
|
|
|
71
78
|
} satisfies DiffReviewCacheEntry);
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
function getCachedExplanations(
|
|
82
|
+
ctx: ExtensionCommandContext,
|
|
83
|
+
cacheKey: string,
|
|
84
|
+
): Map<string, string> {
|
|
85
|
+
let latest: DiffExplanationCacheEntry | undefined;
|
|
86
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
87
|
+
if (
|
|
88
|
+
entry.type !== "custom" ||
|
|
89
|
+
entry.customType !== DIFF_REVIEW_EXPLANATION_CACHE_ENTRY
|
|
90
|
+
) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const data = entry.data as Partial<DiffExplanationCacheEntry> | undefined;
|
|
95
|
+
if (
|
|
96
|
+
data?.cacheKey !== cacheKey ||
|
|
97
|
+
!data.explanations ||
|
|
98
|
+
typeof data.explanations !== "object" ||
|
|
99
|
+
Array.isArray(data.explanations)
|
|
100
|
+
) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
105
|
+
latest = {
|
|
106
|
+
cacheKey: data.cacheKey,
|
|
107
|
+
explanations: Object.fromEntries(
|
|
108
|
+
Object.entries(data.explanations).filter(
|
|
109
|
+
(entry): entry is [string, string] =>
|
|
110
|
+
typeof entry[0] === "string" && typeof entry[1] === "string",
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
updatedAt: data.updatedAt ?? 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return new Map(Object.entries(latest?.explanations ?? {}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function persistCachedExplanations(
|
|
122
|
+
pi: ExtensionAPI,
|
|
123
|
+
cacheKey: string,
|
|
124
|
+
explanations: Map<string, string>,
|
|
125
|
+
): void {
|
|
126
|
+
pi.appendEntry(DIFF_REVIEW_EXPLANATION_CACHE_ENTRY, {
|
|
127
|
+
cacheKey,
|
|
128
|
+
explanations: Object.fromEntries(explanations),
|
|
129
|
+
updatedAt: Date.now(),
|
|
130
|
+
} satisfies DiffExplanationCacheEntry);
|
|
131
|
+
}
|
|
132
|
+
|
|
74
133
|
export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
75
134
|
pi.registerCommand("diff", {
|
|
76
135
|
description: "Review a git diff in a custom TUI (/diff [git diff args])",
|
|
@@ -94,12 +153,19 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
|
94
153
|
const reviewLines = parseDiff(diffText);
|
|
95
154
|
const cacheKey = getDiffCacheKey(ctx.cwd, source, diffText);
|
|
96
155
|
const comments = getCachedComments(ctx, cacheKey);
|
|
156
|
+
const explanations = getCachedExplanations(ctx, cacheKey);
|
|
97
157
|
if (comments.size > 0) {
|
|
98
158
|
ctx.ui.notify(
|
|
99
159
|
`Restored ${comments.size} cached diff comment${comments.size === 1 ? "" : "s"}.`,
|
|
100
160
|
"info",
|
|
101
161
|
);
|
|
102
162
|
}
|
|
163
|
+
if (explanations.size > 0) {
|
|
164
|
+
ctx.ui.notify(
|
|
165
|
+
`Restored ${explanations.size} cached hunk explanation${explanations.size === 1 ? "" : "s"}.`,
|
|
166
|
+
"info",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
103
169
|
|
|
104
170
|
const result = await ctx.ui.custom<ReviewResult>(
|
|
105
171
|
(tui, theme, _keybindings, done) => {
|
|
@@ -114,6 +180,10 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
|
114
180
|
(updatedComments) => {
|
|
115
181
|
persistCachedComments(pi, cacheKey, updatedComments.values());
|
|
116
182
|
},
|
|
183
|
+
explanations,
|
|
184
|
+
(updatedExplanations) => {
|
|
185
|
+
persistCachedExplanations(pi, cacheKey, updatedExplanations);
|
|
186
|
+
},
|
|
117
187
|
);
|
|
118
188
|
},
|
|
119
189
|
);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export function padToWidth(text: string, width: number): string {
|
|
4
|
+
const visible = visibleWidth(text);
|
|
5
|
+
if (visible >= width) return truncateToWidth(text, width);
|
|
6
|
+
return text + " ".repeat(width - visible);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function lineNumberCell(value?: number): string {
|
|
10
|
+
return value == null ? " " : String(value).padStart(4, " ");
|
|
11
|
+
}
|