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 +14 -1
- package/package.json +1 -1
- package/src/diff/source.ts +10 -1
- package/src/review/component.ts +273 -41
- package/src/review/files.ts +76 -0
- package/src/review/navigation.ts +18 -5
- package/src/review/search.ts +5 -1
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
|
-
- `
|
|
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
package/src/diff/source.ts
CHANGED
|
@@ -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 {
|
package/src/review/component.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
413
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
668
|
-
|
|
739
|
+
rows.push({ kind: "split", splitRowIndex });
|
|
740
|
+
if (!this.inlineAnnotationsVisible) continue;
|
|
669
741
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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
|
+
}
|
package/src/review/navigation.ts
CHANGED
|
@@ -19,14 +19,16 @@ export class ReviewNavigationState {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
move(delta: number): boolean {
|
|
22
|
-
|
|
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(
|
|
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
|
package/src/review/search.ts
CHANGED
|
@@ -26,7 +26,10 @@ export class ReviewSearchState {
|
|
|
26
26
|
private _query = "";
|
|
27
27
|
private activeMatchIndex = -1;
|
|
28
28
|
|
|
29
|
-
constructor(
|
|
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) {
|