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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Easily provide code reviews directly within [pi](https://pi.dev/).
6
6
 
7
- <img width="1947" height="1103" alt="image" src="https://github.com/user-attachments/assets/3b1e1c51-4c77-4430-8915-1f7d481b64cb" />
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,7 +11,7 @@ export function parseDiffSource(args: string): DiffSource {
11
11
  };
12
12
  }
13
13
 
14
- const gitArgs = trimmed.split(/\s+/).filter(Boolean);
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
- const source = parseDiffSource(args);
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 ${source.label}: ${message}`, "error");
22
+ ctx.ui.notify(`Unable to read diff: ${message}`, "error");
22
23
  return;
23
24
  }
24
25
 
@@ -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.buildSplitDiffRows();
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
- this.selected = Math.max(
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
- this.selected = Math.max(
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
- const line = this.lines[index];
421
- if (!line) return [];
422
- return [...this.comments.entries()]
423
- .filter(([, comment]) => {
424
- const start = this.lines.findIndex(
425
- (item) => item.id === comment.startLineId,
426
- );
427
- const end = this.lines.findIndex(
428
- (item) => item.id === comment.endLineId,
429
- );
430
- return start !== -1 && end !== -1 && index >= start && index <= end;
431
- })
432
- .map(([key]) => key);
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
- this.comments.delete(this.getSelectionKey(selection.start, selection.end));
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 buildSplitDiffRows(): SplitDiffRow[] {
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
- rows.push({
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
- rows.push({ kind: "split", left: cell, right: cell });
621
+ pushRow({ kind: "split", left: cell, right: cell });
548
622
  } else {
549
- rows.push({ kind: "full", cell: { line, index } });
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
- const rows = this.buildSplitDiffRows();
560
- const row = rows.findIndex((item) =>
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.buildSplitDiffRows().length;
642
+ : this.getSplitDiffRows().length;
573
643
  }
574
644
 
575
645
  private renderSplitDiffCell(