pi-diff-review 0.1.4 → 0.1.6
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 +2 -1
- package/package.json +1 -1
- package/src/diff-source.ts +46 -1
- package/src/index.ts +4 -3
- package/src/review-component.ts +102 -32
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Easily provide code reviews directly within [pi](https://pi.dev/).
|
|
6
6
|
|
|
7
|
-
<img width="
|
|
7
|
+
<img width="1095" height="853" alt="pi-diff-review-screenshot" src="https://github.com/user-attachments/assets/f1a39117-f2a6-4ee7-bf72-a299990ab1dd" />
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -27,6 +27,7 @@ pi install https://github.com/cmpadden/pi-diff-review
|
|
|
27
27
|
- `/diff` reviews the current unstaged `git diff`
|
|
28
28
|
- `/diff <git-diff-args>` passes arguments through to `git diff` (for example `/diff main...HEAD`)
|
|
29
29
|
- `j/k` or arrow keys to move
|
|
30
|
+
- `g/G` to jump to the top or bottom of the diff
|
|
30
31
|
- `ctrl-u` / `ctrl-d` to move up/down by half a page
|
|
31
32
|
- `t` toggles the diff between unified and side-by-side split rendering
|
|
32
33
|
- `J/K` to extend a highlighted selection into a comment range
|
package/package.json
CHANGED
package/src/diff-source.ts
CHANGED
|
@@ -11,7 +11,7 @@ export function parseDiffSource(args: string): DiffSource {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const gitArgs = trimmed
|
|
14
|
+
const gitArgs = tokenizeDiffArgs(trimmed);
|
|
15
15
|
return {
|
|
16
16
|
label: `git diff ${trimmed}`,
|
|
17
17
|
promptLabel: `\`git diff ${trimmed}\``,
|
|
@@ -19,6 +19,51 @@ export function parseDiffSource(args: string): DiffSource {
|
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function tokenizeDiffArgs(input: string): string[] {
|
|
23
|
+
const args: string[] = [];
|
|
24
|
+
let current = "";
|
|
25
|
+
let quote: '"' | "'" | undefined;
|
|
26
|
+
let escaping = false;
|
|
27
|
+
|
|
28
|
+
for (const char of input) {
|
|
29
|
+
if (escaping) {
|
|
30
|
+
current += char;
|
|
31
|
+
escaping = false;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (char === "\\" && quote !== "'") {
|
|
36
|
+
escaping = true;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if ((char === '"' || char === "'") && !quote) {
|
|
41
|
+
quote = char;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (char === quote) {
|
|
46
|
+
quote = undefined;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (/\s/.test(char) && !quote) {
|
|
51
|
+
if (current) {
|
|
52
|
+
args.push(current);
|
|
53
|
+
current = "";
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
current += char;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (escaping) current += "\\";
|
|
62
|
+
if (quote) throw new Error(`Unterminated ${quote} quote in git diff args`);
|
|
63
|
+
if (current) args.push(current);
|
|
64
|
+
return args;
|
|
65
|
+
}
|
|
66
|
+
|
|
22
67
|
export function getDiff(cwd: string, source: DiffSource): string {
|
|
23
68
|
return execFileSync(
|
|
24
69
|
"git",
|
package/src/index.ts
CHANGED
|
@@ -6,19 +6,20 @@ import { getDiff, parseDiffSource } from "./diff-source.ts";
|
|
|
6
6
|
import { parseDiff } from "./diff-parser.ts";
|
|
7
7
|
import { buildReviewPrompt } from "./prompt.ts";
|
|
8
8
|
import { ReviewComponent } from "./review-component.ts";
|
|
9
|
-
import type { ReviewComment, ReviewResult } from "./types.ts";
|
|
9
|
+
import type { DiffSource, ReviewComment, ReviewResult } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
12
12
|
pi.registerCommand("diff", {
|
|
13
13
|
description: "Review a git diff in a custom TUI (/diff [git diff args])",
|
|
14
14
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
15
|
-
|
|
15
|
+
let source: DiffSource;
|
|
16
16
|
let diffText: string;
|
|
17
17
|
try {
|
|
18
|
+
source = parseDiffSource(args);
|
|
18
19
|
diffText = getDiff(ctx.cwd, source);
|
|
19
20
|
} catch (error) {
|
|
20
21
|
const message = error instanceof Error ? error.message : String(error);
|
|
21
|
-
ctx.ui.notify(`Unable to read
|
|
22
|
+
ctx.ui.notify(`Unable to read diff: ${message}`, "error");
|
|
22
23
|
return;
|
|
23
24
|
}
|
|
24
25
|
|
package/src/review-component.ts
CHANGED
|
@@ -38,6 +38,12 @@ export class ReviewComponent {
|
|
|
38
38
|
private layout: ReviewLayout = "side-by-side";
|
|
39
39
|
private diffRenderMode: DiffRenderMode = "unified";
|
|
40
40
|
private editor: Editor;
|
|
41
|
+
private splitRows?: SplitDiffRow[];
|
|
42
|
+
private splitRowByLineIndex?: number[];
|
|
43
|
+
private lineIndexById = new Map<string, number>();
|
|
44
|
+
private commentLineKeys = new Map<number, string[]>();
|
|
45
|
+
private commentsRevision = 0;
|
|
46
|
+
private commentLineKeysRevision = -1;
|
|
41
47
|
|
|
42
48
|
constructor(
|
|
43
49
|
private tui: ReviewTui,
|
|
@@ -49,6 +55,7 @@ export class ReviewComponent {
|
|
|
49
55
|
) {
|
|
50
56
|
const firstCommentable = this.lines.findIndex((line) => line.commentable);
|
|
51
57
|
this.selected = firstCommentable >= 0 ? firstCommentable : 0;
|
|
58
|
+
this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
|
|
52
59
|
|
|
53
60
|
this.editor = new Editor(tui as never, {
|
|
54
61
|
borderColor: (s) => theme.fg("accent", s),
|
|
@@ -71,12 +78,13 @@ export class ReviewComponent {
|
|
|
71
78
|
const trimmed = value.trim();
|
|
72
79
|
const key = this.getSelectionKey(selection.start, selection.end);
|
|
73
80
|
if (!trimmed) {
|
|
74
|
-
this.comments.delete(key);
|
|
81
|
+
if (this.comments.delete(key)) this.markCommentsChanged();
|
|
75
82
|
} else {
|
|
76
83
|
this.comments.set(
|
|
77
84
|
key,
|
|
78
85
|
this.buildCommentFromSelection(selection, trimmed),
|
|
79
86
|
);
|
|
87
|
+
this.markCommentsChanged();
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
this.exitEditMode();
|
|
@@ -126,6 +134,14 @@ export class ReviewComponent {
|
|
|
126
134
|
this.move(-1);
|
|
127
135
|
return;
|
|
128
136
|
}
|
|
137
|
+
if (data === "g") {
|
|
138
|
+
this.jumpToBoundary("start");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (data === "G") {
|
|
142
|
+
this.jumpToBoundary("end");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
129
145
|
if (data === "J") {
|
|
130
146
|
this.extendSelection(1);
|
|
131
147
|
return;
|
|
@@ -172,7 +188,7 @@ export class ReviewComponent {
|
|
|
172
188
|
? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
|
|
173
189
|
: this.hasSelection()
|
|
174
190
|
? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • Enter submit`
|
|
175
|
-
: `${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 • Enter submit • q quit`,
|
|
191
|
+
: `${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • j/k move • g/G top/bottom • ctrl-u/d page • t unified/split • J/K extend • c comment • x delete • n/p hunk • Enter submit • q quit`,
|
|
176
192
|
),
|
|
177
193
|
width,
|
|
178
194
|
),
|
|
@@ -268,7 +284,7 @@ export class ReviewComponent {
|
|
|
268
284
|
}
|
|
269
285
|
|
|
270
286
|
private renderSplitDiffRows(width: number, height: number): string[] {
|
|
271
|
-
const rows = this.
|
|
287
|
+
const rows = this.getSplitDiffRows();
|
|
272
288
|
const output: string[] = [];
|
|
273
289
|
const separatorWidth = 3;
|
|
274
290
|
const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
|
|
@@ -337,13 +353,27 @@ export class ReviewComponent {
|
|
|
337
353
|
invalidate(): void {}
|
|
338
354
|
|
|
339
355
|
private move(delta: number): void {
|
|
340
|
-
|
|
356
|
+
const next = Math.max(
|
|
341
357
|
0,
|
|
342
358
|
Math.min(this.lines.length - 1, this.selected + delta),
|
|
343
359
|
);
|
|
360
|
+
if (next === this.selected) return;
|
|
361
|
+
this.selected = next;
|
|
344
362
|
this.tui.requestRender();
|
|
345
363
|
}
|
|
346
364
|
|
|
365
|
+
private jumpToBoundary(boundary: "start" | "end"): void {
|
|
366
|
+
const next = boundary === "start" ? 0 : Math.max(0, this.lines.length - 1);
|
|
367
|
+
const hadSelection = this.selectionAnchor != null;
|
|
368
|
+
if (next === this.selected && !hadSelection) return;
|
|
369
|
+
this.selected = next;
|
|
370
|
+
if (hadSelection) {
|
|
371
|
+
this.clearSelection();
|
|
372
|
+
} else {
|
|
373
|
+
this.tui.requestRender();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
347
377
|
private toggleLayout(): void {
|
|
348
378
|
this.layout = this.layout === "side-by-side" ? "stacked" : "side-by-side";
|
|
349
379
|
this.tui.requestRender(true);
|
|
@@ -369,14 +399,17 @@ export class ReviewComponent {
|
|
|
369
399
|
if (this.selectionAnchor == null) {
|
|
370
400
|
this.selectionAnchor = this.selected;
|
|
371
401
|
}
|
|
372
|
-
|
|
402
|
+
const next = Math.max(
|
|
373
403
|
0,
|
|
374
404
|
Math.min(this.lines.length - 1, this.selected + delta),
|
|
375
405
|
);
|
|
406
|
+
if (next === this.selected) return;
|
|
407
|
+
this.selected = next;
|
|
376
408
|
this.tui.requestRender();
|
|
377
409
|
}
|
|
378
410
|
|
|
379
411
|
private clearSelection(): void {
|
|
412
|
+
if (this.selectionAnchor == null) return;
|
|
380
413
|
this.selectionAnchor = undefined;
|
|
381
414
|
this.tui.requestRender();
|
|
382
415
|
}
|
|
@@ -417,19 +450,35 @@ export class ReviewComponent {
|
|
|
417
450
|
}
|
|
418
451
|
|
|
419
452
|
private getCommentKeysForLine(index: number): string[] {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
453
|
+
this.ensureCommentLineKeys();
|
|
454
|
+
return this.commentLineKeys.get(index) ?? [];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private markCommentsChanged(): void {
|
|
458
|
+
this.commentsRevision++;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private ensureCommentLineKeys(): void {
|
|
462
|
+
if (this.commentLineKeysRevision === this.commentsRevision) return;
|
|
463
|
+
|
|
464
|
+
this.commentLineKeys = new Map<number, string[]>();
|
|
465
|
+
for (const [key, comment] of this.comments) {
|
|
466
|
+
const start = this.lineIndexById.get(comment.startLineId);
|
|
467
|
+
const end = this.lineIndexById.get(comment.endLineId);
|
|
468
|
+
if (start == null || end == null) continue;
|
|
469
|
+
const from = Math.min(start, end);
|
|
470
|
+
const to = Math.max(start, end);
|
|
471
|
+
for (let index = from; index <= to; index++) {
|
|
472
|
+
const keys = this.commentLineKeys.get(index);
|
|
473
|
+
if (keys) {
|
|
474
|
+
keys.push(key);
|
|
475
|
+
} else {
|
|
476
|
+
this.commentLineKeys.set(index, [key]);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
this.commentLineKeysRevision = this.commentsRevision;
|
|
433
482
|
}
|
|
434
483
|
|
|
435
484
|
private buildCommentFromSelection(
|
|
@@ -456,6 +505,13 @@ export class ReviewComponent {
|
|
|
456
505
|
};
|
|
457
506
|
}
|
|
458
507
|
|
|
508
|
+
private getPositionText(selectedLine?: ReviewLine): string {
|
|
509
|
+
const position = `${Math.min(this.selected + 1, this.lines.length)}/${this.lines.length}`;
|
|
510
|
+
return selectedLine?.filePath
|
|
511
|
+
? `${position} ${selectedLine.filePath}`
|
|
512
|
+
: position;
|
|
513
|
+
}
|
|
514
|
+
|
|
459
515
|
private getFooterText(selectedLine?: ReviewLine): string {
|
|
460
516
|
const selection = this.getSelectionBounds();
|
|
461
517
|
if (selection) {
|
|
@@ -482,7 +538,11 @@ export class ReviewComponent {
|
|
|
482
538
|
private deleteComment(): void {
|
|
483
539
|
const selection = this.getActiveCommentSelection();
|
|
484
540
|
if (!selection) return;
|
|
485
|
-
|
|
541
|
+
if (
|
|
542
|
+
this.comments.delete(this.getSelectionKey(selection.start, selection.end))
|
|
543
|
+
) {
|
|
544
|
+
this.markCommentsChanged();
|
|
545
|
+
}
|
|
486
546
|
this.tui.requestRender();
|
|
487
547
|
}
|
|
488
548
|
|
|
@@ -511,10 +571,24 @@ export class ReviewComponent {
|
|
|
511
571
|
this.tui.requestRender(true);
|
|
512
572
|
}
|
|
513
573
|
|
|
514
|
-
private
|
|
574
|
+
private getSplitDiffRows(): SplitDiffRow[] {
|
|
575
|
+
if (this.splitRows) return this.splitRows;
|
|
576
|
+
|
|
515
577
|
const rows: SplitDiffRow[] = [];
|
|
578
|
+
const rowByLineIndex: number[] = [];
|
|
516
579
|
let index = 0;
|
|
517
580
|
|
|
581
|
+
const pushRow = (row: SplitDiffRow) => {
|
|
582
|
+
const displayRow = rows.length;
|
|
583
|
+
rows.push(row);
|
|
584
|
+
if (row.kind === "full") {
|
|
585
|
+
rowByLineIndex[row.cell.index] = displayRow;
|
|
586
|
+
} else {
|
|
587
|
+
if (row.left) rowByLineIndex[row.left.index] = displayRow;
|
|
588
|
+
if (row.right) rowByLineIndex[row.right.index] = displayRow;
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
518
592
|
while (index < this.lines.length) {
|
|
519
593
|
const line = this.lines[index]!;
|
|
520
594
|
|
|
@@ -533,7 +607,7 @@ export class ReviewComponent {
|
|
|
533
607
|
|
|
534
608
|
const count = Math.max(removals.length, additions.length);
|
|
535
609
|
for (let offset = 0; offset < count; offset++) {
|
|
536
|
-
|
|
610
|
+
pushRow({
|
|
537
611
|
kind: "split",
|
|
538
612
|
left: removals[offset],
|
|
539
613
|
right: additions[offset],
|
|
@@ -544,32 +618,28 @@ export class ReviewComponent {
|
|
|
544
618
|
|
|
545
619
|
if (line.kind === "context") {
|
|
546
620
|
const cell = { line, index };
|
|
547
|
-
|
|
621
|
+
pushRow({ kind: "split", left: cell, right: cell });
|
|
548
622
|
} else {
|
|
549
|
-
|
|
623
|
+
pushRow({ kind: "full", cell: { line, index } });
|
|
550
624
|
}
|
|
551
625
|
index++;
|
|
552
626
|
}
|
|
553
627
|
|
|
628
|
+
this.splitRows = rows;
|
|
629
|
+
this.splitRowByLineIndex = rowByLineIndex;
|
|
554
630
|
return rows;
|
|
555
631
|
}
|
|
556
632
|
|
|
557
633
|
private getSelectedDisplayRow(): number {
|
|
558
634
|
if (this.diffRenderMode === "unified") return this.selected;
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
item.kind === "full"
|
|
562
|
-
? item.cell.index === this.selected
|
|
563
|
-
: item.left?.index === this.selected ||
|
|
564
|
-
item.right?.index === this.selected,
|
|
565
|
-
);
|
|
566
|
-
return row === -1 ? 0 : row;
|
|
635
|
+
this.getSplitDiffRows();
|
|
636
|
+
return this.splitRowByLineIndex?.[this.selected] ?? 0;
|
|
567
637
|
}
|
|
568
638
|
|
|
569
639
|
private getDisplayRowCount(): number {
|
|
570
640
|
return this.diffRenderMode === "unified"
|
|
571
641
|
? this.lines.length
|
|
572
|
-
: this.
|
|
642
|
+
: this.getSplitDiffRows().length;
|
|
573
643
|
}
|
|
574
644
|
|
|
575
645
|
private renderSplitDiffCell(
|