pi-diff-review 0.1.11 → 0.1.13
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 +56 -2
- package/package.json +20 -5
- package/src/comment-manager.ts +73 -0
- package/src/diff-source.ts +36 -10
- package/src/explanation-controller.ts +167 -0
- package/src/index.ts +70 -0
- package/src/render-utils.ts +11 -0
- package/src/review-component.ts +130 -597
- package/src/review-navigation.ts +94 -0
- package/src/review-panes.ts +286 -0
- package/src/split-diff.ts +61 -0
- package/src/types.ts +0 -1
package/src/review-component.ts
CHANGED
|
@@ -2,23 +2,30 @@ import {
|
|
|
2
2
|
getLanguageFromPath,
|
|
3
3
|
highlightCode,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Editor, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { DiffExplainer } from "./explain.ts";
|
|
5
7
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from "./explain.ts";
|
|
8
|
+
GLOBAL_COMMENT_KEY,
|
|
9
|
+
buildCommentFromSelection,
|
|
10
|
+
buildCommentLineKeys,
|
|
11
|
+
buildGlobalComment,
|
|
12
|
+
getSelectionKey,
|
|
13
|
+
} from "./comment-manager.ts";
|
|
14
|
+
import {
|
|
15
|
+
ExplanationController,
|
|
16
|
+
getCurrentHunkScope,
|
|
17
|
+
} from "./explanation-controller.ts";
|
|
17
18
|
import { formatLocation } from "./prompt.ts";
|
|
19
|
+
import { ReviewNavigationState } from "./review-navigation.ts";
|
|
20
|
+
import {
|
|
21
|
+
renderCommentsPane as renderCommentsPaneContent,
|
|
22
|
+
renderExplanationPane as renderExplanationPaneContent,
|
|
23
|
+
} from "./review-panes.ts";
|
|
24
|
+
import { padToWidth, lineNumberCell } from "./render-utils.ts";
|
|
25
|
+
import { buildSplitDiffRows } from "./split-diff.ts";
|
|
18
26
|
import type {
|
|
19
27
|
DiffRenderMode,
|
|
20
28
|
ReviewComment,
|
|
21
|
-
ReviewLayout,
|
|
22
29
|
ReviewLine,
|
|
23
30
|
ReviewResult,
|
|
24
31
|
ReviewTheme,
|
|
@@ -29,32 +36,14 @@ import type {
|
|
|
29
36
|
SplitDiffRow,
|
|
30
37
|
} from "./types.ts";
|
|
31
38
|
|
|
32
|
-
function padToWidth(text: string, width: number): string {
|
|
33
|
-
const visible = visibleWidth(text);
|
|
34
|
-
if (visible >= width) return truncateToWidth(text, width);
|
|
35
|
-
return text + " ".repeat(width - visible);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function lineNumberCell(value?: number): string {
|
|
39
|
-
return value == null ? " " : String(value).padStart(4, " ");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const GLOBAL_COMMENT_KEY = "__global_diff_comment__";
|
|
43
|
-
|
|
44
39
|
export class ReviewComponent {
|
|
45
|
-
private
|
|
46
|
-
private scrollTop = 0;
|
|
40
|
+
private navigation: ReviewNavigationState;
|
|
47
41
|
private editMode = false;
|
|
48
42
|
private editingCommentKey?: string;
|
|
49
|
-
|
|
50
|
-
private
|
|
51
|
-
private diffRenderMode: DiffRenderMode = "unified";
|
|
43
|
+
|
|
44
|
+
private showRightPane = true;
|
|
52
45
|
private rightPaneMode: RightPaneMode = "comments";
|
|
53
|
-
private
|
|
54
|
-
private explanationAbort?: AbortController;
|
|
55
|
-
private explanationRequestId = 0;
|
|
56
|
-
private loadingFrame = 0;
|
|
57
|
-
private loadingTimer?: ReturnType<typeof setInterval>;
|
|
46
|
+
private explanationController: ExplanationController;
|
|
58
47
|
private editor: Editor;
|
|
59
48
|
private splitRows?: SplitDiffRow[];
|
|
60
49
|
private splitRowByLineIndex?: number[];
|
|
@@ -73,10 +62,21 @@ export class ReviewComponent {
|
|
|
73
62
|
private done: (result: ReviewResult) => void,
|
|
74
63
|
private explainer?: DiffExplainer,
|
|
75
64
|
private onCommentsChanged?: (comments: Map<string, ReviewComment>) => void,
|
|
65
|
+
cachedExplanations?: Map<string, string>,
|
|
66
|
+
private onExplanationsChanged?: (explanations: Map<string, string>) => void,
|
|
76
67
|
) {
|
|
77
68
|
const firstCommentable = this.lines.findIndex((line) => line.commentable);
|
|
78
|
-
this.
|
|
69
|
+
this.navigation = new ReviewNavigationState(
|
|
70
|
+
this.lines.length,
|
|
71
|
+
firstCommentable >= 0 ? firstCommentable : 0,
|
|
72
|
+
);
|
|
79
73
|
this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
|
|
74
|
+
this.explanationController = new ExplanationController(
|
|
75
|
+
tui,
|
|
76
|
+
explainer,
|
|
77
|
+
cachedExplanations,
|
|
78
|
+
onExplanationsChanged,
|
|
79
|
+
);
|
|
80
80
|
|
|
81
81
|
this.editor = new Editor(tui as never, {
|
|
82
82
|
borderColor: (s) => theme.fg("accent", s),
|
|
@@ -96,10 +96,7 @@ export class ReviewComponent {
|
|
|
96
96
|
if (this.comments.delete(GLOBAL_COMMENT_KEY))
|
|
97
97
|
this.markCommentsChanged();
|
|
98
98
|
} else {
|
|
99
|
-
this.comments.set(
|
|
100
|
-
GLOBAL_COMMENT_KEY,
|
|
101
|
-
this.buildGlobalComment(trimmed),
|
|
102
|
-
);
|
|
99
|
+
this.comments.set(GLOBAL_COMMENT_KEY, buildGlobalComment(trimmed));
|
|
103
100
|
this.markCommentsChanged();
|
|
104
101
|
}
|
|
105
102
|
this.exitEditMode();
|
|
@@ -118,7 +115,7 @@ export class ReviewComponent {
|
|
|
118
115
|
} else {
|
|
119
116
|
this.comments.set(
|
|
120
117
|
key,
|
|
121
|
-
this.
|
|
118
|
+
buildCommentFromSelection(this.lines, selection, trimmed),
|
|
122
119
|
);
|
|
123
120
|
this.markCommentsChanged();
|
|
124
121
|
}
|
|
@@ -127,6 +124,26 @@ export class ReviewComponent {
|
|
|
127
124
|
};
|
|
128
125
|
}
|
|
129
126
|
|
|
127
|
+
private get selected(): number {
|
|
128
|
+
return this.navigation.selected;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private set selected(value: number) {
|
|
132
|
+
this.navigation.selected = value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private get scrollTop(): number {
|
|
136
|
+
return this.navigation.scrollTop;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private set scrollTop(value: number) {
|
|
140
|
+
this.navigation.scrollTop = value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private get diffRenderMode(): DiffRenderMode {
|
|
144
|
+
return this.navigation.diffRenderMode;
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
handleInput(data: string): void {
|
|
131
148
|
if (this.editMode) {
|
|
132
149
|
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
@@ -151,6 +168,10 @@ export class ReviewComponent {
|
|
|
151
168
|
return;
|
|
152
169
|
}
|
|
153
170
|
if (data === "t") {
|
|
171
|
+
this.toggleRightPane();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (data === "v") {
|
|
154
175
|
this.toggleDiffRenderMode();
|
|
155
176
|
return;
|
|
156
177
|
}
|
|
@@ -232,22 +253,18 @@ export class ReviewComponent {
|
|
|
232
253
|
? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
|
|
233
254
|
: this.hasSelection()
|
|
234
255
|
? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • C overall comment • Enter submit`
|
|
235
|
-
: `${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 • ? explain • J/K extend • c comment • C overall • x delete • n/p hunk • Enter submit • q quit`,
|
|
256
|
+
: `${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • ${this.showRightPane ? "sidebar shown" : "sidebar hidden"} • j/k move • g/G top/bottom • ctrl-u/d page • t sidebar • v unified/split • ? explain • J/K extend • c comment • C overall • x delete • n/p hunk • Enter submit • q quit`,
|
|
236
257
|
),
|
|
237
258
|
width,
|
|
238
259
|
),
|
|
239
260
|
);
|
|
240
|
-
if (this.
|
|
261
|
+
if (!this.showRightPane) {
|
|
241
262
|
this.ensureScroll(viewportHeight);
|
|
242
|
-
output.push(
|
|
243
|
-
...this.renderSideBySide(width, viewportHeight, selectedLine),
|
|
244
|
-
);
|
|
263
|
+
output.push(...this.renderFullWidthDiffRows(width, viewportHeight));
|
|
245
264
|
} else {
|
|
246
|
-
|
|
247
|
-
this.getStackedHeights(viewportHeight);
|
|
248
|
-
this.ensureScroll(diffHeight);
|
|
265
|
+
this.ensureScroll(viewportHeight);
|
|
249
266
|
output.push(
|
|
250
|
-
...this.
|
|
267
|
+
...this.renderSideBySide(width, viewportHeight, selectedLine),
|
|
251
268
|
);
|
|
252
269
|
}
|
|
253
270
|
|
|
@@ -282,28 +299,18 @@ export class ReviewComponent {
|
|
|
282
299
|
return output;
|
|
283
300
|
}
|
|
284
301
|
|
|
285
|
-
private renderStacked(
|
|
286
|
-
width: number,
|
|
287
|
-
diffHeight: number,
|
|
288
|
-
commentsHeight: number,
|
|
289
|
-
selectedLine?: ReviewLine,
|
|
290
|
-
): string[] {
|
|
291
|
-
const comments = this.renderRightPane(width, commentsHeight, selectedLine);
|
|
292
|
-
return [
|
|
293
|
-
...this.renderDiffRows(width, diffHeight),
|
|
294
|
-
this.theme.fg("borderMuted", "─".repeat(width)),
|
|
295
|
-
...Array.from({ length: commentsHeight }, (_, index) =>
|
|
296
|
-
padToWidth(truncateToWidth(comments[index] ?? "", width), width),
|
|
297
|
-
),
|
|
298
|
-
];
|
|
299
|
-
}
|
|
300
|
-
|
|
301
302
|
private renderDiffRows(width: number, height: number): string[] {
|
|
302
303
|
return this.diffRenderMode === "split"
|
|
303
304
|
? this.renderSplitDiffRows(width, height)
|
|
304
305
|
: this.renderUnifiedDiffRows(width, height);
|
|
305
306
|
}
|
|
306
307
|
|
|
308
|
+
private renderFullWidthDiffRows(width: number, height: number): string[] {
|
|
309
|
+
return this.renderDiffRows(width, height).map((row) =>
|
|
310
|
+
padToWidth(truncateToWidth(row, width), width),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
307
314
|
private renderUnifiedDiffRows(width: number, height: number): string[] {
|
|
308
315
|
const output: string[] = [];
|
|
309
316
|
const selection = this.getSelectionBounds();
|
|
@@ -378,66 +385,42 @@ export class ReviewComponent {
|
|
|
378
385
|
return Math.max(6, terminalRows - headerHeight - footerHeight);
|
|
379
386
|
}
|
|
380
387
|
|
|
381
|
-
private getStackedHeights(viewportHeight: number): {
|
|
382
|
-
diffHeight: number;
|
|
383
|
-
commentsHeight: number;
|
|
384
|
-
} {
|
|
385
|
-
const availableForPanes = Math.max(2, viewportHeight - 1);
|
|
386
|
-
let diffHeight = Math.max(1, Math.floor(availableForPanes * 0.6));
|
|
387
|
-
let commentsHeight = availableForPanes - diffHeight;
|
|
388
|
-
|
|
389
|
-
if (commentsHeight < 3 && availableForPanes >= 4) {
|
|
390
|
-
commentsHeight = 3;
|
|
391
|
-
diffHeight = availableForPanes - commentsHeight;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return { diffHeight, commentsHeight };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
388
|
invalidate(): void {
|
|
398
389
|
this.highlightedLineCache.clear();
|
|
399
390
|
}
|
|
400
391
|
|
|
401
392
|
dispose(): void {
|
|
402
|
-
this.
|
|
403
|
-
this.stopLoadingTimer();
|
|
393
|
+
this.explanationController.dispose();
|
|
404
394
|
}
|
|
405
395
|
|
|
406
396
|
private move(delta: number): void {
|
|
407
|
-
|
|
408
|
-
0,
|
|
409
|
-
Math.min(this.lines.length - 1, this.selected + delta),
|
|
410
|
-
);
|
|
411
|
-
if (next === this.selected) return;
|
|
412
|
-
this.selected = next;
|
|
397
|
+
if (!this.navigation.move(delta)) return;
|
|
413
398
|
this.tui.requestRender();
|
|
414
399
|
}
|
|
415
400
|
|
|
416
401
|
private jumpToBoundary(boundary: "start" | "end"): void {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
this.selected = next;
|
|
421
|
-
if (hadSelection) {
|
|
422
|
-
this.clearSelection();
|
|
423
|
-
} else {
|
|
424
|
-
this.tui.requestRender();
|
|
425
|
-
}
|
|
402
|
+
const result = this.navigation.jumpToBoundary(boundary);
|
|
403
|
+
if (!result.changed) return;
|
|
404
|
+
this.tui.requestRender();
|
|
426
405
|
}
|
|
427
406
|
|
|
428
|
-
private
|
|
429
|
-
this.
|
|
407
|
+
private toggleDiffRenderMode(): void {
|
|
408
|
+
this.navigation.toggleDiffRenderMode();
|
|
430
409
|
this.tui.requestRender(true);
|
|
431
410
|
}
|
|
432
411
|
|
|
433
|
-
private
|
|
434
|
-
this.
|
|
435
|
-
this.diffRenderMode === "unified" ? "split" : "unified";
|
|
436
|
-
this.scrollTop = 0;
|
|
412
|
+
private toggleRightPane(): void {
|
|
413
|
+
this.showRightPane = !this.showRightPane;
|
|
437
414
|
this.tui.requestRender(true);
|
|
438
415
|
}
|
|
439
416
|
|
|
417
|
+
private showCommentsPane(): void {
|
|
418
|
+
this.showRightPane = true;
|
|
419
|
+
this.rightPaneMode = "comments";
|
|
420
|
+
}
|
|
421
|
+
|
|
440
422
|
private toggleExplanationPane(): void {
|
|
423
|
+
this.showRightPane = true;
|
|
441
424
|
this.rightPaneMode =
|
|
442
425
|
this.rightPaneMode === "comments" ? "explanation" : "comments";
|
|
443
426
|
if (this.rightPaneMode === "explanation") {
|
|
@@ -448,44 +431,25 @@ export class ReviewComponent {
|
|
|
448
431
|
|
|
449
432
|
private getPageMoveAmount(): number {
|
|
450
433
|
const contentHeight = this.getContentHeight();
|
|
451
|
-
|
|
452
|
-
this.layout === "stacked"
|
|
453
|
-
? this.getStackedHeights(contentHeight).diffHeight
|
|
454
|
-
: contentHeight;
|
|
455
|
-
return Math.max(1, Math.floor(diffHeight / 2));
|
|
434
|
+
return Math.max(1, Math.floor(contentHeight / 2));
|
|
456
435
|
}
|
|
457
436
|
|
|
458
437
|
private extendSelection(delta: number): void {
|
|
459
|
-
if (this.
|
|
460
|
-
this.selectionAnchor = this.selected;
|
|
461
|
-
}
|
|
462
|
-
const next = Math.max(
|
|
463
|
-
0,
|
|
464
|
-
Math.min(this.lines.length - 1, this.selected + delta),
|
|
465
|
-
);
|
|
466
|
-
if (next === this.selected) return;
|
|
467
|
-
this.selected = next;
|
|
438
|
+
if (!this.navigation.extendSelection(delta)) return;
|
|
468
439
|
this.tui.requestRender();
|
|
469
440
|
}
|
|
470
441
|
|
|
471
442
|
private clearSelection(): void {
|
|
472
|
-
if (this.
|
|
473
|
-
this.selectionAnchor = undefined;
|
|
443
|
+
if (!this.navigation.clearSelection()) return;
|
|
474
444
|
this.tui.requestRender();
|
|
475
445
|
}
|
|
476
446
|
|
|
477
447
|
private hasSelection(): boolean {
|
|
478
|
-
return (
|
|
479
|
-
this.selectionAnchor != null && this.selectionAnchor !== this.selected
|
|
480
|
-
);
|
|
448
|
+
return this.navigation.hasSelection();
|
|
481
449
|
}
|
|
482
450
|
|
|
483
451
|
private getSelectionBounds(): SelectionBounds | undefined {
|
|
484
|
-
|
|
485
|
-
return {
|
|
486
|
-
start: Math.min(this.selectionAnchor, this.selected),
|
|
487
|
-
end: Math.max(this.selectionAnchor, this.selected),
|
|
488
|
-
};
|
|
452
|
+
return this.navigation.getSelectionBounds();
|
|
489
453
|
}
|
|
490
454
|
|
|
491
455
|
private getActiveCommentSelection(): SelectionBounds | undefined {
|
|
@@ -497,7 +461,7 @@ export class ReviewComponent {
|
|
|
497
461
|
}
|
|
498
462
|
|
|
499
463
|
private getSelectionKey(start: number, end: number): string {
|
|
500
|
-
return
|
|
464
|
+
return getSelectionKey(this.lines, start, end);
|
|
501
465
|
}
|
|
502
466
|
|
|
503
467
|
private getCommentForSelection(
|
|
@@ -522,62 +486,13 @@ export class ReviewComponent {
|
|
|
522
486
|
private ensureCommentLineKeys(): void {
|
|
523
487
|
if (this.commentLineKeysRevision === this.commentsRevision) return;
|
|
524
488
|
|
|
525
|
-
this.commentLineKeys =
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
if (start == null || end == null) continue;
|
|
530
|
-
const from = Math.min(start, end);
|
|
531
|
-
const to = Math.max(start, end);
|
|
532
|
-
for (let index = from; index <= to; index++) {
|
|
533
|
-
const keys = this.commentLineKeys.get(index);
|
|
534
|
-
if (keys) {
|
|
535
|
-
keys.push(key);
|
|
536
|
-
} else {
|
|
537
|
-
this.commentLineKeys.set(index, [key]);
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
489
|
+
this.commentLineKeys = buildCommentLineKeys(
|
|
490
|
+
this.comments,
|
|
491
|
+
this.lineIndexById,
|
|
492
|
+
);
|
|
542
493
|
this.commentLineKeysRevision = this.commentsRevision;
|
|
543
494
|
}
|
|
544
495
|
|
|
545
|
-
private buildGlobalComment(text: string): ReviewComment {
|
|
546
|
-
return {
|
|
547
|
-
id: GLOBAL_COMMENT_KEY,
|
|
548
|
-
filePath: "Overall diff",
|
|
549
|
-
text,
|
|
550
|
-
global: true,
|
|
551
|
-
startLineId: GLOBAL_COMMENT_KEY,
|
|
552
|
-
endLineId: GLOBAL_COMMENT_KEY,
|
|
553
|
-
lineText: "",
|
|
554
|
-
};
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
private buildCommentFromSelection(
|
|
558
|
-
selection: SelectionBounds,
|
|
559
|
-
text: string,
|
|
560
|
-
): ReviewComment {
|
|
561
|
-
const startLine = this.lines[selection.start]!;
|
|
562
|
-
const endLine = this.lines[selection.end]!;
|
|
563
|
-
const excerpt = this.lines
|
|
564
|
-
.slice(selection.start, selection.end + 1)
|
|
565
|
-
.map((line) => line.text)
|
|
566
|
-
.join("\n");
|
|
567
|
-
return {
|
|
568
|
-
id: this.getSelectionKey(selection.start, selection.end),
|
|
569
|
-
filePath: startLine.filePath ?? endLine.filePath ?? "(unknown file)",
|
|
570
|
-
text,
|
|
571
|
-
startLineId: startLine.id,
|
|
572
|
-
endLineId: endLine.id,
|
|
573
|
-
startOldLineNumber: startLine.oldLineNumber,
|
|
574
|
-
startNewLineNumber: startLine.newLineNumber,
|
|
575
|
-
endOldLineNumber: endLine.oldLineNumber,
|
|
576
|
-
endNewLineNumber: endLine.newLineNumber,
|
|
577
|
-
lineText: excerpt,
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
|
|
581
496
|
private getPositionText(selectedLine?: ReviewLine): string {
|
|
582
497
|
const position = `${Math.min(this.selected + 1, this.lines.length)}/${this.lines.length}`;
|
|
583
498
|
return selectedLine?.filePath
|
|
@@ -621,7 +536,7 @@ export class ReviewComponent {
|
|
|
621
536
|
|
|
622
537
|
private startGlobalEditMode(): void {
|
|
623
538
|
const existing = this.comments.get(GLOBAL_COMMENT_KEY);
|
|
624
|
-
this.
|
|
539
|
+
this.showCommentsPane();
|
|
625
540
|
this.editMode = true;
|
|
626
541
|
this.editingCommentKey = GLOBAL_COMMENT_KEY;
|
|
627
542
|
this.editor.setText(existing?.text ?? "");
|
|
@@ -637,7 +552,7 @@ export class ReviewComponent {
|
|
|
637
552
|
if (startLine.filePath !== endLine.filePath) return;
|
|
638
553
|
|
|
639
554
|
const existing = this.getCommentForSelection(selection);
|
|
640
|
-
this.
|
|
555
|
+
this.showCommentsPane();
|
|
641
556
|
this.editMode = true;
|
|
642
557
|
this.editingCommentKey = this.getSelectionKey(
|
|
643
558
|
selection.start,
|
|
@@ -657,57 +572,7 @@ export class ReviewComponent {
|
|
|
657
572
|
private getSplitDiffRows(): SplitDiffRow[] {
|
|
658
573
|
if (this.splitRows) return this.splitRows;
|
|
659
574
|
|
|
660
|
-
const rows
|
|
661
|
-
const rowByLineIndex: number[] = [];
|
|
662
|
-
let index = 0;
|
|
663
|
-
|
|
664
|
-
const pushRow = (row: SplitDiffRow) => {
|
|
665
|
-
const displayRow = rows.length;
|
|
666
|
-
rows.push(row);
|
|
667
|
-
if (row.kind === "full") {
|
|
668
|
-
rowByLineIndex[row.cell.index] = displayRow;
|
|
669
|
-
} else {
|
|
670
|
-
if (row.left) rowByLineIndex[row.left.index] = displayRow;
|
|
671
|
-
if (row.right) rowByLineIndex[row.right.index] = displayRow;
|
|
672
|
-
}
|
|
673
|
-
};
|
|
674
|
-
|
|
675
|
-
while (index < this.lines.length) {
|
|
676
|
-
const line = this.lines[index]!;
|
|
677
|
-
|
|
678
|
-
if (line.kind === "remove" || line.kind === "add") {
|
|
679
|
-
const removals: SplitDiffCell[] = [];
|
|
680
|
-
const additions: SplitDiffCell[] = [];
|
|
681
|
-
|
|
682
|
-
while (this.lines[index]?.kind === "remove") {
|
|
683
|
-
removals.push({ line: this.lines[index]!, index });
|
|
684
|
-
index++;
|
|
685
|
-
}
|
|
686
|
-
while (this.lines[index]?.kind === "add") {
|
|
687
|
-
additions.push({ line: this.lines[index]!, index });
|
|
688
|
-
index++;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const count = Math.max(removals.length, additions.length);
|
|
692
|
-
for (let offset = 0; offset < count; offset++) {
|
|
693
|
-
pushRow({
|
|
694
|
-
kind: "split",
|
|
695
|
-
left: removals[offset],
|
|
696
|
-
right: additions[offset],
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
continue;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
if (line.kind === "context") {
|
|
703
|
-
const cell = { line, index };
|
|
704
|
-
pushRow({ kind: "split", left: cell, right: cell });
|
|
705
|
-
} else {
|
|
706
|
-
pushRow({ kind: "full", cell: { line, index } });
|
|
707
|
-
}
|
|
708
|
-
index++;
|
|
709
|
-
}
|
|
710
|
-
|
|
575
|
+
const { rows, rowByLineIndex } = buildSplitDiffRows(this.lines);
|
|
711
576
|
this.splitRows = rows;
|
|
712
577
|
this.splitRowByLineIndex = rowByLineIndex;
|
|
713
578
|
return rows;
|
|
@@ -749,18 +614,10 @@ export class ReviewComponent {
|
|
|
749
614
|
}
|
|
750
615
|
|
|
751
616
|
private ensureScroll(viewportHeight: number): void {
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
this.scrollTop = selectedRow;
|
|
757
|
-
}
|
|
758
|
-
if (selectedRow >= this.scrollTop + viewportHeight) {
|
|
759
|
-
this.scrollTop = selectedRow - viewportHeight + 1;
|
|
760
|
-
}
|
|
761
|
-
this.scrollTop = Math.max(
|
|
762
|
-
0,
|
|
763
|
-
Math.min(this.scrollTop, Math.max(0, rowCount - viewportHeight)),
|
|
617
|
+
this.navigation.ensureScroll(
|
|
618
|
+
viewportHeight,
|
|
619
|
+
this.getSelectedDisplayRow(),
|
|
620
|
+
this.getDisplayRowCount(),
|
|
764
621
|
);
|
|
765
622
|
}
|
|
766
623
|
|
|
@@ -858,169 +715,22 @@ export class ReviewComponent {
|
|
|
858
715
|
height: number,
|
|
859
716
|
selectedLine?: ReviewLine,
|
|
860
717
|
): string[] {
|
|
861
|
-
const lines: string[] = [];
|
|
862
|
-
const title = this.theme.fg("accent", this.theme.bold("Comments"));
|
|
863
718
|
const selection = this.getActiveCommentSelection();
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
);
|
|
880
|
-
lines.push("");
|
|
881
|
-
|
|
882
|
-
if (this.editMode && this.editingCommentKey === GLOBAL_COMMENT_KEY) {
|
|
883
|
-
lines[1] = truncateToWidth(
|
|
884
|
-
this.theme.fg("dim", "Overall diff comment"),
|
|
885
|
-
width,
|
|
886
|
-
);
|
|
887
|
-
lines.push(
|
|
888
|
-
...wrapTextWithAnsi(
|
|
889
|
-
this.theme.fg(
|
|
890
|
-
"dim",
|
|
891
|
-
"Editing overall diff comment. Enter saves. Esc or Ctrl+C cancels.",
|
|
892
|
-
),
|
|
893
|
-
width,
|
|
894
|
-
),
|
|
895
|
-
);
|
|
896
|
-
lines.push("");
|
|
897
|
-
for (const line of this.editor.render(Math.max(10, width))) {
|
|
898
|
-
lines.push(truncateToWidth(line, width));
|
|
899
|
-
}
|
|
900
|
-
return lines.slice(0, height);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
const globalComment = this.comments.get(GLOBAL_COMMENT_KEY);
|
|
904
|
-
if (globalComment) {
|
|
905
|
-
lines.push(
|
|
906
|
-
truncateToWidth(
|
|
907
|
-
this.theme.fg("accent", this.theme.bold("Overall diff comment")),
|
|
908
|
-
width,
|
|
909
|
-
),
|
|
910
|
-
);
|
|
911
|
-
lines.push(
|
|
912
|
-
...wrapTextWithAnsi(this.theme.fg("text", globalComment.text), width),
|
|
913
|
-
);
|
|
914
|
-
lines.push(...wrapTextWithAnsi(this.theme.fg("dim", "C edits"), width));
|
|
915
|
-
lines.push("");
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
if (!selectedLine) {
|
|
919
|
-
lines.push(
|
|
920
|
-
...wrapTextWithAnsi(
|
|
921
|
-
this.theme.fg("muted", "No diff lines available."),
|
|
922
|
-
width,
|
|
923
|
-
),
|
|
924
|
-
);
|
|
925
|
-
return lines.slice(0, height);
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
if (!selection) {
|
|
929
|
-
lines.push(
|
|
930
|
-
...wrapTextWithAnsi(
|
|
931
|
-
this.theme.fg(
|
|
932
|
-
"muted",
|
|
933
|
-
"Move to a diff line and press c to add a comment, or press C for an overall diff comment.",
|
|
934
|
-
),
|
|
935
|
-
width,
|
|
936
|
-
),
|
|
937
|
-
);
|
|
938
|
-
return lines.slice(0, height);
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
if (this.editMode && currentComment?.id === this.editingCommentKey) {
|
|
942
|
-
lines.push(
|
|
943
|
-
...wrapTextWithAnsi(
|
|
944
|
-
this.theme.fg(
|
|
945
|
-
"dim",
|
|
946
|
-
"Editing comment. Enter saves. Esc or Ctrl+C cancels.",
|
|
947
|
-
),
|
|
948
|
-
width,
|
|
949
|
-
),
|
|
950
|
-
);
|
|
951
|
-
lines.push("");
|
|
952
|
-
for (const line of this.editor.render(Math.max(10, width))) {
|
|
953
|
-
lines.push(truncateToWidth(line, width));
|
|
954
|
-
}
|
|
955
|
-
} else if (this.editMode && this.editingCommentKey) {
|
|
956
|
-
lines.push(
|
|
957
|
-
...wrapTextWithAnsi(
|
|
958
|
-
this.theme.fg(
|
|
959
|
-
"dim",
|
|
960
|
-
"Editing comment. Enter saves. Esc or Ctrl+C cancels.",
|
|
961
|
-
),
|
|
962
|
-
width,
|
|
963
|
-
),
|
|
964
|
-
);
|
|
965
|
-
lines.push("");
|
|
966
|
-
for (const line of this.editor.render(Math.max(10, width))) {
|
|
967
|
-
lines.push(truncateToWidth(line, width));
|
|
968
|
-
}
|
|
969
|
-
} else if (currentComment) {
|
|
970
|
-
lines.push(
|
|
971
|
-
...wrapTextWithAnsi(this.theme.fg("text", currentComment.text), width),
|
|
972
|
-
);
|
|
973
|
-
lines.push("");
|
|
974
|
-
lines.push(
|
|
975
|
-
...wrapTextWithAnsi(
|
|
976
|
-
this.theme.fg("dim", "x deletes this comment • c edits"),
|
|
977
|
-
width,
|
|
978
|
-
),
|
|
979
|
-
);
|
|
980
|
-
} else {
|
|
981
|
-
lines.push(
|
|
982
|
-
...wrapTextWithAnsi(
|
|
983
|
-
this.theme.fg(
|
|
984
|
-
"muted",
|
|
985
|
-
this.hasSelection()
|
|
986
|
-
? "No comment on this range."
|
|
987
|
-
: "No comment on this line.",
|
|
988
|
-
),
|
|
989
|
-
width,
|
|
990
|
-
),
|
|
991
|
-
);
|
|
992
|
-
lines.push("");
|
|
993
|
-
lines.push(
|
|
994
|
-
...wrapTextWithAnsi(
|
|
995
|
-
this.theme.fg(
|
|
996
|
-
"dim",
|
|
997
|
-
this.hasSelection()
|
|
998
|
-
? "Press c to add a range comment, or C for an overall diff comment."
|
|
999
|
-
: "Press c to add one. Use J/K to extend a range. Press C for an overall diff comment.",
|
|
1000
|
-
),
|
|
1001
|
-
width,
|
|
1002
|
-
),
|
|
1003
|
-
);
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
lines.push("");
|
|
1007
|
-
lines.push(
|
|
1008
|
-
truncateToWidth(
|
|
1009
|
-
this.theme.fg("accent", this.theme.bold("Excerpt")),
|
|
1010
|
-
width,
|
|
1011
|
-
),
|
|
1012
|
-
);
|
|
1013
|
-
const excerpt = this.lines
|
|
1014
|
-
.slice(selection.start, selection.end + 1)
|
|
1015
|
-
.map((line) => line.text)
|
|
1016
|
-
.join("\n");
|
|
1017
|
-
lines.push(
|
|
1018
|
-
...wrapTextWithAnsi(
|
|
1019
|
-
this.theme.fg("toolDiffContext", excerpt || "(blank line)"),
|
|
1020
|
-
width,
|
|
1021
|
-
),
|
|
1022
|
-
);
|
|
1023
|
-
return lines.slice(0, height);
|
|
719
|
+
return renderCommentsPaneContent({
|
|
720
|
+
width,
|
|
721
|
+
height,
|
|
722
|
+
selectedLine,
|
|
723
|
+
theme: this.theme,
|
|
724
|
+
lines: this.lines,
|
|
725
|
+
comments: this.comments,
|
|
726
|
+
editor: this.editor,
|
|
727
|
+
editMode: this.editMode,
|
|
728
|
+
editingCommentKey: this.editingCommentKey,
|
|
729
|
+
selection,
|
|
730
|
+
currentComment: this.getCommentForSelection(selection),
|
|
731
|
+
footerText: this.getFooterText(selectedLine),
|
|
732
|
+
hasSelection: this.hasSelection(),
|
|
733
|
+
});
|
|
1024
734
|
}
|
|
1025
735
|
|
|
1026
736
|
private renderExplanationPane(
|
|
@@ -1028,198 +738,21 @@ export class ReviewComponent {
|
|
|
1028
738
|
height: number,
|
|
1029
739
|
selectedLine?: ReviewLine,
|
|
1030
740
|
): string[] {
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
"dim",
|
|
1040
|
-
scope?.title ??
|
|
1041
|
-
(selectedLine ? formatLocation(selectedLine) : "No selection"),
|
|
1042
|
-
),
|
|
1043
|
-
width,
|
|
1044
|
-
),
|
|
1045
|
-
);
|
|
1046
|
-
lines.push("");
|
|
1047
|
-
|
|
1048
|
-
if (!this.explainer) {
|
|
1049
|
-
lines.push(
|
|
1050
|
-
...wrapTextWithAnsi(
|
|
1051
|
-
this.theme.fg("warning", "Diff explanations are unavailable."),
|
|
1052
|
-
width,
|
|
1053
|
-
),
|
|
1054
|
-
);
|
|
1055
|
-
return lines.slice(0, height);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
if (!scope) {
|
|
1059
|
-
lines.push(
|
|
1060
|
-
...wrapTextWithAnsi(
|
|
1061
|
-
this.theme.fg(
|
|
1062
|
-
"muted",
|
|
1063
|
-
"Move to a changed hunk and press ? to generate an explanation.",
|
|
1064
|
-
),
|
|
1065
|
-
width,
|
|
1066
|
-
),
|
|
1067
|
-
);
|
|
1068
|
-
return lines.slice(0, height);
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
const explanation = this.explanations.get(scope.key);
|
|
1072
|
-
if (!explanation) {
|
|
1073
|
-
lines.push(
|
|
1074
|
-
...wrapTextWithAnsi(
|
|
1075
|
-
this.theme.fg(
|
|
1076
|
-
"muted",
|
|
1077
|
-
"No explanation generated yet. Press ? again after returning to comments to generate this hunk.",
|
|
1078
|
-
),
|
|
1079
|
-
width,
|
|
1080
|
-
),
|
|
1081
|
-
);
|
|
1082
|
-
} else if (explanation.status === "loading") {
|
|
1083
|
-
const spinner = this.getLoadingFrame();
|
|
1084
|
-
lines.push(
|
|
1085
|
-
truncateToWidth(
|
|
1086
|
-
this.theme.fg("accent", `${spinner} Generating explanation...`),
|
|
1087
|
-
width,
|
|
1088
|
-
),
|
|
1089
|
-
);
|
|
1090
|
-
if (explanation.text.trim()) {
|
|
1091
|
-
lines.push("");
|
|
1092
|
-
lines.push(
|
|
1093
|
-
...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
|
|
1094
|
-
);
|
|
1095
|
-
}
|
|
1096
|
-
} else if (explanation.status === "error") {
|
|
1097
|
-
lines.push(
|
|
1098
|
-
...wrapTextWithAnsi(
|
|
1099
|
-
this.theme.fg(
|
|
1100
|
-
"warning",
|
|
1101
|
-
`Unable to explain diff: ${explanation.message}`,
|
|
1102
|
-
),
|
|
1103
|
-
width,
|
|
1104
|
-
),
|
|
1105
|
-
);
|
|
1106
|
-
} else {
|
|
1107
|
-
lines.push(
|
|
1108
|
-
...wrapTextWithAnsi(this.theme.fg("text", explanation.text), width),
|
|
1109
|
-
);
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
lines.push("");
|
|
1113
|
-
lines.push(...wrapTextWithAnsi(this.theme.fg("dim", "? comments"), width));
|
|
1114
|
-
return lines.slice(0, height);
|
|
741
|
+
return renderExplanationPaneContent({
|
|
742
|
+
width,
|
|
743
|
+
height,
|
|
744
|
+
selectedLine,
|
|
745
|
+
theme: this.theme,
|
|
746
|
+
scope: this.getCurrentHunkScope(),
|
|
747
|
+
controller: this.explanationController,
|
|
748
|
+
});
|
|
1115
749
|
}
|
|
1116
750
|
|
|
1117
751
|
private ensureCurrentExplanation(): void {
|
|
1118
|
-
|
|
1119
|
-
if (!scope || !this.explainer) return;
|
|
1120
|
-
if (this.explanations.has(scope.key)) return;
|
|
1121
|
-
|
|
1122
|
-
this.explanationAbort?.abort();
|
|
1123
|
-
const controller = new AbortController();
|
|
1124
|
-
this.explanationAbort = controller;
|
|
1125
|
-
const requestId = ++this.explanationRequestId;
|
|
1126
|
-
let text = "";
|
|
1127
|
-
|
|
1128
|
-
this.explanations.set(scope.key, { status: "loading", text });
|
|
1129
|
-
this.startLoadingTimer();
|
|
1130
|
-
|
|
1131
|
-
void this.explainer
|
|
1132
|
-
.explain(scope, {
|
|
1133
|
-
signal: controller.signal,
|
|
1134
|
-
onDelta: (delta) => {
|
|
1135
|
-
if (requestId !== this.explanationRequestId) return;
|
|
1136
|
-
text += delta;
|
|
1137
|
-
this.explanations.set(scope.key, { status: "loading", text });
|
|
1138
|
-
this.tui.requestRender();
|
|
1139
|
-
},
|
|
1140
|
-
})
|
|
1141
|
-
.then((finalText) => {
|
|
1142
|
-
if (requestId !== this.explanationRequestId) return;
|
|
1143
|
-
this.explanations.set(scope.key, {
|
|
1144
|
-
status: "ready",
|
|
1145
|
-
text: finalText.trim() || text.trim() || "No explanation returned.",
|
|
1146
|
-
});
|
|
1147
|
-
})
|
|
1148
|
-
.catch((error) => {
|
|
1149
|
-
if (requestId !== this.explanationRequestId) return;
|
|
1150
|
-
if (controller.signal.aborted) return;
|
|
1151
|
-
this.explanations.set(scope.key, {
|
|
1152
|
-
status: "error",
|
|
1153
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1154
|
-
});
|
|
1155
|
-
})
|
|
1156
|
-
.finally(() => {
|
|
1157
|
-
if (requestId !== this.explanationRequestId) return;
|
|
1158
|
-
this.stopLoadingTimerIfIdle();
|
|
1159
|
-
this.tui.requestRender();
|
|
1160
|
-
});
|
|
1161
|
-
|
|
1162
|
-
this.tui.requestRender();
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
private getCurrentHunkScope(): ExplanationScope | undefined {
|
|
1166
|
-
const selectedLine = this.lines[this.selected];
|
|
1167
|
-
if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
|
|
1168
|
-
|
|
1169
|
-
let start = this.selected;
|
|
1170
|
-
while (
|
|
1171
|
-
start > 0 &&
|
|
1172
|
-
this.lines[start - 1]?.filePath === selectedLine.filePath &&
|
|
1173
|
-
this.lines[start - 1]?.hunkLabel === selectedLine.hunkLabel
|
|
1174
|
-
) {
|
|
1175
|
-
start--;
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
let end = this.selected;
|
|
1179
|
-
while (
|
|
1180
|
-
end + 1 < this.lines.length &&
|
|
1181
|
-
this.lines[end + 1]?.filePath === selectedLine.filePath &&
|
|
1182
|
-
this.lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
|
|
1183
|
-
) {
|
|
1184
|
-
end++;
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
const diffText = this.lines
|
|
1188
|
-
.slice(start, end + 1)
|
|
1189
|
-
.map((line) => line.text)
|
|
1190
|
-
.join("\n");
|
|
1191
|
-
return {
|
|
1192
|
-
key: `hunk:${selectedLine.filePath}:${selectedLine.hunkLabel}:${start}:${end}`,
|
|
1193
|
-
kind: "hunk",
|
|
1194
|
-
title: `${selectedLine.filePath} ${selectedLine.hunkLabel}`,
|
|
1195
|
-
filePath: selectedLine.filePath,
|
|
1196
|
-
diffText,
|
|
1197
|
-
};
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
private getLoadingFrame(): string {
|
|
1201
|
-
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1202
|
-
return frames[this.loadingFrame % frames.length] ?? "⠋";
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
private startLoadingTimer(): void {
|
|
1206
|
-
if (this.loadingTimer) return;
|
|
1207
|
-
this.loadingTimer = setInterval(() => {
|
|
1208
|
-
this.loadingFrame++;
|
|
1209
|
-
this.tui.requestRender();
|
|
1210
|
-
}, 120);
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
private stopLoadingTimerIfIdle(): void {
|
|
1214
|
-
const hasLoading = [...this.explanations.values()].some(
|
|
1215
|
-
(explanation) => explanation.status === "loading",
|
|
1216
|
-
);
|
|
1217
|
-
if (!hasLoading) this.stopLoadingTimer();
|
|
752
|
+
this.explanationController.ensure(this.getCurrentHunkScope());
|
|
1218
753
|
}
|
|
1219
754
|
|
|
1220
|
-
private
|
|
1221
|
-
|
|
1222
|
-
clearInterval(this.loadingTimer);
|
|
1223
|
-
this.loadingTimer = undefined;
|
|
755
|
+
private getCurrentHunkScope() {
|
|
756
|
+
return getCurrentHunkScope(this.lines, this.selected);
|
|
1224
757
|
}
|
|
1225
758
|
}
|