pi-diff-review 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/extensions/review.ts +607 -56
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -8,6 +8,16 @@ Easily provide code reviews directly within [pi](https://pi.dev/).
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
+
Install from npm:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:pi-diff-review
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Package: https://www.npmjs.com/package/pi-diff-review
|
|
18
|
+
|
|
19
|
+
Or install directly from GitHub:
|
|
20
|
+
|
|
11
21
|
```bash
|
|
12
22
|
pi install https://github.com/cmpadden/pi-diff-review
|
|
13
23
|
```
|
|
@@ -17,6 +27,8 @@ pi install https://github.com/cmpadden/pi-diff-review
|
|
|
17
27
|
- `/diff` reviews the current unstaged `git diff`
|
|
18
28
|
- `/diff <git-diff-args>` passes arguments through to `git diff` (for example `/diff main...HEAD`)
|
|
19
29
|
- `j/k` or arrow keys to move
|
|
30
|
+
- `ctrl-u` / `ctrl-d` to move up/down by half a page
|
|
31
|
+
- `t` toggles the diff between unified and side-by-side split rendering
|
|
20
32
|
- `J/K` to extend a highlighted selection into a comment range
|
|
21
33
|
- `esc` clears the active selection, or exits review when no selection is active
|
|
22
34
|
- `n/p` to jump hunks
|
package/extensions/review.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { parsePatchFiles } from "@pierre/diffs";
|
|
3
|
+
import type {
|
|
4
|
+
ChangeContent,
|
|
5
|
+
ContextContent,
|
|
6
|
+
FileDiffMetadata,
|
|
7
|
+
Hunk,
|
|
8
|
+
} from "@pierre/diffs";
|
|
2
9
|
import type {
|
|
3
10
|
ExtensionAPI,
|
|
4
11
|
ExtensionCommandContext,
|
|
@@ -53,6 +60,18 @@ type DiffSource = {
|
|
|
53
60
|
args: string[];
|
|
54
61
|
};
|
|
55
62
|
|
|
63
|
+
type ReviewLayout = "side-by-side" | "stacked";
|
|
64
|
+
type DiffRenderMode = "unified" | "split";
|
|
65
|
+
|
|
66
|
+
type SplitDiffCell = {
|
|
67
|
+
line: ReviewLine;
|
|
68
|
+
index: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type SplitDiffRow =
|
|
72
|
+
| { kind: "full"; cell: SplitDiffCell }
|
|
73
|
+
| { kind: "split"; left?: SplitDiffCell; right?: SplitDiffCell };
|
|
74
|
+
|
|
56
75
|
function parseDiffSource(args: string): DiffSource {
|
|
57
76
|
const trimmed = args.trim();
|
|
58
77
|
if (!trimmed) {
|
|
@@ -72,14 +91,284 @@ function parseDiffSource(args: string): DiffSource {
|
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
function getDiff(cwd: string, source: DiffSource): string {
|
|
75
|
-
return execFileSync(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
return execFileSync(
|
|
95
|
+
"git",
|
|
96
|
+
["diff", "--no-color", "--unified=3", ...source.args],
|
|
97
|
+
{
|
|
98
|
+
cwd,
|
|
99
|
+
encoding: "utf8",
|
|
100
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
101
|
+
},
|
|
102
|
+
);
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
function parseDiff(diffText: string): ReviewLine[] {
|
|
106
|
+
try {
|
|
107
|
+
const reviewLines = parseDiffWithPierre(diffText);
|
|
108
|
+
if (reviewLines.length > 0) return reviewLines;
|
|
109
|
+
} catch {
|
|
110
|
+
// Fall back to the local parser for any patch formats @pierre/diffs does
|
|
111
|
+
// not recognize. The review UI should remain available even if the richer
|
|
112
|
+
// parser fails on unusual diff output.
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return parseDiffManual(diffText);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseDiffWithPierre(diffText: string): ReviewLine[] {
|
|
119
|
+
const patches = parsePatchFiles(diffText);
|
|
120
|
+
const parsed: ReviewLine[] = [];
|
|
121
|
+
let lineIndex = 0;
|
|
122
|
+
|
|
123
|
+
const pushLine = (line: Omit<ReviewLine, "id">) => {
|
|
124
|
+
parsed.push({ id: `line-${lineIndex++}`, ...line });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
for (const patch of patches) {
|
|
128
|
+
if (patch.patchMetadata?.trim()) {
|
|
129
|
+
for (const line of patch.patchMetadata.trimEnd().split("\n")) {
|
|
130
|
+
pushLine({ kind: "meta", text: line, commentable: false });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const file of patch.files) {
|
|
135
|
+
appendPierreFileDiff(file, pushLine);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return parsed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function appendPierreFileDiff(
|
|
143
|
+
file: FileDiffMetadata,
|
|
144
|
+
pushLine: (line: Omit<ReviewLine, "id">) => void,
|
|
145
|
+
): void {
|
|
146
|
+
const previousFile =
|
|
147
|
+
file.prevName ?? (file.type === "new" ? undefined : file.name);
|
|
148
|
+
const nextFile = file.type === "deleted" ? undefined : file.name;
|
|
149
|
+
const displayPreviousFile = previousFile ?? file.name;
|
|
150
|
+
const displayNextFile = nextFile ?? file.name;
|
|
151
|
+
const currentFile = nextFile ?? previousFile ?? file.name;
|
|
152
|
+
|
|
153
|
+
pushLine({
|
|
154
|
+
kind: "meta",
|
|
155
|
+
text: `diff --git a/${displayPreviousFile} b/${displayNextFile}`,
|
|
156
|
+
filePath: currentFile,
|
|
157
|
+
commentable: false,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (file.type === "new" && file.mode) {
|
|
161
|
+
pushLine({
|
|
162
|
+
kind: "meta",
|
|
163
|
+
text: `new file mode ${file.mode}`,
|
|
164
|
+
filePath: currentFile,
|
|
165
|
+
commentable: false,
|
|
166
|
+
});
|
|
167
|
+
} else if (file.type === "deleted" && file.mode) {
|
|
168
|
+
pushLine({
|
|
169
|
+
kind: "meta",
|
|
170
|
+
text: `deleted file mode ${file.mode}`,
|
|
171
|
+
filePath: currentFile,
|
|
172
|
+
commentable: false,
|
|
173
|
+
});
|
|
174
|
+
} else if (file.prevMode && file.mode && file.prevMode !== file.mode) {
|
|
175
|
+
pushLine({
|
|
176
|
+
kind: "meta",
|
|
177
|
+
text: `old mode ${file.prevMode}`,
|
|
178
|
+
filePath: currentFile,
|
|
179
|
+
commentable: false,
|
|
180
|
+
});
|
|
181
|
+
pushLine({
|
|
182
|
+
kind: "meta",
|
|
183
|
+
text: `new mode ${file.mode}`,
|
|
184
|
+
filePath: currentFile,
|
|
185
|
+
commentable: false,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (file.prevObjectId && file.newObjectId) {
|
|
190
|
+
pushLine({
|
|
191
|
+
kind: "meta",
|
|
192
|
+
text: `index ${file.prevObjectId}..${file.newObjectId}${file.mode ? ` ${file.mode}` : ""}`,
|
|
193
|
+
filePath: currentFile,
|
|
194
|
+
commentable: false,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (file.type === "rename-pure" || file.type === "rename-changed") {
|
|
199
|
+
if (file.prevName) {
|
|
200
|
+
pushLine({
|
|
201
|
+
kind: "meta",
|
|
202
|
+
text: `rename from ${file.prevName}`,
|
|
203
|
+
filePath: currentFile,
|
|
204
|
+
commentable: false,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
pushLine({
|
|
208
|
+
kind: "meta",
|
|
209
|
+
text: `rename to ${file.name}`,
|
|
210
|
+
filePath: currentFile,
|
|
211
|
+
commentable: false,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (file.hunks.length === 0) return;
|
|
216
|
+
|
|
217
|
+
pushLine({
|
|
218
|
+
kind: "meta",
|
|
219
|
+
text: previousFile ? `--- a/${previousFile}` : "--- /dev/null",
|
|
220
|
+
filePath: currentFile,
|
|
221
|
+
commentable: false,
|
|
222
|
+
});
|
|
223
|
+
pushLine({
|
|
224
|
+
kind: "meta",
|
|
225
|
+
text: nextFile ? `+++ b/${nextFile}` : "+++ /dev/null",
|
|
226
|
+
filePath: currentFile,
|
|
227
|
+
commentable: false,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
for (const hunk of file.hunks) {
|
|
231
|
+
appendPierreHunk(file, hunk, currentFile, pushLine);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function appendPierreHunk(
|
|
236
|
+
file: FileDiffMetadata,
|
|
237
|
+
hunk: Hunk,
|
|
238
|
+
currentFile: string,
|
|
239
|
+
pushLine: (line: Omit<ReviewLine, "id">) => void,
|
|
240
|
+
): void {
|
|
241
|
+
const hunkLabel = hunk.hunkSpecs?.trimEnd() ?? "@@";
|
|
242
|
+
let oldLine = hunk.deletionStart;
|
|
243
|
+
let newLine = hunk.additionStart;
|
|
244
|
+
let deletionIndex = hunk.deletionLineIndex;
|
|
245
|
+
let additionIndex = hunk.additionLineIndex;
|
|
246
|
+
|
|
247
|
+
pushLine({
|
|
248
|
+
kind: "hunk",
|
|
249
|
+
text: hunkLabel,
|
|
250
|
+
filePath: currentFile,
|
|
251
|
+
commentable: false,
|
|
252
|
+
hunkLabel,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
for (const content of hunk.hunkContent) {
|
|
256
|
+
if (content.type === "context") {
|
|
257
|
+
({ oldLine, newLine, deletionIndex, additionIndex } = appendPierreContext(
|
|
258
|
+
file,
|
|
259
|
+
content,
|
|
260
|
+
currentFile,
|
|
261
|
+
hunkLabel,
|
|
262
|
+
oldLine,
|
|
263
|
+
newLine,
|
|
264
|
+
deletionIndex,
|
|
265
|
+
additionIndex,
|
|
266
|
+
pushLine,
|
|
267
|
+
));
|
|
268
|
+
} else {
|
|
269
|
+
({ oldLine, newLine, deletionIndex, additionIndex } = appendPierreChange(
|
|
270
|
+
file,
|
|
271
|
+
content,
|
|
272
|
+
currentFile,
|
|
273
|
+
hunkLabel,
|
|
274
|
+
oldLine,
|
|
275
|
+
newLine,
|
|
276
|
+
deletionIndex,
|
|
277
|
+
additionIndex,
|
|
278
|
+
pushLine,
|
|
279
|
+
));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
type PierreLineState = {
|
|
285
|
+
oldLine: number;
|
|
286
|
+
newLine: number;
|
|
287
|
+
deletionIndex: number;
|
|
288
|
+
additionIndex: number;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
function appendPierreContext(
|
|
292
|
+
file: FileDiffMetadata,
|
|
293
|
+
content: ContextContent,
|
|
294
|
+
currentFile: string,
|
|
295
|
+
hunkLabel: string,
|
|
296
|
+
oldLine: number,
|
|
297
|
+
newLine: number,
|
|
298
|
+
deletionIndex: number,
|
|
299
|
+
additionIndex: number,
|
|
300
|
+
pushLine: (line: Omit<ReviewLine, "id">) => void,
|
|
301
|
+
): PierreLineState {
|
|
302
|
+
for (let i = 0; i < content.lines; i++) {
|
|
303
|
+
const lineText =
|
|
304
|
+
file.deletionLines[deletionIndex] ??
|
|
305
|
+
file.additionLines[additionIndex] ??
|
|
306
|
+
"";
|
|
307
|
+
pushLine({
|
|
308
|
+
kind: "context",
|
|
309
|
+
text: ` ${stripLineEnding(lineText)}`,
|
|
310
|
+
filePath: currentFile,
|
|
311
|
+
oldLineNumber: oldLine,
|
|
312
|
+
newLineNumber: newLine,
|
|
313
|
+
commentable: true,
|
|
314
|
+
hunkLabel,
|
|
315
|
+
});
|
|
316
|
+
oldLine++;
|
|
317
|
+
newLine++;
|
|
318
|
+
deletionIndex++;
|
|
319
|
+
additionIndex++;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { oldLine, newLine, deletionIndex, additionIndex };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function appendPierreChange(
|
|
326
|
+
file: FileDiffMetadata,
|
|
327
|
+
content: ChangeContent,
|
|
328
|
+
currentFile: string,
|
|
329
|
+
hunkLabel: string,
|
|
330
|
+
oldLine: number,
|
|
331
|
+
newLine: number,
|
|
332
|
+
deletionIndex: number,
|
|
333
|
+
additionIndex: number,
|
|
334
|
+
pushLine: (line: Omit<ReviewLine, "id">) => void,
|
|
335
|
+
): PierreLineState {
|
|
336
|
+
for (let i = 0; i < content.deletions; i++) {
|
|
337
|
+
const lineText = file.deletionLines[deletionIndex] ?? "";
|
|
338
|
+
pushLine({
|
|
339
|
+
kind: "remove",
|
|
340
|
+
text: `-${stripLineEnding(lineText)}`,
|
|
341
|
+
filePath: currentFile,
|
|
342
|
+
oldLineNumber: oldLine,
|
|
343
|
+
commentable: true,
|
|
344
|
+
hunkLabel,
|
|
345
|
+
});
|
|
346
|
+
oldLine++;
|
|
347
|
+
deletionIndex++;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (let i = 0; i < content.additions; i++) {
|
|
351
|
+
const lineText = file.additionLines[additionIndex] ?? "";
|
|
352
|
+
pushLine({
|
|
353
|
+
kind: "add",
|
|
354
|
+
text: `+${stripLineEnding(lineText)}`,
|
|
355
|
+
filePath: currentFile,
|
|
356
|
+
newLineNumber: newLine,
|
|
357
|
+
commentable: true,
|
|
358
|
+
hunkLabel,
|
|
359
|
+
});
|
|
360
|
+
newLine++;
|
|
361
|
+
additionIndex++;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { oldLine, newLine, deletionIndex, additionIndex };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function stripLineEnding(text: string): string {
|
|
368
|
+
return text.replace(/\r?\n$/, "");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function parseDiffManual(diffText: string): ReviewLine[] {
|
|
83
372
|
const lines = diffText.split("\n");
|
|
84
373
|
const parsed: ReviewLine[] = [];
|
|
85
374
|
|
|
@@ -279,6 +568,8 @@ class ReviewComponent {
|
|
|
279
568
|
private editMode = false;
|
|
280
569
|
private editingCommentKey?: string;
|
|
281
570
|
private selectionAnchor?: number;
|
|
571
|
+
private layout: ReviewLayout = "side-by-side";
|
|
572
|
+
private diffRenderMode: DiffRenderMode = "unified";
|
|
282
573
|
private editor: Editor;
|
|
283
574
|
|
|
284
575
|
constructor(
|
|
@@ -318,7 +609,10 @@ class ReviewComponent {
|
|
|
318
609
|
if (!trimmed) {
|
|
319
610
|
this.comments.delete(key);
|
|
320
611
|
} else {
|
|
321
|
-
this.comments.set(
|
|
612
|
+
this.comments.set(
|
|
613
|
+
key,
|
|
614
|
+
this.buildCommentFromSelection(selection, trimmed),
|
|
615
|
+
);
|
|
322
616
|
}
|
|
323
617
|
|
|
324
618
|
this.exitEditMode();
|
|
@@ -348,6 +642,18 @@ class ReviewComponent {
|
|
|
348
642
|
this.done({ action: "cancel" });
|
|
349
643
|
return;
|
|
350
644
|
}
|
|
645
|
+
if (data === "t") {
|
|
646
|
+
this.toggleDiffRenderMode();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (matchesKey(data, "ctrl+d")) {
|
|
650
|
+
this.move(this.getPageMoveAmount());
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
654
|
+
this.move(-this.getPageMoveAmount());
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
351
657
|
if (data === "j" || matchesKey(data, "down")) {
|
|
352
658
|
this.move(1);
|
|
353
659
|
return;
|
|
@@ -390,33 +696,13 @@ class ReviewComponent {
|
|
|
390
696
|
}
|
|
391
697
|
|
|
392
698
|
render(width: number): string[] {
|
|
393
|
-
const
|
|
394
|
-
const headerHeight = 3;
|
|
395
|
-
const footerHeight = 2;
|
|
396
|
-
const viewportHeight = Math.max(
|
|
397
|
-
6,
|
|
398
|
-
terminalRows - headerHeight - footerHeight,
|
|
399
|
-
);
|
|
400
|
-
this.ensureScroll(viewportHeight);
|
|
401
|
-
|
|
402
|
-
const rightWidth = Math.max(28, Math.floor(width * 0.34));
|
|
403
|
-
const separatorWidth = 3;
|
|
404
|
-
const leftWidth = Math.max(30, width - rightWidth - separatorWidth);
|
|
405
|
-
|
|
699
|
+
const viewportHeight = this.getContentHeight();
|
|
406
700
|
const selectedLine = this.lines[this.selected];
|
|
407
|
-
const rightPane = this.renderRightPane(
|
|
408
|
-
rightWidth,
|
|
409
|
-
viewportHeight,
|
|
410
|
-
selectedLine,
|
|
411
|
-
);
|
|
412
701
|
const output: string[] = [];
|
|
413
702
|
|
|
414
703
|
output.push(
|
|
415
704
|
truncateToWidth(
|
|
416
|
-
this.theme.fg(
|
|
417
|
-
"accent",
|
|
418
|
-
this.theme.bold(`Local Review: ${this.title}`),
|
|
419
|
-
),
|
|
705
|
+
this.theme.fg("accent", this.theme.bold(`Local Review: ${this.title}`)),
|
|
420
706
|
width,
|
|
421
707
|
),
|
|
422
708
|
);
|
|
@@ -428,29 +714,25 @@ class ReviewComponent {
|
|
|
428
714
|
? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
|
|
429
715
|
: this.hasSelection()
|
|
430
716
|
? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • R submit`
|
|
431
|
-
: `${this.lines.length} lines • ${this.comments.size} comments • j/k move • J/K extend • c comment • x delete • n/p hunk • R submit • q quit`,
|
|
717
|
+
: `${this.lines.length} lines • ${this.comments.size} comments • j/k move • ctrl-u/d page • t unified/split • J/K extend • c comment • x delete • n/p hunk • R submit • q quit`,
|
|
432
718
|
),
|
|
433
719
|
width,
|
|
434
720
|
),
|
|
435
721
|
);
|
|
436
722
|
output.push(this.theme.fg("border", "─".repeat(width)));
|
|
437
723
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
: " ".repeat(leftWidth);
|
|
451
|
-
const right = rightPane[row] ?? " ".repeat(rightWidth);
|
|
452
|
-
const combined = `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`;
|
|
453
|
-
output.push(truncateToWidth(combined, width));
|
|
724
|
+
if (this.layout === "side-by-side") {
|
|
725
|
+
this.ensureScroll(viewportHeight);
|
|
726
|
+
output.push(
|
|
727
|
+
...this.renderSideBySide(width, viewportHeight, selectedLine),
|
|
728
|
+
);
|
|
729
|
+
} else {
|
|
730
|
+
const { diffHeight, commentsHeight } =
|
|
731
|
+
this.getStackedHeights(viewportHeight);
|
|
732
|
+
this.ensureScroll(diffHeight);
|
|
733
|
+
output.push(
|
|
734
|
+
...this.renderStacked(width, diffHeight, commentsHeight, selectedLine),
|
|
735
|
+
);
|
|
454
736
|
}
|
|
455
737
|
|
|
456
738
|
output.push(this.theme.fg("border", "─".repeat(width)));
|
|
@@ -463,6 +745,140 @@ class ReviewComponent {
|
|
|
463
745
|
return output;
|
|
464
746
|
}
|
|
465
747
|
|
|
748
|
+
private renderSideBySide(
|
|
749
|
+
width: number,
|
|
750
|
+
height: number,
|
|
751
|
+
selectedLine?: ReviewLine,
|
|
752
|
+
): string[] {
|
|
753
|
+
const rightWidth = Math.max(28, Math.floor(width * 0.34));
|
|
754
|
+
const separatorWidth = 3;
|
|
755
|
+
const leftWidth = Math.max(30, width - rightWidth - separatorWidth);
|
|
756
|
+
const rightPane = this.renderRightPane(rightWidth, height, selectedLine);
|
|
757
|
+
const output: string[] = [];
|
|
758
|
+
const diffPane = this.renderDiffRows(leftWidth, height);
|
|
759
|
+
|
|
760
|
+
for (let row = 0; row < height; row++) {
|
|
761
|
+
const left = diffPane[row] ?? " ".repeat(leftWidth);
|
|
762
|
+
const right = rightPane[row] ?? " ".repeat(rightWidth);
|
|
763
|
+
const combined = `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`;
|
|
764
|
+
output.push(truncateToWidth(combined, width));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return output;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private renderStacked(
|
|
771
|
+
width: number,
|
|
772
|
+
diffHeight: number,
|
|
773
|
+
commentsHeight: number,
|
|
774
|
+
selectedLine?: ReviewLine,
|
|
775
|
+
): string[] {
|
|
776
|
+
const comments = this.renderRightPane(width, commentsHeight, selectedLine);
|
|
777
|
+
return [
|
|
778
|
+
...this.renderDiffRows(width, diffHeight),
|
|
779
|
+
this.theme.fg("borderMuted", "─".repeat(width)),
|
|
780
|
+
...Array.from({ length: commentsHeight }, (_, index) =>
|
|
781
|
+
padToWidth(truncateToWidth(comments[index] ?? "", width), width),
|
|
782
|
+
),
|
|
783
|
+
];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private renderDiffRows(width: number, height: number): string[] {
|
|
787
|
+
return this.diffRenderMode === "split"
|
|
788
|
+
? this.renderSplitDiffRows(width, height)
|
|
789
|
+
: this.renderUnifiedDiffRows(width, height);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private renderUnifiedDiffRows(width: number, height: number): string[] {
|
|
793
|
+
const output: string[] = [];
|
|
794
|
+
const selection = this.getSelectionBounds();
|
|
795
|
+
|
|
796
|
+
for (let row = 0; row < height; row++) {
|
|
797
|
+
const index = this.scrollTop + row;
|
|
798
|
+
const line = this.lines[index];
|
|
799
|
+
output.push(
|
|
800
|
+
line
|
|
801
|
+
? this.renderDiffLine(
|
|
802
|
+
line,
|
|
803
|
+
index,
|
|
804
|
+
width,
|
|
805
|
+
index === this.selected,
|
|
806
|
+
selection,
|
|
807
|
+
)
|
|
808
|
+
: " ".repeat(width),
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return output;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private renderSplitDiffRows(width: number, height: number): string[] {
|
|
816
|
+
const rows = this.buildSplitDiffRows();
|
|
817
|
+
const output: string[] = [];
|
|
818
|
+
const separatorWidth = 3;
|
|
819
|
+
const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
|
|
820
|
+
const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
|
|
821
|
+
|
|
822
|
+
for (let row = 0; row < height; row++) {
|
|
823
|
+
const splitRow = rows[this.scrollTop + row];
|
|
824
|
+
if (!splitRow) {
|
|
825
|
+
output.push(" ".repeat(width));
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (splitRow.kind === "full") {
|
|
830
|
+
output.push(
|
|
831
|
+
this.renderDiffLine(
|
|
832
|
+
splitRow.cell.line,
|
|
833
|
+
splitRow.cell.index,
|
|
834
|
+
width,
|
|
835
|
+
splitRow.cell.index === this.selected,
|
|
836
|
+
this.getSelectionBounds(),
|
|
837
|
+
),
|
|
838
|
+
);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const left = splitRow.left
|
|
843
|
+
? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
|
|
844
|
+
: " ".repeat(leftWidth);
|
|
845
|
+
const right = splitRow.right
|
|
846
|
+
? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
|
|
847
|
+
: " ".repeat(rightWidth);
|
|
848
|
+
output.push(
|
|
849
|
+
truncateToWidth(
|
|
850
|
+
`${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
|
|
851
|
+
width,
|
|
852
|
+
),
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return output;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
private getContentHeight(): number {
|
|
860
|
+
const terminalRows = this.tui.terminal?.rows ?? 24;
|
|
861
|
+
const headerHeight = 3;
|
|
862
|
+
const footerHeight = 2;
|
|
863
|
+
return Math.max(6, terminalRows - headerHeight - footerHeight);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private getStackedHeights(viewportHeight: number): {
|
|
867
|
+
diffHeight: number;
|
|
868
|
+
commentsHeight: number;
|
|
869
|
+
} {
|
|
870
|
+
const availableForPanes = Math.max(2, viewportHeight - 1);
|
|
871
|
+
let diffHeight = Math.max(1, Math.floor(availableForPanes * 0.6));
|
|
872
|
+
let commentsHeight = availableForPanes - diffHeight;
|
|
873
|
+
|
|
874
|
+
if (commentsHeight < 3 && availableForPanes >= 4) {
|
|
875
|
+
commentsHeight = 3;
|
|
876
|
+
diffHeight = availableForPanes - commentsHeight;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return { diffHeight, commentsHeight };
|
|
880
|
+
}
|
|
881
|
+
|
|
466
882
|
invalidate(): void {}
|
|
467
883
|
|
|
468
884
|
private move(delta: number): void {
|
|
@@ -473,6 +889,27 @@ class ReviewComponent {
|
|
|
473
889
|
this.tui.requestRender();
|
|
474
890
|
}
|
|
475
891
|
|
|
892
|
+
private toggleLayout(): void {
|
|
893
|
+
this.layout = this.layout === "side-by-side" ? "stacked" : "side-by-side";
|
|
894
|
+
this.tui.requestRender(true);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private toggleDiffRenderMode(): void {
|
|
898
|
+
this.diffRenderMode =
|
|
899
|
+
this.diffRenderMode === "unified" ? "split" : "unified";
|
|
900
|
+
this.scrollTop = 0;
|
|
901
|
+
this.tui.requestRender(true);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private getPageMoveAmount(): number {
|
|
905
|
+
const contentHeight = this.getContentHeight();
|
|
906
|
+
const diffHeight =
|
|
907
|
+
this.layout === "stacked"
|
|
908
|
+
? this.getStackedHeights(contentHeight).diffHeight
|
|
909
|
+
: contentHeight;
|
|
910
|
+
return Math.max(1, Math.floor(diffHeight / 2));
|
|
911
|
+
}
|
|
912
|
+
|
|
476
913
|
private extendSelection(delta: number): void {
|
|
477
914
|
if (this.selectionAnchor == null) {
|
|
478
915
|
this.selectionAnchor = this.selected;
|
|
@@ -490,7 +927,9 @@ class ReviewComponent {
|
|
|
490
927
|
}
|
|
491
928
|
|
|
492
929
|
private hasSelection(): boolean {
|
|
493
|
-
return
|
|
930
|
+
return (
|
|
931
|
+
this.selectionAnchor != null && this.selectionAnchor !== this.selected
|
|
932
|
+
);
|
|
494
933
|
}
|
|
495
934
|
|
|
496
935
|
private getSelectionBounds(): SelectionBounds | undefined {
|
|
@@ -517,7 +956,9 @@ class ReviewComponent {
|
|
|
517
956
|
selection: SelectionBounds | undefined,
|
|
518
957
|
): ReviewComment | undefined {
|
|
519
958
|
if (!selection) return undefined;
|
|
520
|
-
return this.comments.get(
|
|
959
|
+
return this.comments.get(
|
|
960
|
+
this.getSelectionKey(selection.start, selection.end),
|
|
961
|
+
);
|
|
521
962
|
}
|
|
522
963
|
|
|
523
964
|
private getCommentKeysForLine(index: number): string[] {
|
|
@@ -525,8 +966,12 @@ class ReviewComponent {
|
|
|
525
966
|
if (!line) return [];
|
|
526
967
|
return [...this.comments.entries()]
|
|
527
968
|
.filter(([, comment]) => {
|
|
528
|
-
const start = this.lines.findIndex(
|
|
529
|
-
|
|
969
|
+
const start = this.lines.findIndex(
|
|
970
|
+
(item) => item.id === comment.startLineId,
|
|
971
|
+
);
|
|
972
|
+
const end = this.lines.findIndex(
|
|
973
|
+
(item) => item.id === comment.endLineId,
|
|
974
|
+
);
|
|
530
975
|
return start !== -1 && end !== -1 && index >= start && index <= end;
|
|
531
976
|
})
|
|
532
977
|
.map(([key]) => key);
|
|
@@ -596,7 +1041,10 @@ class ReviewComponent {
|
|
|
596
1041
|
|
|
597
1042
|
const existing = this.getCommentForSelection(selection);
|
|
598
1043
|
this.editMode = true;
|
|
599
|
-
this.editingCommentKey = this.getSelectionKey(
|
|
1044
|
+
this.editingCommentKey = this.getSelectionKey(
|
|
1045
|
+
selection.start,
|
|
1046
|
+
selection.end,
|
|
1047
|
+
);
|
|
600
1048
|
this.editor.setText(existing?.text ?? "");
|
|
601
1049
|
this.tui.requestRender(true);
|
|
602
1050
|
}
|
|
@@ -608,16 +1056,117 @@ class ReviewComponent {
|
|
|
608
1056
|
this.tui.requestRender(true);
|
|
609
1057
|
}
|
|
610
1058
|
|
|
1059
|
+
private buildSplitDiffRows(): SplitDiffRow[] {
|
|
1060
|
+
const rows: SplitDiffRow[] = [];
|
|
1061
|
+
let index = 0;
|
|
1062
|
+
|
|
1063
|
+
while (index < this.lines.length) {
|
|
1064
|
+
const line = this.lines[index]!;
|
|
1065
|
+
|
|
1066
|
+
if (line.kind === "remove" || line.kind === "add") {
|
|
1067
|
+
const removals: SplitDiffCell[] = [];
|
|
1068
|
+
const additions: SplitDiffCell[] = [];
|
|
1069
|
+
|
|
1070
|
+
while (this.lines[index]?.kind === "remove") {
|
|
1071
|
+
removals.push({ line: this.lines[index]!, index });
|
|
1072
|
+
index++;
|
|
1073
|
+
}
|
|
1074
|
+
while (this.lines[index]?.kind === "add") {
|
|
1075
|
+
additions.push({ line: this.lines[index]!, index });
|
|
1076
|
+
index++;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const count = Math.max(removals.length, additions.length);
|
|
1080
|
+
for (let offset = 0; offset < count; offset++) {
|
|
1081
|
+
rows.push({
|
|
1082
|
+
kind: "split",
|
|
1083
|
+
left: removals[offset],
|
|
1084
|
+
right: additions[offset],
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (line.kind === "context") {
|
|
1091
|
+
const cell = { line, index };
|
|
1092
|
+
rows.push({ kind: "split", left: cell, right: cell });
|
|
1093
|
+
} else {
|
|
1094
|
+
rows.push({ kind: "full", cell: { line, index } });
|
|
1095
|
+
}
|
|
1096
|
+
index++;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
return rows;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private getSelectedDisplayRow(): number {
|
|
1103
|
+
if (this.diffRenderMode === "unified") return this.selected;
|
|
1104
|
+
const rows = this.buildSplitDiffRows();
|
|
1105
|
+
const row = rows.findIndex((item) =>
|
|
1106
|
+
item.kind === "full"
|
|
1107
|
+
? item.cell.index === this.selected
|
|
1108
|
+
: item.left?.index === this.selected ||
|
|
1109
|
+
item.right?.index === this.selected,
|
|
1110
|
+
);
|
|
1111
|
+
return row === -1 ? 0 : row;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private getDisplayRowCount(): number {
|
|
1115
|
+
return this.diffRenderMode === "unified"
|
|
1116
|
+
? this.lines.length
|
|
1117
|
+
: this.buildSplitDiffRows().length;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
private renderSplitDiffCell(
|
|
1121
|
+
cell: SplitDiffCell,
|
|
1122
|
+
width: number,
|
|
1123
|
+
side: "left" | "right",
|
|
1124
|
+
): string {
|
|
1125
|
+
const { line, index } = cell;
|
|
1126
|
+
const hasComment = this.getCommentKeysForLine(index).length > 0;
|
|
1127
|
+
const commentMark = hasComment ? this.theme.fg("warning", "●") : " ";
|
|
1128
|
+
const lineNumber =
|
|
1129
|
+
side === "left" ? line.oldLineNumber : line.newLineNumber;
|
|
1130
|
+
const raw = `${commentMark} ${lineNumberCell(lineNumber)} ${line.text}`;
|
|
1131
|
+
|
|
1132
|
+
let styled: string;
|
|
1133
|
+
switch (line.kind) {
|
|
1134
|
+
case "add":
|
|
1135
|
+
styled = this.theme.fg("toolDiffAdded", raw);
|
|
1136
|
+
break;
|
|
1137
|
+
case "remove":
|
|
1138
|
+
styled = this.theme.fg("toolDiffRemoved", raw);
|
|
1139
|
+
break;
|
|
1140
|
+
case "context":
|
|
1141
|
+
styled = this.theme.fg("toolDiffContext", raw);
|
|
1142
|
+
break;
|
|
1143
|
+
default:
|
|
1144
|
+
styled = this.theme.fg("muted", raw);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
styled = truncateToWidth(styled, width);
|
|
1148
|
+
const selection = this.getSelectionBounds();
|
|
1149
|
+
const inSelection =
|
|
1150
|
+
selection != null && index >= selection.start && index <= selection.end;
|
|
1151
|
+
if (index === this.selected || inSelection) {
|
|
1152
|
+
return this.theme.bg("selectedBg", padToWidth(styled, width));
|
|
1153
|
+
}
|
|
1154
|
+
return styled;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
611
1157
|
private ensureScroll(viewportHeight: number): void {
|
|
612
|
-
|
|
613
|
-
|
|
1158
|
+
const selectedRow = this.getSelectedDisplayRow();
|
|
1159
|
+
const rowCount = this.getDisplayRowCount();
|
|
1160
|
+
|
|
1161
|
+
if (selectedRow < this.scrollTop) {
|
|
1162
|
+
this.scrollTop = selectedRow;
|
|
614
1163
|
}
|
|
615
|
-
if (
|
|
616
|
-
this.scrollTop =
|
|
1164
|
+
if (selectedRow >= this.scrollTop + viewportHeight) {
|
|
1165
|
+
this.scrollTop = selectedRow - viewportHeight + 1;
|
|
617
1166
|
}
|
|
618
1167
|
this.scrollTop = Math.max(
|
|
619
1168
|
0,
|
|
620
|
-
Math.min(this.scrollTop, Math.max(0,
|
|
1169
|
+
Math.min(this.scrollTop, Math.max(0, rowCount - viewportHeight)),
|
|
621
1170
|
);
|
|
622
1171
|
}
|
|
623
1172
|
|
|
@@ -835,7 +1384,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
835
1384
|
return;
|
|
836
1385
|
}
|
|
837
1386
|
|
|
838
|
-
pi.sendUserMessage(
|
|
1387
|
+
pi.sendUserMessage(
|
|
1388
|
+
buildReviewPrompt(result.comments, source.promptLabel),
|
|
1389
|
+
);
|
|
839
1390
|
},
|
|
840
1391
|
});
|
|
841
1392
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-diff-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Local diff review TUI extension for pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,5 +38,8 @@
|
|
|
38
38
|
"extensions": [
|
|
39
39
|
"./extensions"
|
|
40
40
|
]
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@pierre/diffs": "^1.1.21"
|
|
41
44
|
}
|
|
42
45
|
}
|