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 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
@@ -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("git", ["diff", "--no-color", "--unified=3", ...source.args], {
76
- cwd,
77
- encoding: "utf8",
78
- stdio: ["ignore", "pipe", "pipe"],
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(key, this.buildCommentFromSelection(selection, trimmed));
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 terminalRows = this.tui.terminal?.rows ?? 24;
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
- const selection = this.getSelectionBounds();
439
- for (let row = 0; row < viewportHeight; row++) {
440
- const index = this.scrollTop + row;
441
- const line = this.lines[index];
442
- const left = line
443
- ? this.renderDiffLine(
444
- line,
445
- index,
446
- leftWidth,
447
- index === this.selected,
448
- selection,
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 this.selectionAnchor != null && this.selectionAnchor !== this.selected;
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(this.getSelectionKey(selection.start, selection.end));
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((item) => item.id === comment.startLineId);
529
- const end = this.lines.findIndex((item) => item.id === comment.endLineId);
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(selection.start, selection.end);
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
- if (this.selected < this.scrollTop) {
613
- this.scrollTop = this.selected;
1158
+ const selectedRow = this.getSelectedDisplayRow();
1159
+ const rowCount = this.getDisplayRowCount();
1160
+
1161
+ if (selectedRow < this.scrollTop) {
1162
+ this.scrollTop = selectedRow;
614
1163
  }
615
- if (this.selected >= this.scrollTop + viewportHeight) {
616
- this.scrollTop = this.selected - viewportHeight + 1;
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, this.lines.length - viewportHeight)),
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(buildReviewPrompt(result.comments, source.promptLabel));
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.0",
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
  }