pi-diff-review 0.1.15 → 0.1.17
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 +3 -17
- package/package.json +1 -1
- package/src/{diff-parser.ts → diff/parser.ts} +1 -1
- package/src/{diff-source.ts → diff/source.ts} +1 -1
- package/src/{split-diff.ts → diff/split.ts} +5 -1
- package/src/{explanation-controller.ts → explanation/controller.ts} +2 -2
- package/src/index.ts +10 -6
- package/src/{review-component.ts → review/component.ts} +207 -118
- package/src/review/search.ts +109 -0
- /package/src/{explain.ts → explanation/explainer.ts} +0 -0
- /package/src/{render-utils.ts → render/utils.ts} +0 -0
- /package/src/{comment-manager.ts → review/comments.ts} +0 -0
- /package/src/{review-navigation.ts → review/navigation.ts} +0 -0
- /package/src/{prompt.ts → review/prompt.ts} +0 -0
- /package/src/{types.ts → review/types.ts} +0 -0
package/README.md
CHANGED
|
@@ -62,6 +62,7 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
|
|
|
62
62
|
- `/diff --cached` reviews staged changes
|
|
63
63
|
- `/diff main...HEAD` reviews changes on the current branch relative to `main`
|
|
64
64
|
- `/diff <git-diff-args>` passes arguments through to `git diff`
|
|
65
|
+
- `h` toggles the command help modal
|
|
65
66
|
- `j/k` or arrow keys to move
|
|
66
67
|
- `g/G` to jump to the top or bottom of the diff
|
|
67
68
|
- `ctrl-u` / `ctrl-d` to move up/down by half a page
|
|
@@ -79,21 +80,6 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
|
|
|
79
80
|
- Comments are cached per session and restored when reopening the same diff
|
|
80
81
|
- `q` to exit
|
|
81
82
|
|
|
82
|
-
##
|
|
83
|
+
## Contributing
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
```bash
|
|
87
|
-
pre-commit install
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
Run the same checks manually with either:
|
|
91
|
-
|
|
92
|
-
```bash
|
|
93
|
-
pre-commit run --all-files
|
|
94
|
-
npm run precommit
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
## Release
|
|
98
|
-
|
|
99
|
-
See [RELEASE.md](./RELEASE.md).
|
|
85
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development and release instructions.
|
package/package.json
CHANGED
|
@@ -2,8 +2,8 @@ import type {
|
|
|
2
2
|
DiffExplainer,
|
|
3
3
|
ExplanationScope,
|
|
4
4
|
ExplanationState,
|
|
5
|
-
} from "./
|
|
6
|
-
import type { ReviewLine, ReviewTui } from "
|
|
5
|
+
} from "./explainer.ts";
|
|
6
|
+
import type { ReviewLine, ReviewTui } from "../review/types.ts";
|
|
7
7
|
|
|
8
8
|
const LOADING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
9
|
|
package/src/index.ts
CHANGED
|
@@ -3,12 +3,16 @@ import type {
|
|
|
3
3
|
ExtensionAPI,
|
|
4
4
|
ExtensionCommandContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { getDiff, parseDiffSource } from "./diff
|
|
7
|
-
import { parseDiff } from "./diff
|
|
8
|
-
import { PiModelDiffExplainer } from "./
|
|
9
|
-
import { buildReviewPrompt } from "./prompt.ts";
|
|
10
|
-
import { ReviewComponent } from "./review
|
|
11
|
-
import type {
|
|
6
|
+
import { getDiff, parseDiffSource } from "./diff/source.ts";
|
|
7
|
+
import { parseDiff } from "./diff/parser.ts";
|
|
8
|
+
import { PiModelDiffExplainer } from "./explanation/explainer.ts";
|
|
9
|
+
import { buildReviewPrompt } from "./review/prompt.ts";
|
|
10
|
+
import { ReviewComponent } from "./review/component.ts";
|
|
11
|
+
import type {
|
|
12
|
+
DiffSource,
|
|
13
|
+
ReviewComment,
|
|
14
|
+
ReviewResult,
|
|
15
|
+
} from "./review/types.ts";
|
|
12
16
|
|
|
13
17
|
const DIFF_REVIEW_CACHE_ENTRY = "pi-diff-review-cache";
|
|
14
18
|
const DIFF_REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
|
|
@@ -9,22 +9,23 @@ import {
|
|
|
9
9
|
visibleWidth,
|
|
10
10
|
wrapTextWithAnsi,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
|
-
import type { DiffExplainer } from "
|
|
12
|
+
import type { DiffExplainer } from "../explanation/explainer.ts";
|
|
13
13
|
import {
|
|
14
14
|
GLOBAL_COMMENT_KEY,
|
|
15
15
|
buildCommentFromSelection,
|
|
16
16
|
buildCommentLineKeys,
|
|
17
17
|
buildGlobalComment,
|
|
18
18
|
getSelectionKey,
|
|
19
|
-
} from "./
|
|
19
|
+
} from "./comments.ts";
|
|
20
20
|
import {
|
|
21
21
|
ExplanationController,
|
|
22
22
|
getCurrentHunkScope,
|
|
23
|
-
} from "
|
|
23
|
+
} from "../explanation/controller.ts";
|
|
24
24
|
import { formatCommentLocation, formatLocation } from "./prompt.ts";
|
|
25
|
-
import { ReviewNavigationState } from "./
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
25
|
+
import { ReviewNavigationState } from "./navigation.ts";
|
|
26
|
+
import { ReviewSearchState } from "./search.ts";
|
|
27
|
+
import { padToWidth, lineNumberCell } from "../render/utils.ts";
|
|
28
|
+
import { buildSplitDiffRows } from "../diff/split.ts";
|
|
28
29
|
import type {
|
|
29
30
|
DiffRenderMode,
|
|
30
31
|
ReviewComment,
|
|
@@ -50,14 +51,32 @@ type AnnotatedDiffRow =
|
|
|
50
51
|
| { kind: "split"; splitRowIndex: number }
|
|
51
52
|
| InlineBoxRow;
|
|
52
53
|
|
|
54
|
+
const HELP_COMMANDS = [
|
|
55
|
+
["h", "show or hide this help"],
|
|
56
|
+
["j/k or arrows", "move selection"],
|
|
57
|
+
["ctrl-u / ctrl-d", "move up or down half a page"],
|
|
58
|
+
["g / G", "jump to top or bottom"],
|
|
59
|
+
["n / p", "jump to next or previous hunk"],
|
|
60
|
+
["/", "search diff lines"],
|
|
61
|
+
["n / N", "jump between search matches"],
|
|
62
|
+
["J / K", "extend highlighted selection"],
|
|
63
|
+
["c", "add or edit a line or range comment"],
|
|
64
|
+
["C", "add or edit an overall diff comment"],
|
|
65
|
+
["x", "delete the current line or range comment"],
|
|
66
|
+
["t", "toggle inline comments and explanations"],
|
|
67
|
+
["v", "toggle unified or split rendering"],
|
|
68
|
+
["?", "toggle AI explanation for current hunk"],
|
|
69
|
+
["Enter", "submit comments, save edits, or jump to search result"],
|
|
70
|
+
["Esc", "close help, cancel search/edit, clear selection, or exit"],
|
|
71
|
+
["q", "exit review"],
|
|
72
|
+
] as const;
|
|
73
|
+
|
|
53
74
|
export class ReviewComponent {
|
|
54
75
|
private navigation: ReviewNavigationState;
|
|
55
76
|
private editMode = false;
|
|
56
77
|
private editingCommentKey?: string;
|
|
57
|
-
private
|
|
58
|
-
private
|
|
59
|
-
private draftSearchQuery = "";
|
|
60
|
-
private searchMessage = "";
|
|
78
|
+
private search: ReviewSearchState;
|
|
79
|
+
private helpVisible = false;
|
|
61
80
|
|
|
62
81
|
private inlineAnnotationsVisible = true;
|
|
63
82
|
private visibleExplanationKeys = new Set<string>();
|
|
@@ -97,6 +116,7 @@ export class ReviewComponent {
|
|
|
97
116
|
firstCommentable >= 0 ? firstCommentable : 0,
|
|
98
117
|
);
|
|
99
118
|
this.lines.forEach((line, index) => this.lineIndexById.set(line.id, index));
|
|
119
|
+
this.search = new ReviewSearchState(this.lines);
|
|
100
120
|
this.explanationController = new ExplanationController(
|
|
101
121
|
tui,
|
|
102
122
|
explainer,
|
|
@@ -172,6 +192,13 @@ export class ReviewComponent {
|
|
|
172
192
|
}
|
|
173
193
|
|
|
174
194
|
handleInput(data: string): void {
|
|
195
|
+
if (this.helpVisible) {
|
|
196
|
+
if (data === "h" || matchesKey(data, "escape")) {
|
|
197
|
+
this.toggleHelp();
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
175
202
|
if (this.editMode) {
|
|
176
203
|
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
177
204
|
this.exitEditMode();
|
|
@@ -183,13 +210,13 @@ export class ReviewComponent {
|
|
|
183
210
|
return;
|
|
184
211
|
}
|
|
185
212
|
|
|
186
|
-
if (this.
|
|
213
|
+
if (this.search.mode) {
|
|
187
214
|
this.handleSearchInput(data);
|
|
188
215
|
return;
|
|
189
216
|
}
|
|
190
217
|
|
|
191
218
|
if (matchesKey(data, "escape")) {
|
|
192
|
-
if (this.
|
|
219
|
+
if (this.search.query) {
|
|
193
220
|
this.clearSearch();
|
|
194
221
|
} else if (this.hasSelection()) {
|
|
195
222
|
this.clearSelection();
|
|
@@ -202,6 +229,10 @@ export class ReviewComponent {
|
|
|
202
229
|
this.done({ action: "cancel" });
|
|
203
230
|
return;
|
|
204
231
|
}
|
|
232
|
+
if (data === "h") {
|
|
233
|
+
this.toggleHelp();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
205
236
|
if (data === "t") {
|
|
206
237
|
this.toggleInlineAnnotations();
|
|
207
238
|
return;
|
|
@@ -251,14 +282,14 @@ export class ReviewComponent {
|
|
|
251
282
|
return;
|
|
252
283
|
}
|
|
253
284
|
if (data === "n") {
|
|
254
|
-
if (this.
|
|
285
|
+
if (this.search.query) {
|
|
255
286
|
this.jumpSearch(1);
|
|
256
287
|
} else {
|
|
257
288
|
this.jumpHunk(1);
|
|
258
289
|
}
|
|
259
290
|
return;
|
|
260
291
|
}
|
|
261
|
-
if (data === "N" && this.
|
|
292
|
+
if (data === "N" && this.search.query) {
|
|
262
293
|
this.jumpSearch(-1);
|
|
263
294
|
return;
|
|
264
295
|
}
|
|
@@ -293,15 +324,9 @@ export class ReviewComponent {
|
|
|
293
324
|
const output: string[] = [];
|
|
294
325
|
|
|
295
326
|
output.push(
|
|
296
|
-
|
|
297
|
-
this.
|
|
298
|
-
|
|
299
|
-
this.editMode
|
|
300
|
-
? `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments • editing inline comment • Enter save • Esc/Ctrl+C cancel`
|
|
301
|
-
: this.hasSelection()
|
|
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`,
|
|
304
|
-
),
|
|
327
|
+
this.renderStatusLine(
|
|
328
|
+
this.getHeaderText(selectedLine),
|
|
329
|
+
"Press h for help",
|
|
305
330
|
width,
|
|
306
331
|
),
|
|
307
332
|
);
|
|
@@ -315,9 +340,132 @@ export class ReviewComponent {
|
|
|
315
340
|
width,
|
|
316
341
|
),
|
|
317
342
|
);
|
|
343
|
+
return this.helpVisible ? this.renderHelpModal(output, width) : output;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private getHeaderText(selectedLine?: ReviewLine): string {
|
|
347
|
+
const base = `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments`;
|
|
348
|
+
|
|
349
|
+
if (this.editMode) {
|
|
350
|
+
return `${base} • editing ${this.editingCommentKey === GLOBAL_COMMENT_KEY ? "overall comment" : "inline comment"}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (this.hasSelection()) {
|
|
354
|
+
return `${base} • selection active`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return `${base} • ${this.getPositionText(selectedLine)} • inline ${this.inlineAnnotationsVisible ? "shown" : "hidden"} • ${this.diffRenderMode}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private renderStatusLine(left: string, right: string, width: number): string {
|
|
361
|
+
const styledLeft = this.theme.fg("dim", left);
|
|
362
|
+
const styledRight = this.theme.fg("muted", right);
|
|
363
|
+
const rightWidth = visibleWidth(styledRight);
|
|
364
|
+
const leftWidth = Math.max(0, width - rightWidth - 1);
|
|
365
|
+
const truncatedLeft = truncateToWidth(styledLeft, leftWidth);
|
|
366
|
+
const spacer = Math.max(
|
|
367
|
+
1,
|
|
368
|
+
width - visibleWidth(truncatedLeft) - rightWidth,
|
|
369
|
+
);
|
|
370
|
+
return truncateToWidth(
|
|
371
|
+
`${truncatedLeft}${" ".repeat(spacer)}${styledRight}`,
|
|
372
|
+
width,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private renderHelpModal(rows: string[], width: number): string[] {
|
|
377
|
+
if (rows.length === 0 || width < 8) return rows;
|
|
378
|
+
|
|
379
|
+
const modalWidth = Math.max(8, Math.min(72, width - 2));
|
|
380
|
+
const contentWidth = Math.max(4, modalWidth - 4);
|
|
381
|
+
const keyWidth = Math.min(16, Math.max(8, Math.floor(contentWidth * 0.35)));
|
|
382
|
+
const modalRows = this.buildHelpModalRows(
|
|
383
|
+
modalWidth,
|
|
384
|
+
contentWidth,
|
|
385
|
+
keyWidth,
|
|
386
|
+
);
|
|
387
|
+
const maxRows = Math.max(1, rows.length - 2);
|
|
388
|
+
const visibleModalRows =
|
|
389
|
+
modalRows.length > maxRows
|
|
390
|
+
? [
|
|
391
|
+
...modalRows.slice(0, maxRows - 1),
|
|
392
|
+
this.renderModalRow(
|
|
393
|
+
this.theme.fg("dim", "More commands available on taller screens"),
|
|
394
|
+
contentWidth,
|
|
395
|
+
),
|
|
396
|
+
]
|
|
397
|
+
: modalRows;
|
|
398
|
+
const top = Math.max(
|
|
399
|
+
1,
|
|
400
|
+
Math.floor((rows.length - visibleModalRows.length) / 2),
|
|
401
|
+
);
|
|
402
|
+
const left = Math.max(0, Math.floor((width - modalWidth) / 2));
|
|
403
|
+
|
|
404
|
+
const output = [...rows];
|
|
405
|
+
for (let index = 0; index < visibleModalRows.length; index++) {
|
|
406
|
+
const row = visibleModalRows[index]!;
|
|
407
|
+
output[top + index] = padToWidth(
|
|
408
|
+
truncateToWidth(`${" ".repeat(left)}${row}`, width),
|
|
409
|
+
width,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
318
412
|
return output;
|
|
319
413
|
}
|
|
320
414
|
|
|
415
|
+
private buildHelpModalRows(
|
|
416
|
+
modalWidth: number,
|
|
417
|
+
contentWidth: number,
|
|
418
|
+
keyWidth: number,
|
|
419
|
+
): string[] {
|
|
420
|
+
const rows = [
|
|
421
|
+
this.renderModalHorizontal(" Help ", modalWidth, "top"),
|
|
422
|
+
this.renderModalRow(
|
|
423
|
+
this.theme.fg("dim", "Press h or Esc to close."),
|
|
424
|
+
contentWidth,
|
|
425
|
+
),
|
|
426
|
+
this.renderModalRow("", contentWidth),
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
for (const [keys, description] of HELP_COMMANDS) {
|
|
430
|
+
const keyCell = padToWidth(
|
|
431
|
+
truncateToWidth(this.theme.fg("accent", keys), keyWidth),
|
|
432
|
+
keyWidth,
|
|
433
|
+
);
|
|
434
|
+
rows.push(
|
|
435
|
+
this.renderModalRow(
|
|
436
|
+
`${keyCell} ${this.theme.fg("text", description)}`,
|
|
437
|
+
contentWidth,
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
rows.push(this.renderModalHorizontal("", modalWidth, "bottom"));
|
|
443
|
+
return rows;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private renderModalRow(text: string, contentWidth: number): string {
|
|
447
|
+
return `${this.theme.fg("borderMuted", "│")} ${padToWidth(
|
|
448
|
+
truncateToWidth(text, contentWidth),
|
|
449
|
+
contentWidth,
|
|
450
|
+
)} ${this.theme.fg("borderMuted", "│")}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private renderModalHorizontal(
|
|
454
|
+
title: string,
|
|
455
|
+
width: number,
|
|
456
|
+
part: "top" | "bottom",
|
|
457
|
+
): string {
|
|
458
|
+
const contentWidth = Math.max(0, width - 2);
|
|
459
|
+
const visibleTitle = truncateToWidth(title, contentWidth);
|
|
460
|
+
const remaining = Math.max(0, contentWidth - visibleWidth(visibleTitle));
|
|
461
|
+
const left = part === "top" ? "╭" : "╰";
|
|
462
|
+
const right = part === "top" ? "╮" : "╯";
|
|
463
|
+
return `${this.theme.fg("borderMuted", left)}${visibleTitle}${this.theme.fg(
|
|
464
|
+
"borderMuted",
|
|
465
|
+
"─".repeat(remaining),
|
|
466
|
+
)}${this.theme.fg("borderMuted", right)}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
321
469
|
private renderAnnotatedDiffRows(width: number, height: number): string[] {
|
|
322
470
|
const rows = this.getAnnotatedRows(width);
|
|
323
471
|
const output: string[] = [];
|
|
@@ -767,6 +915,11 @@ export class ReviewComponent {
|
|
|
767
915
|
this.tui.requestRender(true);
|
|
768
916
|
}
|
|
769
917
|
|
|
918
|
+
private toggleHelp(): void {
|
|
919
|
+
this.helpVisible = !this.helpVisible;
|
|
920
|
+
this.tui.requestRender(true);
|
|
921
|
+
}
|
|
922
|
+
|
|
770
923
|
private hideInlineExplanation(): void {
|
|
771
924
|
this.visibleExplanationKeys.clear();
|
|
772
925
|
}
|
|
@@ -869,11 +1022,11 @@ export class ReviewComponent {
|
|
|
869
1022
|
}
|
|
870
1023
|
|
|
871
1024
|
private getFooterText(selectedLine?: ReviewLine): string {
|
|
872
|
-
if (this.
|
|
873
|
-
return `Search: /${this.
|
|
1025
|
+
if (this.search.mode) {
|
|
1026
|
+
return `Search: /${this.search.draftQuery} • Enter jump • Esc cancel`;
|
|
874
1027
|
}
|
|
875
1028
|
|
|
876
|
-
const searchStatus = this.
|
|
1029
|
+
const searchStatus = this.search.getStatusText(this.selected);
|
|
877
1030
|
if (searchStatus) return searchStatus;
|
|
878
1031
|
|
|
879
1032
|
const selection = this.getSelectionBounds();
|
|
@@ -886,21 +1039,6 @@ export class ReviewComponent {
|
|
|
886
1039
|
return `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
|
|
887
1040
|
}
|
|
888
1041
|
|
|
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
|
-
|
|
904
1042
|
private jumpHunk(direction: 1 | -1): void {
|
|
905
1043
|
let index = this.selected + direction;
|
|
906
1044
|
while (index >= 0 && index < this.lines.length) {
|
|
@@ -965,92 +1103,27 @@ export class ReviewComponent {
|
|
|
965
1103
|
}
|
|
966
1104
|
|
|
967
1105
|
private startSearchMode(): void {
|
|
968
|
-
this.
|
|
969
|
-
this.draftSearchQuery = this.searchQuery;
|
|
970
|
-
this.searchMessage = "";
|
|
1106
|
+
this.search.start();
|
|
971
1107
|
this.tui.requestRender();
|
|
972
1108
|
}
|
|
973
1109
|
|
|
974
1110
|
private handleSearchInput(data: string): void {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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
|
-
}
|
|
1111
|
+
const result = this.search.handleInput(data, this.selected);
|
|
1112
|
+
if (result.selected != null) this.selected = result.selected;
|
|
1113
|
+
this.tui.requestRender();
|
|
1012
1114
|
}
|
|
1013
1115
|
|
|
1014
1116
|
private clearSearch(): void {
|
|
1015
|
-
this.
|
|
1016
|
-
this.searchQuery = "";
|
|
1017
|
-
this.draftSearchQuery = "";
|
|
1018
|
-
this.searchMessage = "";
|
|
1117
|
+
this.search.clear();
|
|
1019
1118
|
this.tui.requestRender();
|
|
1020
1119
|
}
|
|
1021
1120
|
|
|
1022
1121
|
private jumpSearch(direction: 1 | -1): void {
|
|
1023
|
-
const
|
|
1024
|
-
if (
|
|
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]!);
|
|
1122
|
+
const result = this.search.jump(direction, this.selected);
|
|
1123
|
+
if (result.selected != null) this.selected = result.selected;
|
|
1040
1124
|
this.tui.requestRender();
|
|
1041
1125
|
}
|
|
1042
1126
|
|
|
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
|
-
|
|
1054
1127
|
private getSplitDiffRows(): SplitDiffRow[] {
|
|
1055
1128
|
if (this.splitRows) return this.splitRows;
|
|
1056
1129
|
|
|
@@ -1100,11 +1173,11 @@ export class ReviewComponent {
|
|
|
1100
1173
|
}
|
|
1101
1174
|
|
|
1102
1175
|
private getDisplayText(line: ReviewLine): string {
|
|
1103
|
-
|
|
1104
|
-
line.kind === "remove" ||
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1176
|
+
const raw =
|
|
1177
|
+
line.kind === "add" || line.kind === "remove" || line.kind === "context"
|
|
1178
|
+
? line.text.slice(1)
|
|
1179
|
+
: line.text;
|
|
1180
|
+
return expandTabs(raw);
|
|
1108
1181
|
}
|
|
1109
1182
|
|
|
1110
1183
|
private applyDiffBackground(
|
|
@@ -1186,3 +1259,19 @@ export class ReviewComponent {
|
|
|
1186
1259
|
return getCurrentHunkScope(this.lines, this.selected);
|
|
1187
1260
|
}
|
|
1188
1261
|
}
|
|
1262
|
+
|
|
1263
|
+
function expandTabs(text: string, tabSize = 4): string {
|
|
1264
|
+
let result = "";
|
|
1265
|
+
let col = 0;
|
|
1266
|
+
for (const char of text) {
|
|
1267
|
+
if (char === "\t") {
|
|
1268
|
+
const spaces = tabSize - (col % tabSize);
|
|
1269
|
+
result += " ".repeat(spaces);
|
|
1270
|
+
col += spaces;
|
|
1271
|
+
} else {
|
|
1272
|
+
result += char;
|
|
1273
|
+
col++;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return result;
|
|
1277
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ReviewLine } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export type SearchInputResult = {
|
|
5
|
+
selected?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export class ReviewSearchState {
|
|
9
|
+
mode = false;
|
|
10
|
+
query = "";
|
|
11
|
+
draftQuery = "";
|
|
12
|
+
message = "";
|
|
13
|
+
|
|
14
|
+
constructor(private readonly lines: ReviewLine[]) {}
|
|
15
|
+
|
|
16
|
+
start(): void {
|
|
17
|
+
this.mode = true;
|
|
18
|
+
this.draftQuery = this.query;
|
|
19
|
+
this.message = "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
handleInput(data: string, selected: number): SearchInputResult {
|
|
23
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
24
|
+
this.mode = false;
|
|
25
|
+
this.draftQuery = "";
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (matchesKey(data, "enter")) {
|
|
30
|
+
const query = this.draftQuery.trim();
|
|
31
|
+
this.mode = false;
|
|
32
|
+
this.draftQuery = "";
|
|
33
|
+
this.query = query;
|
|
34
|
+
this.message = "";
|
|
35
|
+
return query ? this.jump(1, selected) : {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
39
|
+
this.draftQuery = "";
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (matchesKey(data, "backspace") || data === "\u007f" || data === "\b") {
|
|
44
|
+
this.draftQuery = this.draftQuery.slice(0, -1);
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (data.length === 1 && data >= " " && data !== "\u007f") {
|
|
49
|
+
this.draftQuery += data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
clear(): void {
|
|
56
|
+
this.mode = false;
|
|
57
|
+
this.query = "";
|
|
58
|
+
this.draftQuery = "";
|
|
59
|
+
this.message = "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
jump(direction: 1 | -1, selected: number): SearchInputResult {
|
|
63
|
+
const query = this.query.trim();
|
|
64
|
+
if (!query) return {};
|
|
65
|
+
|
|
66
|
+
const matches = this.getMatchIndexes(query);
|
|
67
|
+
if (matches.length === 0) {
|
|
68
|
+
this.message = `No matches for /${query}`;
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.message = "";
|
|
73
|
+
const current =
|
|
74
|
+
direction === 1
|
|
75
|
+
? matches.find((index) => index > selected)
|
|
76
|
+
: [...matches].reverse().find((index) => index < selected);
|
|
77
|
+
return {
|
|
78
|
+
selected:
|
|
79
|
+
current ??
|
|
80
|
+
(direction === 1 ? matches[0]! : matches[matches.length - 1]!),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getStatusText(selected: number): string {
|
|
85
|
+
if (this.message) return this.message;
|
|
86
|
+
if (!this.query) return "";
|
|
87
|
+
|
|
88
|
+
const matches = this.getMatchIndexes(this.query);
|
|
89
|
+
if (matches.length === 0) return `No matches for /${this.query}`;
|
|
90
|
+
|
|
91
|
+
const current = matches.findIndex((index) => index === selected);
|
|
92
|
+
const position =
|
|
93
|
+
current >= 0
|
|
94
|
+
? `${current + 1}/${matches.length}`
|
|
95
|
+
: `${matches.length} matches`;
|
|
96
|
+
return `Search /${this.query} • ${position} • n next • N previous • Esc clear search`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private getMatchIndexes(query: string): number[] {
|
|
100
|
+
const needle = query.toLocaleLowerCase();
|
|
101
|
+
if (!needle) return [];
|
|
102
|
+
|
|
103
|
+
const matches: number[] = [];
|
|
104
|
+
this.lines.forEach((line, index) => {
|
|
105
|
+
if (line.text.toLocaleLowerCase().includes(needle)) matches.push(index);
|
|
106
|
+
});
|
|
107
|
+
return matches;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|