pi-diff-review 0.1.21 → 0.1.23

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
@@ -47,6 +47,14 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
47
47
  /diff main...HEAD
48
48
  ```
49
49
 
50
+ Review a single file by using a git pathspec after `--`. Pi path autocomplete works here too:
51
+
52
+ ```text
53
+ /diff -- @src/index.ts
54
+ /diff --cached -- @src/index.ts
55
+ /diff main...HEAD -- @src/index.ts
56
+ ```
57
+
50
58
  `/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
59
 
52
60
  Open one or more files or folders with `/view`:
@@ -74,13 +82,18 @@ Open one or more files or folders with `/view`:
74
82
  - `h` toggles the command help modal
75
83
  - `j/k` or arrow keys to move
76
84
  - `g/G` to jump to the top or bottom of the diff
85
+ - `[/]` to jump to the previous or next file
86
+ - `f` to focus the current file, or clear file focus
87
+ - `t` toggles the left file sidebar
77
88
  - `ctrl-u` / `ctrl-d` to move up/down by half a page
78
- - `t` toggles inline comments/explanations
89
+ - `s` toggles inline comments/explanations
79
90
  - `v` toggles the diff between unified and side-by-side split rendering
80
91
  - `?` toggles an AI-generated explanation for the current hunk
81
92
  - `/` searches visible diff text, highlights matches, and `n/N` moves between them while a search is active
82
93
  - `J/K` to extend a highlighted selection into a comment range
83
94
  - `esc` clears the active selection, or exits review when no selection is active
95
+ - File headers break the review into per-file sections with change counts
96
+ - Optional left sidebar lists files with `+/-` counts and tracks the current file
84
97
  - `n/p` to jump hunks
85
98
  - `c` to add or edit a comment for the current line or selected range
