pi-diff-review 0.1.13 → 0.1.15
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 +4 -3
- package/package.json +1 -1
- package/src/prompt.ts +1 -1
- package/src/review-component.ts +603 -173
- package/src/types.ts +0 -2
- package/src/review-panes.ts +0 -286
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
# pi-diff-review
|
|
4
4
|
|
|
5
|
-
Embedded code reviews
|
|
5
|
+
Embedded code reviews and AI summaries directly within [pi](https://pi.dev/).
|
|
6
6
|
|
|
7
|
-
<img width="1986" height="1556" alt="pi-diff-review-screenshot
|
|
7
|
+
<img width="1986" height="1556" alt="pi-diff-review-screenshot" src="https://github.com/user-attachments/assets/5ddd7226-28d4-4617-8c5c-35b4b6af68dc" />
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -65,9 +65,10 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
|
|
|
65
65
|
- `j/k` or arrow keys to move
|
|
66
66
|
- `g/G` to jump to the top or bottom of the diff
|
|
67
67
|
- `ctrl-u` / `ctrl-d` to move up/down by half a page
|
|
68
|
-
- `t` toggles
|
|
68
|
+
- `t` toggles inline comments/explanations
|
|
69
69
|
- `v` toggles the diff between unified and side-by-side split rendering
|
|
70
70
|
- `?` toggles an AI-generated explanation for the current hunk
|
|
71
|
+
- `/` searches diff lines; `n/N` moves between matches while a search is active
|
|
71
72
|
- `J/K` to extend a highlighted selection into a comment range
|
|
72
73
|
- `esc` clears the active selection, or exits review when no selection is active
|
|
73
74
|
- `n/p` to jump hunks
|
package/package.json
CHANGED
package/src/prompt.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function formatLocation(line: {
|
|
|
17
17
|
return file;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function formatCommentLocation(comment: ReviewComment): string {
|
|
20
|
+
export function formatCommentLocation(comment: ReviewComment): string {
|
|
21
21
|
if (comment.global) return "Overall diff";
|
|
22
22
|
|
|
23
23
|
const start = formatLocation({
|
package/src/review-component.ts
CHANGED
|
@@ -2,7 +2,13 @@ import {
|
|
|
2
2
|
getLanguageFromPath,
|
|
3
3
|
highlightCode,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Editor,
|
|
7
|
+
matchesKey,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
wrapTextWithAnsi,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
6
12
|
import type { DiffExplainer } from "./explain.ts";
|
|
7
13
|
import {
|
|
8
14
|
GLOBAL_COMMENT_KEY,
|
|
@@ -15,12 +21,8 @@ import {
|
|
|
15
21
|
ExplanationController,
|
|
16
22
|
getCurrentHunkScope,
|
|
17
23
|
} from "./explanation-controller.ts";
|
|
18
|
-
import { formatLocation } from "./prompt.ts";
|
|
24
|
+
import { formatCommentLocation, formatLocation } from "./prompt.ts";
|
|
19
25
|
import { ReviewNavigationState } from "./review-navigation.ts";
|
|
20
|
-
import {
|
|
21
|
-
renderCommentsPane as renderCommentsPaneContent,
|
|
22
|
-
renderExplanationPane as renderExplanationPaneContent,
|
|
23
|
-
} from "./review-panes.ts";
|
|
24
26
|
import { padToWidth, lineNumberCell } from "./render-utils.ts";
|
|
25
27
|
import { buildSplitDiffRows } from "./split-diff.ts";
|
|
26
28
|
import type {
|
|
@@ -30,28 +32,52 @@ import type {
|
|
|
30
32
|
ReviewResult,
|
|
31
33
|
ReviewTheme,
|
|
32
34
|
ReviewTui,
|
|
33
|
-
RightPaneMode,
|
|
34
35
|
SelectionBounds,
|
|
35
36
|
SplitDiffCell,
|
|
36
37
|
SplitDiffRow,
|
|
37
38
|
} from "./types.ts";
|
|
38
39
|
|
|
40
|
+
type InlineBoxPart = "top" | "body" | "bottom";
|
|
41
|
+
type InlineBoxRowKind = "comment" | "editor" | "explanation";
|
|
42
|
+
type InlineBoxRow = {
|
|
43
|
+
kind: InlineBoxRowKind;
|
|
44
|
+
text: string;
|
|
45
|
+
part: InlineBoxPart;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type AnnotatedDiffRow =
|
|
49
|
+
| { kind: "diff"; lineIndex: number }
|
|
50
|
+
| { kind: "split"; splitRowIndex: number }
|
|
51
|
+
| InlineBoxRow;
|
|
52
|
+
|
|
39
53
|
export class ReviewComponent {
|
|
40
54
|
private navigation: ReviewNavigationState;
|
|
41
55
|
private editMode = false;
|
|
42
56
|
private editingCommentKey?: string;
|
|
57
|
+
private searchMode = false;
|
|
58
|
+
private searchQuery = "";
|
|
59
|
+
private draftSearchQuery = "";
|
|
60
|
+
private searchMessage = "";
|
|
43
61
|
|
|
44
|
-
private
|
|
45
|
-
private
|
|
62
|
+
private inlineAnnotationsVisible = true;
|
|
63
|
+
private visibleExplanationKeys = new Set<string>();
|
|
46
64
|
private explanationController: ExplanationController;
|
|
47
65
|
private editor: Editor;
|
|
48
66
|
private splitRows?: SplitDiffRow[];
|
|
49
|
-
private splitRowByLineIndex?: number[];
|
|
50
67
|
private lineIndexById = new Map<string, number>();
|
|
51
68
|
private commentLineKeys = new Map<number, string[]>();
|
|
52
69
|
private commentsRevision = 0;
|
|
53
70
|
private commentLineKeysRevision = -1;
|
|
54
71
|
private highlightedLineCache = new Map<string, string>();
|
|
72
|
+
private annotatedRows?: AnnotatedDiffRow[];
|
|
73
|
+
private annotatedRowsWidth = 0;
|
|
74
|
+
private annotatedRowsRevision = -1;
|
|
75
|
+
private annotatedRowsEditMode = false;
|
|
76
|
+
private annotatedRowsEditingCommentKey?: string;
|
|
77
|
+
private annotatedRowsMode?: DiffRenderMode;
|
|
78
|
+
private annotatedRowsInlineAnnotationsVisible = true;
|
|
79
|
+
private annotatedRowsVisibleExplanationCount = 0;
|
|
80
|
+
private annotatedRowByLineIndex?: number[];
|
|
55
81
|
|
|
56
82
|
constructor(
|
|
57
83
|
private tui: ReviewTui,
|
|
@@ -120,6 +146,7 @@ export class ReviewComponent {
|
|
|
120
146
|
this.markCommentsChanged();
|
|
121
147
|
}
|
|
122
148
|
|
|
149
|
+
this.navigation.clearSelection();
|
|
123
150
|
this.exitEditMode();
|
|
124
151
|
};
|
|
125
152
|
}
|
|
@@ -151,12 +178,20 @@ export class ReviewComponent {
|
|
|
151
178
|
return;
|
|
152
179
|
}
|
|
153
180
|
this.editor.handleInput(data);
|
|
181
|
+
this.invalidateAnnotatedRows();
|
|
154
182
|
this.tui.requestRender();
|
|
155
183
|
return;
|
|
156
184
|
}
|
|
157
185
|
|
|
186
|
+
if (this.searchMode) {
|
|
187
|
+
this.handleSearchInput(data);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
158
191
|
if (matchesKey(data, "escape")) {
|
|
159
|
-
if (this.
|
|
192
|
+
if (this.searchQuery) {
|
|
193
|
+
this.clearSearch();
|
|
194
|
+
} else if (this.hasSelection()) {
|
|
160
195
|
this.clearSelection();
|
|
161
196
|
} else {
|
|
162
197
|
this.done({ action: "cancel" });
|
|
@@ -168,7 +203,7 @@ export class ReviewComponent {
|
|
|
168
203
|
return;
|
|
169
204
|
}
|
|
170
205
|
if (data === "t") {
|
|
171
|
-
this.
|
|
206
|
+
this.toggleInlineAnnotations();
|
|
172
207
|
return;
|
|
173
208
|
}
|
|
174
209
|
if (data === "v") {
|
|
@@ -179,6 +214,10 @@ export class ReviewComponent {
|
|
|
179
214
|
this.toggleExplanationPane();
|
|
180
215
|
return;
|
|
181
216
|
}
|
|
217
|
+
if (data === "/") {
|
|
218
|
+
this.startSearchMode();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
182
221
|
if (matchesKey(data, "ctrl+d")) {
|
|
183
222
|
this.move(this.getPageMoveAmount());
|
|
184
223
|
return;
|
|
@@ -212,7 +251,15 @@ export class ReviewComponent {
|
|
|
212
251
|
return;
|
|
213
252
|
}
|
|
214
253
|
if (data === "n") {
|
|
215
|
-
this.
|
|
254
|
+
if (this.searchQuery) {
|
|
255
|
+
this.jumpSearch(1);
|
|
256
|
+
} else {
|
|
257
|
+
this.jumpHunk(1);
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (data === "N" && this.searchQuery) {
|
|
262
|
+
this.jumpSearch(-1);
|
|
216
263
|
return;
|
|
217
264
|
}
|
|
218
265
|
if (data === "p") {
|
|
@@ -250,23 +297,17 @@ export class ReviewComponent {
|
|
|
250
297
|
this.theme.fg(
|
|
251
298
|
"dim",
|
|
252
299
|
this.editMode
|
|
253
|
-
? `${this.lines.length} lines • ${this.comments.size} comments • editing comment • Enter save • Esc/Ctrl+C cancel`
|
|
300
|
+
? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • editing inline comment • Enter save • Esc/Ctrl+C cancel`
|
|
254
301
|
: this.hasSelection()
|
|
255
|
-
? `${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • C overall comment • Enter submit`
|
|
256
|
-
: `${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • ${this.
|
|
302
|
+
? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • J/K extend • Esc clear selection • c comment range • C overall comment • Enter submit`
|
|
303
|
+
: `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • ${this.getPositionText(selectedLine)} • inline ${this.inlineAnnotationsVisible ? "shown" : "hidden"} • j/k move • g/G top/bottom • ctrl-u/d page • / search • t annotations • v unified/split • ? explain • J/K extend • c comment • C overall • x delete • ${this.searchQuery ? "n/N search" : "n/p hunk"} • Enter submit • q quit`,
|
|
257
304
|
),
|
|
258
305
|
width,
|
|
259
306
|
),
|
|
260
307
|
);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
} else {
|
|
265
|
-
this.ensureScroll(viewportHeight);
|
|
266
|
-
output.push(
|
|
267
|
-
...this.renderSideBySide(width, viewportHeight, selectedLine),
|
|
268
|
-
);
|
|
269
|
-
}
|
|
308
|
+
|
|
309
|
+
this.ensureScroll(viewportHeight, width);
|
|
310
|
+
output.push(...this.renderAnnotatedDiffRows(width, viewportHeight));
|
|
270
311
|
|
|
271
312
|
output.push(
|
|
272
313
|
truncateToWidth(
|
|
@@ -277,105 +318,415 @@ export class ReviewComponent {
|
|
|
277
318
|
return output;
|
|
278
319
|
}
|
|
279
320
|
|
|
280
|
-
private
|
|
281
|
-
width
|
|
282
|
-
height: number,
|
|
283
|
-
selectedLine?: ReviewLine,
|
|
284
|
-
): string[] {
|
|
285
|
-
const rightWidth = Math.max(28, Math.floor(width * 0.34));
|
|
286
|
-
const separatorWidth = 3;
|
|
287
|
-
const leftWidth = Math.max(30, width - rightWidth - separatorWidth);
|
|
288
|
-
const rightPane = this.renderRightPane(rightWidth, height, selectedLine);
|
|
321
|
+
private renderAnnotatedDiffRows(width: number, height: number): string[] {
|
|
322
|
+
const rows = this.getAnnotatedRows(width);
|
|
289
323
|
const output: string[] = [];
|
|
290
|
-
const diffPane = this.renderDiffRows(leftWidth, height);
|
|
291
324
|
|
|
292
325
|
for (let row = 0; row < height; row++) {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
326
|
+
const annotated = rows[this.scrollTop + row];
|
|
327
|
+
if (!annotated) {
|
|
328
|
+
output.push(" ".repeat(width));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (annotated.kind === "diff") {
|
|
333
|
+
const index = annotated.lineIndex;
|
|
334
|
+
const line = this.lines[index]!;
|
|
335
|
+
output.push(
|
|
336
|
+
this.renderDiffLine(
|
|
337
|
+
line,
|
|
338
|
+
index,
|
|
339
|
+
width,
|
|
340
|
+
index === this.selected,
|
|
341
|
+
this.getSelectionBounds(),
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (annotated.kind === "split") {
|
|
348
|
+
output.push(this.renderSplitDiffRowAt(annotated.splitRowIndex, width));
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
output.push(this.renderInlineAnnotationRow(annotated, width));
|
|
297
353
|
}
|
|
298
354
|
|
|
299
355
|
return output;
|
|
300
356
|
}
|
|
301
357
|
|
|
302
|
-
private
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
358
|
+
private getAnnotatedRows(width: number): AnnotatedDiffRow[] {
|
|
359
|
+
if (
|
|
360
|
+
this.annotatedRows &&
|
|
361
|
+
this.annotatedRowsWidth === width &&
|
|
362
|
+
this.annotatedRowsRevision === this.commentsRevision &&
|
|
363
|
+
this.annotatedRowsEditMode === this.editMode &&
|
|
364
|
+
this.annotatedRowsEditingCommentKey === this.editingCommentKey &&
|
|
365
|
+
this.annotatedRowsMode === this.diffRenderMode &&
|
|
366
|
+
this.annotatedRowsInlineAnnotationsVisible ===
|
|
367
|
+
this.inlineAnnotationsVisible &&
|
|
368
|
+
this.annotatedRowsVisibleExplanationCount ===
|
|
369
|
+
this.visibleExplanationKeys.size &&
|
|
370
|
+
this.visibleExplanationKeys.size === 0
|
|
371
|
+
) {
|
|
372
|
+
return this.annotatedRows;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const rows: AnnotatedDiffRow[] = [];
|
|
376
|
+
const rowByLineIndex: number[] = [];
|
|
377
|
+
|
|
378
|
+
this.pushGlobalAnnotationRows(rows, width);
|
|
379
|
+
|
|
380
|
+
if (this.diffRenderMode === "split") {
|
|
381
|
+
this.pushAnnotatedSplitRows(rows, rowByLineIndex, width);
|
|
382
|
+
} else {
|
|
383
|
+
this.pushAnnotatedUnifiedRows(rows, rowByLineIndex, width);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.annotatedRows = rows;
|
|
387
|
+
this.annotatedRowsWidth = width;
|
|
388
|
+
this.annotatedRowsRevision = this.commentsRevision;
|
|
389
|
+
this.annotatedRowsEditMode = this.editMode;
|
|
390
|
+
this.annotatedRowsEditingCommentKey = this.editingCommentKey;
|
|
391
|
+
this.annotatedRowsMode = this.diffRenderMode;
|
|
392
|
+
this.annotatedRowsInlineAnnotationsVisible = this.inlineAnnotationsVisible;
|
|
393
|
+
this.annotatedRowsVisibleExplanationCount =
|
|
394
|
+
this.visibleExplanationKeys.size;
|
|
395
|
+
this.annotatedRowByLineIndex = rowByLineIndex;
|
|
396
|
+
return rows;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private pushAnnotatedUnifiedRows(
|
|
400
|
+
rows: AnnotatedDiffRow[],
|
|
401
|
+
rowByLineIndex: number[],
|
|
402
|
+
width: number,
|
|
403
|
+
): void {
|
|
404
|
+
for (let index = 0; index < this.lines.length; index++) {
|
|
405
|
+
rowByLineIndex[index] = rows.length;
|
|
406
|
+
rows.push({ kind: "diff", lineIndex: index });
|
|
407
|
+
|
|
408
|
+
if (!this.inlineAnnotationsVisible) continue;
|
|
409
|
+
|
|
410
|
+
this.pushInlineCommentRows(rows, index, width);
|
|
411
|
+
this.pushInlineEditorRows(rows, index, width);
|
|
412
|
+
this.pushInlineExplanationRows(rows, index, width);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private pushAnnotatedSplitRows(
|
|
417
|
+
rows: AnnotatedDiffRow[],
|
|
418
|
+
rowByLineIndex: number[],
|
|
419
|
+
width: number,
|
|
420
|
+
): void {
|
|
421
|
+
const splitRows = this.getSplitDiffRows();
|
|
422
|
+
for (
|
|
423
|
+
let splitRowIndex = 0;
|
|
424
|
+
splitRowIndex < splitRows.length;
|
|
425
|
+
splitRowIndex++
|
|
426
|
+
) {
|
|
427
|
+
const splitRow = splitRows[splitRowIndex]!;
|
|
428
|
+
const lineIndexes = this.getLineIndexesForSplitRow(splitRow);
|
|
429
|
+
for (const lineIndex of lineIndexes) {
|
|
430
|
+
rowByLineIndex[lineIndex] = rows.length;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
rows.push({ kind: "split", splitRowIndex });
|
|
434
|
+
if (!this.inlineAnnotationsVisible) continue;
|
|
435
|
+
|
|
436
|
+
for (const lineIndex of lineIndexes) {
|
|
437
|
+
this.pushInlineCommentRows(rows, lineIndex, width);
|
|
438
|
+
this.pushInlineEditorRows(rows, lineIndex, width);
|
|
439
|
+
this.pushInlineExplanationRows(rows, lineIndex, width);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
306
442
|
}
|
|
307
443
|
|
|
308
|
-
private
|
|
309
|
-
|
|
310
|
-
|
|
444
|
+
private getLineIndexesForSplitRow(row: SplitDiffRow): number[] {
|
|
445
|
+
if (row.kind === "full") return [row.cell.index];
|
|
446
|
+
const indexes: number[] = [];
|
|
447
|
+
if (row.left) indexes.push(row.left.index);
|
|
448
|
+
if (row.right && row.right.index !== row.left?.index) {
|
|
449
|
+
indexes.push(row.right.index);
|
|
450
|
+
}
|
|
451
|
+
return indexes;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private pushGlobalAnnotationRows(
|
|
455
|
+
rows: AnnotatedDiffRow[],
|
|
456
|
+
width: number,
|
|
457
|
+
): void {
|
|
458
|
+
if (!this.inlineAnnotationsVisible) return;
|
|
459
|
+
|
|
460
|
+
const globalComment = this.comments.get(GLOBAL_COMMENT_KEY);
|
|
461
|
+
if (globalComment) {
|
|
462
|
+
this.pushAnnotationBlock(
|
|
463
|
+
rows,
|
|
464
|
+
"comment",
|
|
465
|
+
globalComment.text,
|
|
466
|
+
width,
|
|
467
|
+
"Overall diff comment",
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (this.editMode && this.editingCommentKey === GLOBAL_COMMENT_KEY) {
|
|
472
|
+
this.pushInlineEditorBlock(rows, "Draft overall diff note", width);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private pushInlineCommentRows(
|
|
477
|
+
rows: AnnotatedDiffRow[],
|
|
478
|
+
lineIndex: number,
|
|
479
|
+
width: number,
|
|
480
|
+
): void {
|
|
481
|
+
for (const comment of this.getCommentsEndingAtLine(lineIndex)) {
|
|
482
|
+
if (this.editMode && comment.id === this.editingCommentKey) continue;
|
|
483
|
+
this.pushAnnotationBlock(
|
|
484
|
+
rows,
|
|
485
|
+
"comment",
|
|
486
|
+
comment.text,
|
|
487
|
+
width,
|
|
488
|
+
formatCommentLocation(comment),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private pushInlineEditorRows(
|
|
494
|
+
rows: AnnotatedDiffRow[],
|
|
495
|
+
lineIndex: number,
|
|
496
|
+
width: number,
|
|
497
|
+
): void {
|
|
498
|
+
if (!this.editMode || this.editingCommentKey === GLOBAL_COMMENT_KEY) return;
|
|
499
|
+
const selection = this.getActiveCommentSelection();
|
|
500
|
+
if (!selection || selection.end !== lineIndex) return;
|
|
501
|
+
this.pushInlineEditorBlock(
|
|
502
|
+
rows,
|
|
503
|
+
`Draft note - ${this.formatSelectionLocation(selection)}`,
|
|
504
|
+
width,
|
|
311
505
|
);
|
|
312
506
|
}
|
|
313
507
|
|
|
314
|
-
private
|
|
315
|
-
|
|
316
|
-
|
|
508
|
+
private pushInlineExplanationRows(
|
|
509
|
+
rows: AnnotatedDiffRow[],
|
|
510
|
+
lineIndex: number,
|
|
511
|
+
width: number,
|
|
512
|
+
): void {
|
|
513
|
+
const scope = getCurrentHunkScope(this.lines, lineIndex);
|
|
514
|
+
if (!scope || !this.visibleExplanationKeys.has(scope.key)) return;
|
|
515
|
+
|
|
516
|
+
const end = this.getHunkEndIndex(lineIndex);
|
|
517
|
+
if (end !== lineIndex) return;
|
|
518
|
+
|
|
519
|
+
const explanation = this.explanationController.getState(scope);
|
|
520
|
+
if (!this.explanationController.isAvailable) {
|
|
521
|
+
this.pushExplanationBlock(rows, "Explanation unavailable.", width);
|
|
522
|
+
} else if (!explanation) {
|
|
523
|
+
this.pushExplanationBlock(rows, "No explanation generated yet.", width);
|
|
524
|
+
} else if (explanation.status === "loading") {
|
|
525
|
+
this.pushExplanationBlock(
|
|
526
|
+
rows,
|
|
527
|
+
`${this.explanationController.getLoadingFrame()} ${explanation.text || "Generating explanation..."}`,
|
|
528
|
+
width,
|
|
529
|
+
);
|
|
530
|
+
} else if (explanation.status === "error") {
|
|
531
|
+
this.pushExplanationBlock(
|
|
532
|
+
rows,
|
|
533
|
+
`Explanation failed: ${explanation.message}`,
|
|
534
|
+
width,
|
|
535
|
+
);
|
|
536
|
+
} else {
|
|
537
|
+
this.pushExplanationBlock(rows, explanation.text, width);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
317
540
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
541
|
+
private pushAnnotationBlock(
|
|
542
|
+
rows: AnnotatedDiffRow[],
|
|
543
|
+
kind: "comment",
|
|
544
|
+
text: string,
|
|
545
|
+
width: number,
|
|
546
|
+
title?: string,
|
|
547
|
+
): void {
|
|
548
|
+
this.pushCommentBlock(rows, text, width, title);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private pushCommentBlock(
|
|
552
|
+
rows: AnnotatedDiffRow[],
|
|
553
|
+
text: string,
|
|
554
|
+
width: number,
|
|
555
|
+
title?: string,
|
|
556
|
+
): void {
|
|
557
|
+
rows.push({
|
|
558
|
+
kind: "comment",
|
|
559
|
+
text: title ? this.theme.fg("accent", ` ${title} `) : "",
|
|
560
|
+
part: "top",
|
|
561
|
+
});
|
|
562
|
+
const wrapped = wrapTextWithAnsi(
|
|
563
|
+
this.theme.fg("text", text),
|
|
564
|
+
this.getInlineContentWidth(width),
|
|
565
|
+
);
|
|
566
|
+
for (const line of wrapped.length > 0 ? wrapped : [""]) {
|
|
567
|
+
rows.push({ kind: "comment", text: line, part: "body" });
|
|
568
|
+
}
|
|
569
|
+
rows.push({ kind: "comment", text: "", part: "bottom" });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private pushExplanationBlock(
|
|
573
|
+
rows: AnnotatedDiffRow[],
|
|
574
|
+
text: string,
|
|
575
|
+
width: number,
|
|
576
|
+
): void {
|
|
577
|
+
rows.push({
|
|
578
|
+
kind: "explanation",
|
|
579
|
+
text: this.theme.fg("accent", " ✨ Explanation "),
|
|
580
|
+
part: "top",
|
|
581
|
+
});
|
|
582
|
+
const wrapped = wrapTextWithAnsi(
|
|
583
|
+
this.theme.fg("muted", text),
|
|
584
|
+
this.getInlineContentWidth(width),
|
|
585
|
+
);
|
|
586
|
+
for (const line of wrapped.length > 0 ? wrapped : [""]) {
|
|
587
|
+
rows.push({ kind: "explanation", text: line, part: "body" });
|
|
588
|
+
}
|
|
589
|
+
rows.push({ kind: "explanation", text: "", part: "bottom" });
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private pushInlineEditorBlock(
|
|
593
|
+
rows: AnnotatedDiffRow[],
|
|
594
|
+
title: string,
|
|
595
|
+
width: number,
|
|
596
|
+
): void {
|
|
597
|
+
rows.push({
|
|
598
|
+
kind: "editor",
|
|
599
|
+
text: this.theme.fg("accent", ` ${title} `),
|
|
600
|
+
part: "top",
|
|
601
|
+
});
|
|
602
|
+
const editorLines = this.editor.render(this.getInlineContentWidth(width));
|
|
603
|
+
const bodyLines = editorLines.slice(1, -1);
|
|
604
|
+
for (const line of bodyLines.length > 0 ? bodyLines : [""]) {
|
|
605
|
+
rows.push({ kind: "editor", text: line, part: "body" });
|
|
606
|
+
}
|
|
607
|
+
rows.push({ kind: "editor", text: "", part: "bottom" });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private renderInlineAnnotationRow(
|
|
611
|
+
row: Exclude<AnnotatedDiffRow, { kind: "diff" } | { kind: "split" }>,
|
|
612
|
+
width: number,
|
|
613
|
+
): string {
|
|
614
|
+
if (row.kind === "editor") {
|
|
615
|
+
return this.renderInlineBoxRow(
|
|
616
|
+
{ text: row.text, part: row.part },
|
|
617
|
+
width,
|
|
618
|
+
"accent",
|
|
331
619
|
);
|
|
332
620
|
}
|
|
333
621
|
|
|
334
|
-
|
|
622
|
+
if (row.kind === "comment" || row.kind === "explanation") {
|
|
623
|
+
return this.renderInlineBoxRow(
|
|
624
|
+
{ text: row.text, part: row.part },
|
|
625
|
+
width,
|
|
626
|
+
"borderMuted",
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return " ".repeat(width);
|
|
335
631
|
}
|
|
336
632
|
|
|
337
|
-
private
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
633
|
+
private renderInlineBoxRow(
|
|
634
|
+
row: { text: string; part: "top" | "body" | "bottom" },
|
|
635
|
+
width: number,
|
|
636
|
+
borderColor: "accent" | "borderMuted",
|
|
637
|
+
): string {
|
|
638
|
+
const indent = " ";
|
|
639
|
+
const contentWidth = this.getInlineContentWidth(width);
|
|
640
|
+
const body =
|
|
641
|
+
row.part === "top" || row.part === "bottom"
|
|
642
|
+
? this.renderInlineBoxHorizontal(row.text, contentWidth, borderColor)
|
|
643
|
+
: padToWidth(truncateToWidth(row.text, contentWidth), contentWidth);
|
|
644
|
+
const left = row.part === "top" ? "╭" : row.part === "bottom" ? "╰" : "│";
|
|
645
|
+
const right = row.part === "top" ? "╮" : row.part === "bottom" ? "╯" : "│";
|
|
646
|
+
return padToWidth(
|
|
647
|
+
truncateToWidth(
|
|
648
|
+
`${indent}${this.theme.fg(borderColor, left)}${body}${this.theme.fg(borderColor, right)}`,
|
|
649
|
+
width,
|
|
650
|
+
),
|
|
651
|
+
width,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
343
654
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
655
|
+
private renderInlineBoxHorizontal(
|
|
656
|
+
title: string,
|
|
657
|
+
width: number,
|
|
658
|
+
borderColor: "accent" | "borderMuted",
|
|
659
|
+
): string {
|
|
660
|
+
if (!title) return this.theme.fg(borderColor, "─".repeat(width));
|
|
350
661
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
splitRow.cell.index,
|
|
356
|
-
width,
|
|
357
|
-
splitRow.cell.index === this.selected,
|
|
358
|
-
this.getSelectionBounds(),
|
|
359
|
-
),
|
|
360
|
-
);
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
662
|
+
const visibleTitle = truncateToWidth(title, Math.max(0, width));
|
|
663
|
+
const remaining = Math.max(0, width - visibleWidth(visibleTitle));
|
|
664
|
+
return `${visibleTitle}${this.theme.fg(borderColor, "─".repeat(remaining))}`;
|
|
665
|
+
}
|
|
363
666
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
667
|
+
private getInlineContentWidth(width: number): number {
|
|
668
|
+
return Math.max(10, width - 8);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private invalidateAnnotatedRows(): void {
|
|
672
|
+
this.annotatedRows = undefined;
|
|
673
|
+
this.annotatedRowByLineIndex = undefined;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private getCommentsEndingAtLine(lineIndex: number): ReviewComment[] {
|
|
677
|
+
const comments: ReviewComment[] = [];
|
|
678
|
+
for (const comment of this.comments.values()) {
|
|
679
|
+
if (comment.global) continue;
|
|
680
|
+
const start = this.lineIndexById.get(comment.startLineId);
|
|
681
|
+
const end = this.lineIndexById.get(comment.endLineId);
|
|
682
|
+
if (start == null || end == null) continue;
|
|
683
|
+
if (Math.max(start, end) === lineIndex) comments.push(comment);
|
|
684
|
+
}
|
|
685
|
+
return comments.sort((a, b) => a.id.localeCompare(b.id));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private getHunkEndIndex(selected: number): number | undefined {
|
|
689
|
+
const selectedLine = this.lines[selected];
|
|
690
|
+
if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
|
|
691
|
+
|
|
692
|
+
let end = selected;
|
|
693
|
+
while (
|
|
694
|
+
end + 1 < this.lines.length &&
|
|
695
|
+
this.lines[end + 1]?.filePath === selectedLine.filePath &&
|
|
696
|
+
this.lines[end + 1]?.hunkLabel === selectedLine.hunkLabel
|
|
697
|
+
) {
|
|
698
|
+
end++;
|
|
699
|
+
}
|
|
700
|
+
return end;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private renderSplitDiffRowAt(splitRowIndex: number, width: number): string {
|
|
704
|
+
const splitRow = this.getSplitDiffRows()[splitRowIndex];
|
|
705
|
+
if (!splitRow) return " ".repeat(width);
|
|
706
|
+
|
|
707
|
+
if (splitRow.kind === "full") {
|
|
708
|
+
return this.renderDiffLine(
|
|
709
|
+
splitRow.cell.line,
|
|
710
|
+
splitRow.cell.index,
|
|
711
|
+
width,
|
|
712
|
+
splitRow.cell.index === this.selected,
|
|
713
|
+
this.getSelectionBounds(),
|
|
375
714
|
);
|
|
376
715
|
}
|
|
377
716
|
|
|
378
|
-
|
|
717
|
+
const separatorWidth = 3;
|
|
718
|
+
const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
|
|
719
|
+
const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
|
|
720
|
+
const left = splitRow.left
|
|
721
|
+
? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
|
|
722
|
+
: " ".repeat(leftWidth);
|
|
723
|
+
const right = splitRow.right
|
|
724
|
+
? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
|
|
725
|
+
: " ".repeat(rightWidth);
|
|
726
|
+
return truncateToWidth(
|
|
727
|
+
`${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
|
|
728
|
+
width,
|
|
729
|
+
);
|
|
379
730
|
}
|
|
380
731
|
|
|
381
732
|
private getContentHeight(): number {
|
|
@@ -387,6 +738,7 @@ export class ReviewComponent {
|
|
|
387
738
|
|
|
388
739
|
invalidate(): void {
|
|
389
740
|
this.highlightedLineCache.clear();
|
|
741
|
+
this.invalidateAnnotatedRows();
|
|
390
742
|
}
|
|
391
743
|
|
|
392
744
|
dispose(): void {
|
|
@@ -409,23 +761,28 @@ export class ReviewComponent {
|
|
|
409
761
|
this.tui.requestRender(true);
|
|
410
762
|
}
|
|
411
763
|
|
|
412
|
-
private
|
|
413
|
-
this.
|
|
764
|
+
private toggleInlineAnnotations(): void {
|
|
765
|
+
this.inlineAnnotationsVisible = !this.inlineAnnotationsVisible;
|
|
766
|
+
this.invalidateAnnotatedRows();
|
|
414
767
|
this.tui.requestRender(true);
|
|
415
768
|
}
|
|
416
769
|
|
|
417
|
-
private
|
|
418
|
-
this.
|
|
419
|
-
this.rightPaneMode = "comments";
|
|
770
|
+
private hideInlineExplanation(): void {
|
|
771
|
+
this.visibleExplanationKeys.clear();
|
|
420
772
|
}
|
|
421
773
|
|
|
422
774
|
private toggleExplanationPane(): void {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (this.
|
|
775
|
+
const scope = this.getCurrentHunkScope();
|
|
776
|
+
if (!scope) return;
|
|
777
|
+
|
|
778
|
+
if (this.visibleExplanationKeys.has(scope.key)) {
|
|
779
|
+
this.visibleExplanationKeys.delete(scope.key);
|
|
780
|
+
} else {
|
|
781
|
+
this.visibleExplanationKeys.add(scope.key);
|
|
427
782
|
this.ensureCurrentExplanation();
|
|
428
783
|
}
|
|
784
|
+
|
|
785
|
+
this.invalidateAnnotatedRows();
|
|
429
786
|
this.tui.requestRender(true);
|
|
430
787
|
}
|
|
431
788
|
|
|
@@ -464,6 +821,16 @@ export class ReviewComponent {
|
|
|
464
821
|
return getSelectionKey(this.lines, start, end);
|
|
465
822
|
}
|
|
466
823
|
|
|
824
|
+
private formatSelectionLocation(selection: SelectionBounds): string {
|
|
825
|
+
const startLine = this.lines[selection.start];
|
|
826
|
+
const endLine = this.lines[selection.end];
|
|
827
|
+
if (!startLine || !endLine) return "diff";
|
|
828
|
+
|
|
829
|
+
const start = formatLocation(startLine);
|
|
830
|
+
const end = formatLocation(endLine);
|
|
831
|
+
return start === end ? start : `${start} -> ${end}`;
|
|
832
|
+
}
|
|
833
|
+
|
|
467
834
|
private getCommentForSelection(
|
|
468
835
|
selection: SelectionBounds | undefined,
|
|
469
836
|
): ReviewComment | undefined {
|
|
@@ -480,6 +847,7 @@ export class ReviewComponent {
|
|
|
480
847
|
|
|
481
848
|
private markCommentsChanged(): void {
|
|
482
849
|
this.commentsRevision++;
|
|
850
|
+
this.invalidateAnnotatedRows();
|
|
483
851
|
this.onCommentsChanged?.(this.comments);
|
|
484
852
|
}
|
|
485
853
|
|
|
@@ -501,6 +869,13 @@ export class ReviewComponent {
|
|
|
501
869
|
}
|
|
502
870
|
|
|
503
871
|
private getFooterText(selectedLine?: ReviewLine): string {
|
|
872
|
+
if (this.searchMode) {
|
|
873
|
+
return `Search: /${this.draftSearchQuery} • Enter jump • Esc cancel`;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const searchStatus = this.getSearchStatusText();
|
|
877
|
+
if (searchStatus) return searchStatus;
|
|
878
|
+
|
|
504
879
|
const selection = this.getSelectionBounds();
|
|
505
880
|
if (selection) {
|
|
506
881
|
const count = selection.end - selection.start + 1;
|
|
@@ -511,6 +886,21 @@ export class ReviewComponent {
|
|
|
511
886
|
return `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
|
|
512
887
|
}
|
|
513
888
|
|
|
889
|
+
private getSearchStatusText(): string {
|
|
890
|
+
if (this.searchMessage) return this.searchMessage;
|
|
891
|
+
if (!this.searchQuery) return "";
|
|
892
|
+
|
|
893
|
+
const matches = this.getSearchMatchIndexes(this.searchQuery);
|
|
894
|
+
if (matches.length === 0) return `No matches for /${this.searchQuery}`;
|
|
895
|
+
|
|
896
|
+
const current = matches.findIndex((index) => index === this.selected);
|
|
897
|
+
const position =
|
|
898
|
+
current >= 0
|
|
899
|
+
? `${current + 1}/${matches.length}`
|
|
900
|
+
: `${matches.length} matches`;
|
|
901
|
+
return `Search /${this.searchQuery} • ${position} • n next • N previous • Esc clear search`;
|
|
902
|
+
}
|
|
903
|
+
|
|
514
904
|
private jumpHunk(direction: 1 | -1): void {
|
|
515
905
|
let index = this.selected + direction;
|
|
516
906
|
while (index >= 0 && index < this.lines.length) {
|
|
@@ -536,10 +926,12 @@ export class ReviewComponent {
|
|
|
536
926
|
|
|
537
927
|
private startGlobalEditMode(): void {
|
|
538
928
|
const existing = this.comments.get(GLOBAL_COMMENT_KEY);
|
|
539
|
-
this.
|
|
929
|
+
this.inlineAnnotationsVisible = true;
|
|
930
|
+
this.hideInlineExplanation();
|
|
540
931
|
this.editMode = true;
|
|
541
932
|
this.editingCommentKey = GLOBAL_COMMENT_KEY;
|
|
542
933
|
this.editor.setText(existing?.text ?? "");
|
|
934
|
+
this.invalidateAnnotatedRows();
|
|
543
935
|
this.tui.requestRender(true);
|
|
544
936
|
}
|
|
545
937
|
|
|
@@ -552,13 +944,15 @@ export class ReviewComponent {
|
|
|
552
944
|
if (startLine.filePath !== endLine.filePath) return;
|
|
553
945
|
|
|
554
946
|
const existing = this.getCommentForSelection(selection);
|
|
555
|
-
this.
|
|
947
|
+
this.inlineAnnotationsVisible = true;
|
|
948
|
+
this.hideInlineExplanation();
|
|
556
949
|
this.editMode = true;
|
|
557
950
|
this.editingCommentKey = this.getSelectionKey(
|
|
558
951
|
selection.start,
|
|
559
952
|
selection.end,
|
|
560
953
|
);
|
|
561
954
|
this.editor.setText(existing?.text ?? "");
|
|
955
|
+
this.invalidateAnnotatedRows();
|
|
562
956
|
this.tui.requestRender(true);
|
|
563
957
|
}
|
|
564
958
|
|
|
@@ -566,28 +960,112 @@ export class ReviewComponent {
|
|
|
566
960
|
this.editMode = false;
|
|
567
961
|
this.editingCommentKey = undefined;
|
|
568
962
|
this.editor.setText("");
|
|
963
|
+
this.invalidateAnnotatedRows();
|
|
569
964
|
this.tui.requestRender(true);
|
|
570
965
|
}
|
|
571
966
|
|
|
967
|
+
private startSearchMode(): void {
|
|
968
|
+
this.searchMode = true;
|
|
969
|
+
this.draftSearchQuery = this.searchQuery;
|
|
970
|
+
this.searchMessage = "";
|
|
971
|
+
this.tui.requestRender();
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private handleSearchInput(data: string): void {
|
|
975
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
976
|
+
this.searchMode = false;
|
|
977
|
+
this.draftSearchQuery = "";
|
|
978
|
+
this.tui.requestRender();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (matchesKey(data, "enter")) {
|
|
983
|
+
const query = this.draftSearchQuery.trim();
|
|
984
|
+
this.searchMode = false;
|
|
985
|
+
this.draftSearchQuery = "";
|
|
986
|
+
this.searchQuery = query;
|
|
987
|
+
this.searchMessage = "";
|
|
988
|
+
if (query) {
|
|
989
|
+
this.jumpSearch(1);
|
|
990
|
+
} else {
|
|
991
|
+
this.tui.requestRender();
|
|
992
|
+
}
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
997
|
+
this.draftSearchQuery = "";
|
|
998
|
+
this.tui.requestRender();
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (matchesKey(data, "backspace") || data === "\u007f" || data === "\b") {
|
|
1003
|
+
this.draftSearchQuery = this.draftSearchQuery.slice(0, -1);
|
|
1004
|
+
this.tui.requestRender();
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (data.length === 1 && data >= " " && data !== "\u007f") {
|
|
1009
|
+
this.draftSearchQuery += data;
|
|
1010
|
+
this.tui.requestRender();
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
private clearSearch(): void {
|
|
1015
|
+
this.searchMode = false;
|
|
1016
|
+
this.searchQuery = "";
|
|
1017
|
+
this.draftSearchQuery = "";
|
|
1018
|
+
this.searchMessage = "";
|
|
1019
|
+
this.tui.requestRender();
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
private jumpSearch(direction: 1 | -1): void {
|
|
1023
|
+
const query = this.searchQuery.trim();
|
|
1024
|
+
if (!query) return;
|
|
1025
|
+
|
|
1026
|
+
const matches = this.getSearchMatchIndexes(query);
|
|
1027
|
+
if (matches.length === 0) {
|
|
1028
|
+
this.searchMessage = `No matches for /${query}`;
|
|
1029
|
+
this.tui.requestRender();
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
this.searchMessage = "";
|
|
1034
|
+
const current =
|
|
1035
|
+
direction === 1
|
|
1036
|
+
? matches.find((index) => index > this.selected)
|
|
1037
|
+
: [...matches].reverse().find((index) => index < this.selected);
|
|
1038
|
+
this.selected =
|
|
1039
|
+
current ?? (direction === 1 ? matches[0]! : matches[matches.length - 1]!);
|
|
1040
|
+
this.tui.requestRender();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
private getSearchMatchIndexes(query: string): number[] {
|
|
1044
|
+
const needle = query.toLocaleLowerCase();
|
|
1045
|
+
if (!needle) return [];
|
|
1046
|
+
|
|
1047
|
+
const matches: number[] = [];
|
|
1048
|
+
this.lines.forEach((line, index) => {
|
|
1049
|
+
if (line.text.toLocaleLowerCase().includes(needle)) matches.push(index);
|
|
1050
|
+
});
|
|
1051
|
+
return matches;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
572
1054
|
private getSplitDiffRows(): SplitDiffRow[] {
|
|
573
1055
|
if (this.splitRows) return this.splitRows;
|
|
574
1056
|
|
|
575
|
-
const { rows
|
|
1057
|
+
const { rows } = buildSplitDiffRows(this.lines);
|
|
576
1058
|
this.splitRows = rows;
|
|
577
|
-
this.splitRowByLineIndex = rowByLineIndex;
|
|
578
1059
|
return rows;
|
|
579
1060
|
}
|
|
580
1061
|
|
|
581
|
-
private getSelectedDisplayRow(): number {
|
|
582
|
-
|
|
583
|
-
this.
|
|
584
|
-
return this.splitRowByLineIndex?.[this.selected] ?? 0;
|
|
1062
|
+
private getSelectedDisplayRow(width: number): number {
|
|
1063
|
+
this.getAnnotatedRows(width);
|
|
1064
|
+
return this.annotatedRowByLineIndex?.[this.selected] ?? 0;
|
|
585
1065
|
}
|
|
586
1066
|
|
|
587
|
-
private getDisplayRowCount(): number {
|
|
588
|
-
return this.
|
|
589
|
-
? this.lines.length
|
|
590
|
-
: this.getSplitDiffRows().length;
|
|
1067
|
+
private getDisplayRowCount(width: number): number {
|
|
1068
|
+
return this.getAnnotatedRows(width).length;
|
|
591
1069
|
}
|
|
592
1070
|
|
|
593
1071
|
private renderSplitDiffCell(
|
|
@@ -597,7 +1075,7 @@ export class ReviewComponent {
|
|
|
597
1075
|
): string {
|
|
598
1076
|
const { line, index } = cell;
|
|
599
1077
|
const hasComment = this.getCommentKeysForLine(index).length > 0;
|
|
600
|
-
const commentMark = hasComment ? this.theme.fg("
|
|
1078
|
+
const commentMark = hasComment ? this.theme.fg("borderAccent", "│") : " ";
|
|
601
1079
|
const lineNumber =
|
|
602
1080
|
side === "left" ? line.oldLineNumber : line.newLineNumber;
|
|
603
1081
|
const prefix = `${commentMark} ${lineNumberCell(lineNumber)} `;
|
|
@@ -613,11 +1091,11 @@ export class ReviewComponent {
|
|
|
613
1091
|
return this.applyDiffBackground(line, styled, width);
|
|
614
1092
|
}
|
|
615
1093
|
|
|
616
|
-
private ensureScroll(viewportHeight: number): void {
|
|
1094
|
+
private ensureScroll(viewportHeight: number, width: number): void {
|
|
617
1095
|
this.navigation.ensureScroll(
|
|
618
1096
|
viewportHeight,
|
|
619
|
-
this.getSelectedDisplayRow(),
|
|
620
|
-
this.getDisplayRowCount(),
|
|
1097
|
+
this.getSelectedDisplayRow(width),
|
|
1098
|
+
this.getDisplayRowCount(width),
|
|
621
1099
|
);
|
|
622
1100
|
}
|
|
623
1101
|
|
|
@@ -686,7 +1164,7 @@ export class ReviewComponent {
|
|
|
686
1164
|
selection?: SelectionBounds,
|
|
687
1165
|
): string {
|
|
688
1166
|
const hasComment = this.getCommentKeysForLine(index).length > 0;
|
|
689
|
-
const commentMark = hasComment ? this.theme.fg("
|
|
1167
|
+
const commentMark = hasComment ? this.theme.fg("borderAccent", "│") : " ";
|
|
690
1168
|
const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
|
|
691
1169
|
const prefix = `${commentMark} ${numbers} `;
|
|
692
1170
|
let styled = this.renderDiffRowContent(line, prefix);
|
|
@@ -700,54 +1178,6 @@ export class ReviewComponent {
|
|
|
700
1178
|
return this.applyDiffBackground(line, styled, width);
|
|
701
1179
|
}
|
|
702
1180
|
|
|
703
|
-
private renderRightPane(
|
|
704
|
-
width: number,
|
|
705
|
-
height: number,
|
|
706
|
-
selectedLine?: ReviewLine,
|
|
707
|
-
): string[] {
|
|
708
|
-
return this.rightPaneMode === "explanation"
|
|
709
|
-
? this.renderExplanationPane(width, height, selectedLine)
|
|
710
|
-
: this.renderCommentsPane(width, height, selectedLine);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
private renderCommentsPane(
|
|
714
|
-
width: number,
|
|
715
|
-
height: number,
|
|
716
|
-
selectedLine?: ReviewLine,
|
|
717
|
-
): string[] {
|
|
718
|
-
const selection = this.getActiveCommentSelection();
|
|
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
|
-
});
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
private renderExplanationPane(
|
|
737
|
-
width: number,
|
|
738
|
-
height: number,
|
|
739
|
-
selectedLine?: ReviewLine,
|
|
740
|
-
): string[] {
|
|
741
|
-
return renderExplanationPaneContent({
|
|
742
|
-
width,
|
|
743
|
-
height,
|
|
744
|
-
selectedLine,
|
|
745
|
-
theme: this.theme,
|
|
746
|
-
scope: this.getCurrentHunkScope(),
|
|
747
|
-
controller: this.explanationController,
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
|
|
751
1181
|
private ensureCurrentExplanation(): void {
|
|
752
1182
|
this.explanationController.ensure(this.getCurrentHunkScope());
|
|
753
1183
|
}
|
package/src/types.ts
CHANGED
package/src/review-panes.ts
DELETED
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
-
import type { Editor } from "@earendil-works/pi-tui";
|
|
3
|
-
import type { ExplanationController } from "./explanation-controller.ts";
|
|
4
|
-
import { GLOBAL_COMMENT_KEY } from "./comment-manager.ts";
|
|
5
|
-
import { formatLocation } from "./prompt.ts";
|
|
6
|
-
import type { ExplanationScope } from "./explain.ts";
|
|
7
|
-
import type {
|
|
8
|
-
ReviewComment,
|
|
9
|
-
ReviewLine,
|
|
10
|
-
ReviewTheme,
|
|
11
|
-
SelectionBounds,
|
|
12
|
-
} from "./types.ts";
|
|
13
|
-
|
|
14
|
-
export type RenderCommentsPaneOptions = {
|
|
15
|
-
width: number;
|
|
16
|
-
height: number;
|
|
17
|
-
selectedLine?: ReviewLine;
|
|
18
|
-
theme: ReviewTheme;
|
|
19
|
-
lines: ReviewLine[];
|
|
20
|
-
comments: Map<string, ReviewComment>;
|
|
21
|
-
editor: Editor;
|
|
22
|
-
editMode: boolean;
|
|
23
|
-
editingCommentKey?: string;
|
|
24
|
-
selection: SelectionBounds | undefined;
|
|
25
|
-
currentComment: ReviewComment | undefined;
|
|
26
|
-
footerText: string;
|
|
27
|
-
hasSelection: boolean;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export function renderCommentsPane({
|
|
31
|
-
width,
|
|
32
|
-
height,
|
|
33
|
-
selectedLine,
|
|
34
|
-
theme,
|
|
35
|
-
lines: diffLines,
|
|
36
|
-
comments,
|
|
37
|
-
editor,
|
|
38
|
-
editMode,
|
|
39
|
-
editingCommentKey,
|
|
40
|
-
selection,
|
|
41
|
-
currentComment,
|
|
42
|
-
footerText,
|
|
43
|
-
hasSelection,
|
|
44
|
-
}: RenderCommentsPaneOptions): string[] {
|
|
45
|
-
const lines: string[] = [];
|
|
46
|
-
const title = theme.fg("accent", theme.bold("Comments"));
|
|
47
|
-
|
|
48
|
-
lines.push(truncateToWidth(title, width));
|
|
49
|
-
lines.push(
|
|
50
|
-
truncateToWidth(
|
|
51
|
-
theme.fg(
|
|
52
|
-
"dim",
|
|
53
|
-
selection
|
|
54
|
-
? footerText
|
|
55
|
-
: selectedLine
|
|
56
|
-
? formatLocation(selectedLine)
|
|
57
|
-
: "No selection",
|
|
58
|
-
),
|
|
59
|
-
width,
|
|
60
|
-
),
|
|
61
|
-
);
|
|
62
|
-
lines.push("");
|
|
63
|
-
|
|
64
|
-
if (editMode && editingCommentKey === GLOBAL_COMMENT_KEY) {
|
|
65
|
-
lines[1] = truncateToWidth(theme.fg("dim", "Overall diff comment"), width);
|
|
66
|
-
lines.push(
|
|
67
|
-
...wrapTextWithAnsi(
|
|
68
|
-
theme.fg(
|
|
69
|
-
"dim",
|
|
70
|
-
"Editing overall diff comment. Enter saves. Esc or Ctrl+C cancels.",
|
|
71
|
-
),
|
|
72
|
-
width,
|
|
73
|
-
),
|
|
74
|
-
);
|
|
75
|
-
lines.push("");
|
|
76
|
-
for (const line of editor.render(Math.max(10, width))) {
|
|
77
|
-
lines.push(truncateToWidth(line, width));
|
|
78
|
-
}
|
|
79
|
-
return lines.slice(0, height);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const globalComment = comments.get(GLOBAL_COMMENT_KEY);
|
|
83
|
-
if (globalComment) {
|
|
84
|
-
lines.push(
|
|
85
|
-
truncateToWidth(
|
|
86
|
-
theme.fg("accent", theme.bold("Overall diff comment")),
|
|
87
|
-
width,
|
|
88
|
-
),
|
|
89
|
-
);
|
|
90
|
-
lines.push(
|
|
91
|
-
...wrapTextWithAnsi(theme.fg("text", globalComment.text), width),
|
|
92
|
-
);
|
|
93
|
-
lines.push(...wrapTextWithAnsi(theme.fg("dim", "C edits"), width));
|
|
94
|
-
lines.push("");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (!selectedLine) {
|
|
98
|
-
lines.push(
|
|
99
|
-
...wrapTextWithAnsi(theme.fg("muted", "No diff lines available."), width),
|
|
100
|
-
);
|
|
101
|
-
return lines.slice(0, height);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (!selection) {
|
|
105
|
-
lines.push(
|
|
106
|
-
...wrapTextWithAnsi(
|
|
107
|
-
theme.fg(
|
|
108
|
-
"muted",
|
|
109
|
-
"Move to a diff line and press c to add a comment, or press C for an overall diff comment.",
|
|
110
|
-
),
|
|
111
|
-
width,
|
|
112
|
-
),
|
|
113
|
-
);
|
|
114
|
-
return lines.slice(0, height);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (editMode && currentComment?.id === editingCommentKey) {
|
|
118
|
-
lines.push(
|
|
119
|
-
...wrapTextWithAnsi(
|
|
120
|
-
theme.fg("dim", "Editing comment. Enter saves. Esc or Ctrl+C cancels."),
|
|
121
|
-
width,
|
|
122
|
-
),
|
|
123
|
-
);
|
|
124
|
-
lines.push("");
|
|
125
|
-
for (const line of editor.render(Math.max(10, width))) {
|
|
126
|
-
lines.push(truncateToWidth(line, width));
|
|
127
|
-
}
|
|
128
|
-
} else if (editMode && editingCommentKey) {
|
|
129
|
-
lines.push(
|
|
130
|
-
...wrapTextWithAnsi(
|
|
131
|
-
theme.fg("dim", "Editing comment. Enter saves. Esc or Ctrl+C cancels."),
|
|
132
|
-
width,
|
|
133
|
-
),
|
|
134
|
-
);
|
|
135
|
-
lines.push("");
|
|
136
|
-
for (const line of editor.render(Math.max(10, width))) {
|
|
137
|
-
lines.push(truncateToWidth(line, width));
|
|
138
|
-
}
|
|
139
|
-
} else if (currentComment) {
|
|
140
|
-
lines.push(
|
|
141
|
-
...wrapTextWithAnsi(theme.fg("text", currentComment.text), width),
|
|
142
|
-
);
|
|
143
|
-
lines.push("");
|
|
144
|
-
lines.push(
|
|
145
|
-
...wrapTextWithAnsi(
|
|
146
|
-
theme.fg("dim", "x deletes this comment • c edits"),
|
|
147
|
-
width,
|
|
148
|
-
),
|
|
149
|
-
);
|
|
150
|
-
} else {
|
|
151
|
-
lines.push(
|
|
152
|
-
...wrapTextWithAnsi(
|
|
153
|
-
theme.fg(
|
|
154
|
-
"muted",
|
|
155
|
-
hasSelection
|
|
156
|
-
? "No comment on this range."
|
|
157
|
-
: "No comment on this line.",
|
|
158
|
-
),
|
|
159
|
-
width,
|
|
160
|
-
),
|
|
161
|
-
);
|
|
162
|
-
lines.push("");
|
|
163
|
-
lines.push(
|
|
164
|
-
...wrapTextWithAnsi(
|
|
165
|
-
theme.fg(
|
|
166
|
-
"dim",
|
|
167
|
-
hasSelection
|
|
168
|
-
? "Press c to add a range comment, or C for an overall diff comment."
|
|
169
|
-
: "Press c to add one. Use J/K to extend a range. Press C for an overall diff comment.",
|
|
170
|
-
),
|
|
171
|
-
width,
|
|
172
|
-
),
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
lines.push("");
|
|
177
|
-
lines.push(truncateToWidth(theme.fg("accent", theme.bold("Excerpt")), width));
|
|
178
|
-
const excerpt = diffLines
|
|
179
|
-
.slice(selection.start, selection.end + 1)
|
|
180
|
-
.map((line) => line.text)
|
|
181
|
-
.join("\n");
|
|
182
|
-
lines.push(
|
|
183
|
-
...wrapTextWithAnsi(
|
|
184
|
-
theme.fg("toolDiffContext", excerpt || "(blank line)"),
|
|
185
|
-
width,
|
|
186
|
-
),
|
|
187
|
-
);
|
|
188
|
-
return lines.slice(0, height);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export type RenderExplanationPaneOptions = {
|
|
192
|
-
width: number;
|
|
193
|
-
height: number;
|
|
194
|
-
selectedLine?: ReviewLine;
|
|
195
|
-
theme: ReviewTheme;
|
|
196
|
-
scope: ExplanationScope | undefined;
|
|
197
|
-
controller: ExplanationController;
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
export function renderExplanationPane({
|
|
201
|
-
width,
|
|
202
|
-
height,
|
|
203
|
-
selectedLine,
|
|
204
|
-
theme,
|
|
205
|
-
scope,
|
|
206
|
-
controller,
|
|
207
|
-
}: RenderExplanationPaneOptions): string[] {
|
|
208
|
-
const lines: string[] = [];
|
|
209
|
-
const title = theme.fg("accent", theme.bold("Explanation"));
|
|
210
|
-
|
|
211
|
-
lines.push(truncateToWidth(title, width));
|
|
212
|
-
lines.push(
|
|
213
|
-
truncateToWidth(
|
|
214
|
-
theme.fg(
|
|
215
|
-
"dim",
|
|
216
|
-
scope?.title ??
|
|
217
|
-
(selectedLine ? formatLocation(selectedLine) : "No selection"),
|
|
218
|
-
),
|
|
219
|
-
width,
|
|
220
|
-
),
|
|
221
|
-
);
|
|
222
|
-
lines.push("");
|
|
223
|
-
|
|
224
|
-
if (!controller.isAvailable) {
|
|
225
|
-
lines.push(
|
|
226
|
-
...wrapTextWithAnsi(
|
|
227
|
-
theme.fg("warning", "Diff explanations are unavailable."),
|
|
228
|
-
width,
|
|
229
|
-
),
|
|
230
|
-
);
|
|
231
|
-
return lines.slice(0, height);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (!scope) {
|
|
235
|
-
lines.push(
|
|
236
|
-
...wrapTextWithAnsi(
|
|
237
|
-
theme.fg(
|
|
238
|
-
"muted",
|
|
239
|
-
"Move to a changed hunk and press ? to generate an explanation.",
|
|
240
|
-
),
|
|
241
|
-
width,
|
|
242
|
-
),
|
|
243
|
-
);
|
|
244
|
-
return lines.slice(0, height);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
const explanation = controller.getState(scope);
|
|
248
|
-
if (!explanation) {
|
|
249
|
-
lines.push(
|
|
250
|
-
...wrapTextWithAnsi(
|
|
251
|
-
theme.fg(
|
|
252
|
-
"muted",
|
|
253
|
-
"No explanation generated yet. Press ? again after returning to comments to generate this hunk.",
|
|
254
|
-
),
|
|
255
|
-
width,
|
|
256
|
-
),
|
|
257
|
-
);
|
|
258
|
-
} else if (explanation.status === "loading") {
|
|
259
|
-
const spinner = controller.getLoadingFrame();
|
|
260
|
-
lines.push(
|
|
261
|
-
truncateToWidth(
|
|
262
|
-
theme.fg("accent", `${spinner} Generating explanation...`),
|
|
263
|
-
width,
|
|
264
|
-
),
|
|
265
|
-
);
|
|
266
|
-
if (explanation.text.trim()) {
|
|
267
|
-
lines.push("");
|
|
268
|
-
lines.push(
|
|
269
|
-
...wrapTextWithAnsi(theme.fg("text", explanation.text), width),
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
} else if (explanation.status === "error") {
|
|
273
|
-
lines.push(
|
|
274
|
-
...wrapTextWithAnsi(
|
|
275
|
-
theme.fg("warning", `Unable to explain diff: ${explanation.message}`),
|
|
276
|
-
width,
|
|
277
|
-
),
|
|
278
|
-
);
|
|
279
|
-
} else {
|
|
280
|
-
lines.push(...wrapTextWithAnsi(theme.fg("text", explanation.text), width));
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
lines.push("");
|
|
284
|
-
lines.push(...wrapTextWithAnsi(theme.fg("dim", "? comments"), width));
|
|
285
|
-
return lines.slice(0, height);
|
|
286
|
-
}
|