86
99
  - `C` to add or edit an overall diff comment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,13 +22,22 @@ export function parseDiffSource(args: string): DiffSource {
22
22
 
23
23
  function tokenizeDiffArgs(input: string): string[] {
24
24
  try {
25
- return tokenizeShellArgs(input);
25
+ return normalizeDiffPathspecs(tokenizeShellArgs(input));
26
26
  } catch (error) {
27
27
  const message = error instanceof Error ? error.message : String(error);
28
28
  throw new Error(message.replace(/in arguments$/, "in git diff args"));
29
29
  }
30
30
  }
31
31
 
32
+ function normalizeDiffPathspecs(args: string[]): string[] {
33
+ const separatorIndex = args.indexOf("--");
34
+ if (separatorIndex < 0) return args;
35
+
36
+ return args.map((arg, index) =>
37
+ index > separatorIndex && arg.startsWith("@") ? arg.slice(1) : arg,
38
+ );
39
+ }
40
+
32
41
  export const DIFF_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
33
42
 
34
43
  export function getDiff(cwd: string, source: DiffSource): string {
@@ -28,6 +28,7 @@ import {
28
28
  import { formatCommentLocation, formatLocation } from "./prompt.ts";
29
29
  import { ReviewNavigationState } from "./navigation.ts";
30
30
  import { ReviewSearchState } from "./search.ts";
31
+ import { buildReviewFileIndex, type ReviewFileSection } from "./files.ts";
31
32
  import { padToWidth, lineNumberCell } from "../render/utils.ts";
32
33
  import { buildSplitDiffRows } from "../diff/split.ts";
33
34
  import type {
@@ -53,6 +54,7 @@ type InlineBoxRow = {
53
54
  };
54
55
 
55
56
  type AnnotatedDiffRow =
57
+ | { kind: "file-header"; file: ReviewFileSection }
56
58
  | { kind: "diff"; lineIndex: number }
57
59
  | { kind: "split"; splitRowIndex: number }
58
60
  | InlineBoxRow;
@@ -63,6 +65,8 @@ const HELP_COMMANDS = [
63
65
  ["PgUp / PgDown", "move up or down half a page"],
64
66
  ["ctrl-u / ctrl-d", "move up or down half a page"],
65
67
  ["g / G", "jump to top or bottom"],
68
+ ["[ / ]", "jump to previous or next file"],
69
+ ["f", "focus or unfocus the current file"],
66
70
  ["n / p", "jump to next or previous hunk"],
67
71
  ["/", "search diff lines"],
68
72
  ["n / N", "jump between search matches"],
@@ -70,7 +74,8 @@ const HELP_COMMANDS = [
70
74
  ["c", "add or edit a line or range comment"],
71
75
  ["C", "add or edit an overall diff comment"],
72
76
  ["x", "delete the current line or range comment"],
73
- ["t", "toggle inline comments and explanations"],
77
+ ["t", "toggle the file sidebar"],
78
+ ["s", "toggle inline comments and explanations"],
74
79
  ["v", "toggle unified or split rendering"],
75
80
  ["?", "toggle AI explanation for current hunk"],
76
81
  ["a", "ask a question about the current hunk"],
@@ -85,6 +90,7 @@ export class ReviewComponent {
85
90
  private editingCommentKey?: string;
86
91
  private search: ReviewSearchState;
87
92
  private helpVisible = false;
93
+ private fileSidebarVisible = false;
88
94
 
89
95
  private inlineAnnotationsVisible = true;
90
96
  private visibleExplanationKeys = new Set<string>();
@@ -95,6 +101,8 @@ export class ReviewComponent {
95
101
  private explanationController: ExplanationController;
96
102
  private editor: Editor;
97
103
  private splitRows?: SplitDiffRow[];
104
+ private readonly fileIndex: ReturnType<typeof buildReviewFileIndex>;
105
+ private focusedFilePath?: string;
98
106
  private lineIndexById = new Map<string, number>();
99
107
  private commentLineKeys = new Map<number, string[]>();
100
108
  private commentsRevision = 0;
@@ -133,7 +141,10 @@ export class ReviewComponent {
133
141
  firstCommentable >= 0 ? firstCommentable : 0,
134
142
  );
135
143
  this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
136
- this.search = new ReviewSearchState(this.lines);
144
+ this.fileIndex = buildReviewFileIndex(this.lines);
145
+ this.search = new ReviewSearchState(this.lines, (lineIndex) =>
146
+ this.isLineVisible(lineIndex),
147
+ );
137
148
 
138
149
  const restoredAsk = cachedAsk
139
150
  ? this.restorePersistedAsk(cachedAsk)
@@ -289,6 +300,11 @@ export class ReviewComponent {
289
300
  this.clearSearch();
290
301
  } else if (this.hasSelection()) {
291
302
  this.clearSelection();
303
+ } else if (this.focusedFilePath) {
304
+ this.focusedFilePath = undefined;
305
+ this.highlightedLineCache.clear();
306
+ this.invalidateAnnotatedRows();
307
+ this.tui.requestRender(true);
292
308
  } else {
293
309
  this.done({ action: "cancel" });
294
310
  }
@@ -307,7 +323,7 @@ export class ReviewComponent {
307
323
  return;
308
324
  }
309
325
  if (data === "t") {
310
- this.toggleInlineAnnotations();
326
+ this.toggleFileSidebar();
311
327
  return;
312
328
  }
313
329
  if (data === "v") {
@@ -350,6 +366,22 @@ export class ReviewComponent {
350
366
  this.jumpToBoundary("end");
351
367
  return;
352
368
  }
369
+ if (data === "[") {
370
+ this.jumpFile(-1);
371
+ return;
372
+ }
373
+ if (data === "]") {
374
+ this.jumpFile(1);
375
+ return;
376
+ }
377
+ if (data === "f") {
378
+ this.toggleCurrentFileFocus();
379
+ return;
380
+ }
381
+ if (data === "s") {
382
+ this.toggleInlineAnnotations();
383
+ return;
384
+ }
353
385
  if (data === "J") {
354
386
  this.extendSelection(1);
355
387
  return;
@@ -409,8 +441,24 @@ export class ReviewComponent {
409
441
  ),
410
442
  );
411
443
 
412
- this.ensureScroll(viewportHeight, width);
413
- output.push(...this.renderAnnotatedDiffRows(width, viewportHeight));
444
+ const sidebarWidth = this.getFileSidebarWidth(width);
445
+ const contentWidth = Math.max(10, width - sidebarWidth);
446
+ this.ensureScroll(viewportHeight, contentWidth);
447
+ const bodyRows = this.renderAnnotatedDiffRows(contentWidth, viewportHeight);
448
+ const sidebarRows =
449
+ sidebarWidth > 0
450
+ ? this.renderFileSidebar(sidebarWidth, viewportHeight)
451
+ : undefined;
452
+
453
+ for (let index = 0; index < viewportHeight; index++) {
454
+ const body = bodyRows[index] ?? " ".repeat(contentWidth);
455
+ if (!sidebarRows) {
456
+ output.push(body);
457
+ continue;
458
+ }
459
+ const sidebar = sidebarRows[index] ?? " ".repeat(sidebarWidth);
460
+ output.push(truncateToWidth(`${sidebar}${body}`, width));
461
+ }
414
462
 
415
463
  output.push(
416
464
  truncateToWidth(
@@ -428,7 +476,9 @@ export class ReviewComponent {
428
476
  selectedLine?: ReviewLine,
429
477
  workspaceSummary?: WorkspaceCommentSummary,
430
478
  ): string {
431
- const base = `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments${this.formatWorkspaceSummary(workspaceSummary)}`;
479
+ const visibleLineCount = this.getVisibleLineIndexes().length;
480
+ const summaryCount = this.explanationController.explanations.size;
481
+ const base = `${visibleLineCount}/${this.lines.length} lines • ${this.fileIndex.sections.length} files • ${this.comments.size} comments • ${summaryCount} summaries${this.formatWorkspaceSummary(workspaceSummary)}`;
432
482
 
433
483
  if (this.editMode) {
434
484
  return `${base} • editing ${this.editingCommentKey === GLOBAL_COMMENT_KEY ? "overall comment" : "inline comment"}`;
@@ -438,7 +488,10 @@ export class ReviewComponent {
438
488
  return `${base} • selection active`;
439
489
  }
440
490
 
441
- return `${base} ${this.getPositionText(selectedLine)} • inline ${this.inlineAnnotationsVisible ? "shown" : "hidden"} • ${this.diffRenderMode}`;
491
+ const focusText = this.focusedFilePath
492
+ ? ` • focus ${this.focusedFilePath}`
493
+ : "";
494
+ return `${base}${focusText}`;
442
495
  }
443
496
 
444
497
  private renderStatusLine(left: string, right: string, width: number): string {
@@ -561,6 +614,11 @@ export class ReviewComponent {
561
614
  continue;
562
615
  }
563
616
 
617
+ if (annotated.kind === "file-header") {
618
+ output.push(this.renderFileHeaderRow(annotated.file, width));
619
+ continue;
620
+ }
621
+
564
622
  if (annotated.kind === "diff") {
565
623
  const index = annotated.lineIndex;
566
624
  const line = this.lines[index]!;
@@ -601,7 +659,8 @@ export class ReviewComponent {
601
659
  this.visibleExplanationKeys.size &&
602
660
  this.visibleExplanationKeys.size === 0 &&
603
661
  !this.askInputMode &&
604
- !this.askScope
662
+ !this.askScope &&
663
+ this.focusedFilePath == null
605
664
  ) {
606
665
  return this.annotatedRows;
607
666
  }
@@ -635,15 +694,22 @@ export class ReviewComponent {
635
694
  rowByLineIndex: number[],
636
695
  width: number,
637
696
  ): void {
638
- for (let index = 0; index < this.lines.length; index++) {
639
- rowByLineIndex[index] = rows.length;
640
- rows.push({ kind: "diff", lineIndex: index });
641
-
642
- if (!this.inlineAnnotationsVisible) continue;
643
-
644
- this.pushInlineCommentRows(rows, index, width);
645
- this.pushInlineEditorRows(rows, index, width);
646
- this.pushInlineExplanationRows(rows, index, width);
697
+ for (const file of this.getVisibleFileSections()) {
698
+ rows.push({ kind: "file-header", file });
699
+ for (
700
+ let index = file.startLineIndex;
701
+ index <= file.endLineIndex;
702
+ index++
703
+ ) {
704
+ rowByLineIndex[index] = rows.length;
705
+ rows.push({ kind: "diff", lineIndex: index });
706
+
707
+ if (!this.inlineAnnotationsVisible) continue;
708
+
709
+ this.pushInlineCommentRows(rows, index, width);
710
+ this.pushInlineEditorRows(rows, index, width);
711
+ this.pushInlineExplanationRows(rows, index, width);
712
+ }
647
713
  }
648
714
  }
649
715
 
@@ -653,24 +719,31 @@ export class ReviewComponent {
653
719
  width: number,
654
720
  ): void {
655
721
  const splitRows = this.getSplitDiffRows();
656
- for (
657
- let splitRowIndex = 0;
658
- splitRowIndex < splitRows.length;
659
- splitRowIndex++
660
- ) {
661
- const splitRow = splitRows[splitRowIndex]!;
662
- const lineIndexes = this.getLineIndexesForSplitRow(splitRow);
663
- for (const lineIndex of lineIndexes) {
664
- rowByLineIndex[lineIndex] = rows.length;
665
- }
722
+ for (const file of this.getVisibleFileSections()) {
723
+ rows.push({ kind: "file-header", file });
724
+ for (
725
+ let splitRowIndex = 0;
726
+ splitRowIndex < splitRows.length;
727
+ splitRowIndex++
728
+ ) {
729
+ const splitRow = splitRows[splitRowIndex]!;
730
+ const lineIndexes = this.getLineIndexesForSplitRow(splitRow).filter(
731
+ (lineIndex) =>
732
+ lineIndex >= file.startLineIndex && lineIndex <= file.endLineIndex,
733
+ );
734
+ if (lineIndexes.length === 0) continue;
735
+ for (const lineIndex of lineIndexes) {
736
+ rowByLineIndex[lineIndex] = rows.length;
737
+ }
666
738
 
667
- rows.push({ kind: "split", splitRowIndex });
668
- if (!this.inlineAnnotationsVisible) continue;
739
+ rows.push({ kind: "split", splitRowIndex });
740
+ if (!this.inlineAnnotationsVisible) continue;
669
741
 
670
- for (const lineIndex of lineIndexes) {
671
- this.pushInlineCommentRows(rows, lineIndex, width);
672
- this.pushInlineEditorRows(rows, lineIndex, width);
673
- this.pushInlineExplanationRows(rows, lineIndex, width);
742
+ for (const lineIndex of lineIndexes) {
743
+ this.pushInlineCommentRows(rows, lineIndex, width);
744
+ this.pushInlineEditorRows(rows, lineIndex, width);
745
+ this.pushInlineExplanationRows(rows, lineIndex, width);
746
+ }
674
747
  }
675
748
  }
676
749
  }
@@ -946,6 +1019,107 @@ export class ReviewComponent {
946
1019
  return Math.max(10, width - 8);
947
1020
  }
948
1021
 
1022
+ private renderFileHeaderRow(file: ReviewFileSection, width: number): string {
1023
+ const selected = this.getCurrentFileSection()?.filePath === file.filePath;
1024
+ const focused = this.focusedFilePath === file.filePath;
1025
+ const summary = ` ${file.filePath} +${file.additions} -${file.deletions}${file.hunks > 0 ? ` ${file.hunks} hunk${file.hunks === 1 ? "" : "s"}` : ""}${focused ? " [focused]" : ""}`;
1026
+ const text = padToWidth(
1027
+ truncateToWidth(this.theme.fg("accent", summary), width),
1028
+ width,
1029
+ );
1030
+ return selected ? this.theme.bg("selectedBg", text) : text;
1031
+ }
1032
+
1033
+ private getFileSidebarWidth(width: number): number {
1034
+ if (!this.fileSidebarVisible || width < 60) return 0;
1035
+ return Math.max(20, Math.min(36, Math.floor(width * 0.3)));
1036
+ }
1037
+
1038
+ private renderFileSidebar(width: number, height: number): string[] {
1039
+ const files = this.fileIndex.sections;
1040
+ const current = this.getCurrentFileSection()?.filePath;
1041
+ const currentIndex = Math.max(
1042
+ 0,
1043
+ files.findIndex((file) => file.filePath === current),
1044
+ );
1045
+ const visibleHeight = Math.max(0, height);
1046
+ const maxScrollTop = Math.max(0, files.length - visibleHeight);
1047
+ const scrollTop = Math.max(
1048
+ 0,
1049
+ Math.min(
1050
+ Math.max(0, currentIndex - Math.floor(visibleHeight / 2)),
1051
+ maxScrollTop,
1052
+ ),
1053
+ );
1054
+
1055
+ const rows: string[] = [];
1056
+
1057
+ for (let row = 0; row < visibleHeight; row++) {
1058
+ const file = files[scrollTop + row];
1059
+ if (!file) {
1060
+ rows.push(" ".repeat(width));
1061
+ continue;
1062
+ }
1063
+ const selected = file.filePath === current;
1064
+ const focused = file.filePath === this.focusedFilePath;
1065
+ const label = file.filePath;
1066
+ const countSuffix = focused ? " *" : "";
1067
+ const added = this.theme.fg("toolDiffAdded", `+${file.additions}`);
1068
+ const removed = this.theme.fg("toolDiffRemoved", `-${file.deletions}`);
1069
+ const counts = `${added} ${removed}${countSuffix}`;
1070
+ const rightWidth = visibleWidth(counts);
1071
+ const leftWidth = Math.max(0, width - rightWidth - 1);
1072
+ const line = `${padToWidth(truncateToWidth(label, leftWidth), leftWidth)} ${counts}`;
1073
+ const padded = padToWidth(truncateToWidth(line, width), width);
1074
+ rows.push(selected ? this.theme.bg("selectedBg", padded) : padded);
1075
+ }
1076
+
1077
+ return rows.slice(0, height);
1078
+ }
1079
+
1080
+ private getVisibleFileSections(): ReviewFileSection[] {
1081
+ return this.focusedFilePath
1082
+ ? this.fileIndex.sections.filter(
1083
+ (section) => section.filePath === this.focusedFilePath,
1084
+ )
1085
+ : this.fileIndex.sections;
1086
+ }
1087
+
1088
+ private getVisibleLineIndexes(): number[] {
1089
+ const indexes: number[] = [];
1090
+ for (const file of this.getVisibleFileSections()) {
1091
+ for (
1092
+ let index = file.startLineIndex;
1093
+ index <= file.endLineIndex;
1094
+ index++
1095
+ ) {
1096
+ indexes.push(index);
1097
+ }
1098
+ }
1099
+ return indexes;
1100
+ }
1101
+
1102
+ private isLineVisible(lineIndex: number): boolean {
1103
+ const line = this.lines[lineIndex];
1104
+ if (!line?.filePath) return this.focusedFilePath == null;
1105
+ return !this.focusedFilePath || line.filePath === this.focusedFilePath;
1106
+ }
1107
+
1108
+ private getCurrentFileSection(): ReviewFileSection | undefined {
1109
+ const current = this.fileIndex.sectionIndexByLine[this.selected];
1110
+ return current == null || current < 0
1111
+ ? undefined
1112
+ : this.fileIndex.sections[current];
1113
+ }
1114
+
1115
+ private ensureSelectedVisible(): void {
1116
+ if (this.isLineVisible(this.selected)) return;
1117
+ const file = this.getVisibleFileSections()[0];
1118
+ const target =
1119
+ file?.firstCommentableLineIndex ?? this.getVisibleLineIndexes()[0];
1120
+ if (target != null) this.navigation.setSelected(target);
1121
+ }
1122
+
949
1123
  private invalidateAnnotatedRows(): void {
950
1124
  this.annotatedRows = undefined;
951
1125
  this.annotatedRowByLineIndex = undefined;
@@ -1090,16 +1264,63 @@ export class ReviewComponent {
1090
1264
  }
1091
1265
 
1092
1266
  private move(delta: number): void {
1093
- if (!this.navigation.move(delta)) return;
1267
+ const visible = this.getVisibleLineIndexes();
1268
+ if (visible.length === 0) return;
1269
+ this.ensureSelectedVisible();
1270
+ const currentIndex = Math.max(0, visible.indexOf(this.selected));
1271
+ const next =
1272
+ visible[Math.max(0, Math.min(visible.length - 1, currentIndex + delta))];
1273
+ if (next == null || !this.navigation.setSelected(next)) return;
1094
1274
  this.tui.requestRender();
1095
1275
  }
1096
1276
 
1097
1277
  private jumpToBoundary(boundary: "start" | "end"): void {
1098
- const result = this.navigation.jumpToBoundary(boundary);
1278
+ const visible = this.getVisibleLineIndexes();
1279
+ if (visible.length === 0) return;
1280
+ const next =
1281
+ boundary === "start" ? visible[0] : visible[visible.length - 1];
1282
+ if (next == null) return;
1283
+ const result = this.navigation.jumpToIndex(next);
1099
1284
  if (!result.changed) return;
1100
1285
  this.tui.requestRender();
1101
1286
  }
1102
1287
 
1288
+ private jumpFile(direction: 1 | -1): void {
1289
+ const files = this.getVisibleFileSections();
1290
+ if (files.length === 0) return;
1291
+ const current = this.getCurrentFileSection();
1292
+ const currentIndex = current
1293
+ ? files.findIndex((file) => file.filePath === current.filePath)
1294
+ : -1;
1295
+ const nextIndex =
1296
+ currentIndex >= 0
1297
+ ? Math.max(0, Math.min(files.length - 1, currentIndex + direction))
1298
+ : direction === 1
1299
+ ? 0
1300
+ : files.length - 1;
1301
+ const next = files[nextIndex];
1302
+ if (!next) return;
1303
+ this.navigation.jumpToIndex(next.firstCommentableLineIndex);
1304
+ this.tui.requestRender(true);
1305
+ }
1306
+
1307
+ private toggleCurrentFileFocus(): void {
1308
+ const current = this.getCurrentFileSection();
1309
+ if (!current) return;
1310
+ this.focusedFilePath =
1311
+ this.focusedFilePath === current.filePath ? undefined : current.filePath;
1312
+ this.ensureSelectedVisible();
1313
+ this.highlightedLineCache.clear();
1314
+ this.invalidateAnnotatedRows();
1315
+ this.tui.requestRender(true);
1316
+ }
1317
+
1318
+ private toggleFileSidebar(): void {
1319
+ this.fileSidebarVisible = !this.fileSidebarVisible;
1320
+ this.invalidateAnnotatedRows();
1321
+ this.tui.requestRender(true);
1322
+ }
1323
+
1103
1324
  private toggleDiffRenderMode(): void {
1104
1325
  this.navigation.toggleDiffRenderMode();
1105
1326
  this.tui.requestRender(true);
@@ -1144,7 +1365,13 @@ export class ReviewComponent {
1144
1365
  }
1145
1366
 
1146
1367
  private extendSelection(delta: number): void {
1147
- if (!this.navigation.extendSelection(delta)) return;
1368
+ const visible = this.getVisibleLineIndexes();
1369
+ if (visible.length === 0) return;
1370
+ this.ensureSelectedVisible();
1371
+ const currentIndex = Math.max(0, visible.indexOf(this.selected));
1372
+ const next =
1373
+ visible[Math.max(0, Math.min(visible.length - 1, currentIndex + delta))];
1374
+ if (next == null || !this.navigation.extendSelectionTo(next)) return;
1148
1375
  this.tui.requestRender();
1149
1376
  }
1150
1377
 
@@ -1214,9 +1441,15 @@ export class ReviewComponent {
1214
1441
  }
1215
1442
 
1216
1443
  private getPositionText(selectedLine?: ReviewLine): string {
1217
- const position = `${Math.min(this.selected + 1, this.lines.length)}/${this.lines.length}`;
1444
+ const visible = this.getVisibleLineIndexes();
1445
+ const visiblePosition = Math.max(0, visible.indexOf(this.selected)) + 1;
1446
+ const position = `${visiblePosition}/${Math.max(visible.length, 1)}`;
1447
+ const file = this.getCurrentFileSection();
1448
+ const filePosition = file
1449
+ ? `${this.fileIndex.sections.findIndex((section) => section.filePath === file.filePath) + 1}/${this.fileIndex.sections.length}`
1450
+ : undefined;
1218
1451
  return selectedLine?.filePath
1219
- ? `${position} ${selectedLine.filePath}`
1452
+ ? `${position}${filePosition ? ` • file ${filePosition}` : ""} ${selectedLine.filePath}`
1220
1453
  : position;
1221
1454
  }
1222
1455
 
@@ -1239,9 +1472,8 @@ export class ReviewComponent {
1239
1472
  return `Selected ${count} lines: ${formatLocation(startLine)} -> ${formatLocation(endLine)}`;
1240
1473
  }
1241
1474
 
1242
- const selectedText = `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
1243
1475
  const workspaceText = this.formatWorkspaceSummary(workspaceSummary, false);
1244
- return workspaceText ? `${selectedText} • ${workspaceText}` : selectedText;
1476
+ return workspaceText ? workspaceText.slice(3) : "";
1245
1477
  }
1246
1478
 
1247
1479
  private formatWorkspaceSummary(
@@ -1265,7 +1497,7 @@ export class ReviewComponent {
1265
1497
  private jumpHunk(direction: 1 | -1): void {
1266
1498
  let index = this.selected + direction;
1267
1499
  while (index >= 0 && index < this.lines.length) {
1268
- if (this.lines[index]?.kind === "hunk") {
1500
+ if (this.isLineVisible(index) && this.lines[index]?.kind === "hunk") {
1269
1501
  this.selected = index;
1270
1502
  this.tui.requestRender();
1271
1503
  return;
@@ -0,0 +1,76 @@
1
+ import type { ReviewLine } from "./types.ts";
2
+
3
+ export type ReviewFileSection = {
4
+ filePath: string;
5
+ startLineIndex: number;
6
+ endLineIndex: number;
7
+ firstCommentableLineIndex: number;
8
+ additions: number;
9
+ deletions: number;
10
+ hunks: number;
11
+ };
12
+
13
+ export type ReviewFileIndex = {
14
+ sections: ReviewFileSection[];
15
+ sectionIndexByLine: number[];
16
+ };
17
+
18
+ export function buildReviewFileIndex(lines: ReviewLine[]): ReviewFileIndex {
19
+ const sections: ReviewFileSection[] = [];
20
+ const sectionIndexByLine: number[] = Array.from(
21
+ { length: lines.length },
22
+ () => -1,
23
+ );
24
+
25
+ let current: ReviewFileSection | undefined;
26
+ let currentIndex = -1;
27
+
28
+ const ensureSection = (filePath: string, lineIndex: number) => {
29
+ if (current?.filePath === filePath) return current;
30
+ currentIndex = sections.length;
31
+ current = {
32
+ filePath,
33
+ startLineIndex: lineIndex,
34
+ endLineIndex: lineIndex,
35
+ firstCommentableLineIndex: lineIndex,
36
+ additions: 0,
37
+ deletions: 0,
38
+ hunks: 0,
39
+ };
40
+ sections.push(current);
41
+ return current;
42
+ };
43
+
44
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
45
+ const line = lines[lineIndex]!;
46
+ if (!line.filePath) continue;
47
+ const section = ensureSection(line.filePath, lineIndex);
48
+ section.endLineIndex = lineIndex;
49
+ sectionIndexByLine[lineIndex] = currentIndex;
50
+ if (
51
+ line.commentable &&
52
+ section.firstCommentableLineIndex === section.startLineIndex
53
+ ) {
54
+ section.firstCommentableLineIndex = lineIndex;
55
+ }
56
+ if (line.kind === "add") section.additions++;
57
+ if (line.kind === "remove") section.deletions++;
58
+ if (line.kind === "hunk") section.hunks++;
59
+ }
60
+
61
+ for (const section of sections) {
62
+ if (!lines[section.firstCommentableLineIndex]?.commentable) {
63
+ const fallback = lines.findIndex(
64
+ (line, index) =>
65
+ index >= section.startLineIndex &&
66
+ index <= section.endLineIndex &&
67
+ line?.filePath === section.filePath &&
68
+ line.commentable,
69
+ );
70
+ section.firstCommentableLineIndex =
71
+ fallback >= 0 ? fallback : section.startLineIndex;
72
+ }
73
+ }
74
+
75
+ return { sections, sectionIndexByLine };
76
+ }
@@ -19,14 +19,16 @@ export class ReviewNavigationState {
19
19
  }
20
20
 
21
21
  move(delta: number): boolean {
22
- const next = this.clampLineIndex(this.selected + delta);
23
- if (next === this.selected) return false;
24
- this.selected = next;
25
- return true;
22
+ return this.setSelected(this.selected + delta);
26
23
  }
27
24
 
28
25
  jumpToBoundary(boundary: "start" | "end"): JumpBoundaryResult {
29
26
  const next = boundary === "start" ? 0 : Math.max(0, this.lineCount - 1);
27
+ return this.jumpToIndex(next);
28
+ }
29
+
30
+ jumpToIndex(index: number): JumpBoundaryResult {
31
+ const next = this.clampLineIndex(index);
30
32
  const hadSelection = this.selectionAnchor != null;
31
33
  const changed = next !== this.selected || hadSelection;
32
34
  this.selected = next;
@@ -35,10 +37,14 @@ export class ReviewNavigationState {
35
37
  }
36
38
 
37
39
  extendSelection(delta: number): boolean {
40
+ return this.extendSelectionTo(this.selected + delta);
41
+ }
42
+
43
+ extendSelectionTo(index: number): boolean {
38
44
  if (this.selectionAnchor == null) {
39
45
  this.selectionAnchor = this.selected;
40
46
  }
41
- const next = this.clampLineIndex(this.selected + delta);
47
+ const next = this.clampLineIndex(index);
42
48
  if (next === this.selected) return false;
43
49
  this.selected = next;
44
50
  return true;
@@ -50,6 +56,13 @@ export class ReviewNavigationState {
50
56
  return true;
51
57
  }
52
58
 
59
+ setSelected(index: number): boolean {
60
+ const next = this.clampLineIndex(index);
61
+ if (next === this.selected) return false;
62
+ this.selected = next;
63
+ return true;
64
+ }
65
+
53
66
  hasSelection(): boolean {
54
67
  return (
55
68
  this.selectionAnchor != null && this.selectionAnchor !== this.selected
@@ -26,7 +26,10 @@ export class ReviewSearchState {
26
26
  private _query = "";
27
27
  private activeMatchIndex = -1;
28
28
 
29
- constructor(private readonly lines: ReviewLine[]) {}
29
+ constructor(
30
+ private readonly lines: ReviewLine[],
31
+ private readonly isLineVisible: (lineIndex: number) => boolean = () => true,
32
+ ) {}
30
33
 
31
34
  get query(): string {
32
35
  return this._query;
@@ -160,6 +163,7 @@ export class ReviewSearchState {
160
163
 
161
164
  const matches: SearchMatch[] = [];
162
165
  this.lines.forEach((line, lineIndex) => {
166
+ if (!this.isLineVisible(lineIndex)) return;
163
167
  const haystack = getSearchableLineText(line);
164
168
  let start = haystack.indexOf(needle);
165
169
  while (start >= 0) {