pi-diff-review 0.1.19 → 0.1.21
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 +13 -2
- package/extensions/review.ts +5 -1
- package/package.json +1 -1
- package/src/diff/source.ts +6 -41
- package/src/explanation/controller.ts +34 -1
- package/src/explanation/explainer.ts +22 -2
- package/src/index.ts +170 -226
- package/src/review/cache.ts +171 -0
- package/src/review/component.ts +85 -14
- package/src/review/prompt.ts +25 -2
- package/src/review/search.ts +83 -20
- package/src/review/types.ts +8 -0
- package/src/review/workspace-comments.ts +493 -0
- package/src/shared/args.ts +44 -0
- package/src/view/parser.ts +54 -0
- package/src/view/source.ts +132 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { PersistedAsk, ReviewComment } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const REVIEW_COMMENT_CACHE_ENTRY = "pi-diff-review-cache";
|
|
8
|
+
const REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
|
|
9
|
+
const REVIEW_ASK_CACHE_ENTRY = "pi-diff-review-ask-cache";
|
|
10
|
+
|
|
11
|
+
type ReviewCommentCacheEntry = {
|
|
12
|
+
cacheKey: string;
|
|
13
|
+
comments: ReviewComment[];
|
|
14
|
+
updatedAt: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ReviewExplanationCacheEntry = {
|
|
18
|
+
cacheKey: string;
|
|
19
|
+
explanations: Record<string, string>;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ReviewAskCacheEntry = {
|
|
24
|
+
cacheKey: string;
|
|
25
|
+
ask?: PersistedAsk;
|
|
26
|
+
updatedAt: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function getCachedComments(
|
|
30
|
+
ctx: ExtensionCommandContext,
|
|
31
|
+
cacheKey: string,
|
|
32
|
+
): Map<string, ReviewComment> {
|
|
33
|
+
let latest: ReviewCommentCacheEntry | undefined;
|
|
34
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
35
|
+
if (
|
|
36
|
+
entry.type !== "custom" ||
|
|
37
|
+
entry.customType !== REVIEW_COMMENT_CACHE_ENTRY
|
|
38
|
+
) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const data = entry.data as Partial<ReviewCommentCacheEntry> | undefined;
|
|
43
|
+
if (data?.cacheKey !== cacheKey || !Array.isArray(data.comments)) continue;
|
|
44
|
+
|
|
45
|
+
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
46
|
+
latest = {
|
|
47
|
+
cacheKey: data.cacheKey,
|
|
48
|
+
comments: data.comments,
|
|
49
|
+
updatedAt: data.updatedAt ?? 0,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return new Map(
|
|
55
|
+
(latest?.comments ?? []).map((comment) => [comment.id, comment]),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function persistCachedComments(
|
|
60
|
+
pi: ExtensionAPI,
|
|
61
|
+
cacheKey: string,
|
|
62
|
+
comments: Iterable<ReviewComment>,
|
|
63
|
+
): void {
|
|
64
|
+
pi.appendEntry(REVIEW_COMMENT_CACHE_ENTRY, {
|
|
65
|
+
cacheKey,
|
|
66
|
+
comments: [...comments],
|
|
67
|
+
updatedAt: Date.now(),
|
|
68
|
+
} satisfies ReviewCommentCacheEntry);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getCachedExplanations(
|
|
72
|
+
ctx: ExtensionCommandContext,
|
|
73
|
+
cacheKey: string,
|
|
74
|
+
): Map<string, string> {
|
|
75
|
+
let latest: ReviewExplanationCacheEntry | undefined;
|
|
76
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
77
|
+
if (
|
|
78
|
+
entry.type !== "custom" ||
|
|
79
|
+
entry.customType !== REVIEW_EXPLANATION_CACHE_ENTRY
|
|
80
|
+
) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const data = entry.data as Partial<ReviewExplanationCacheEntry> | undefined;
|
|
85
|
+
if (
|
|
86
|
+
data?.cacheKey !== cacheKey ||
|
|
87
|
+
!data.explanations ||
|
|
88
|
+
typeof data.explanations !== "object" ||
|
|
89
|
+
Array.isArray(data.explanations)
|
|
90
|
+
) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
95
|
+
latest = {
|
|
96
|
+
cacheKey: data.cacheKey,
|
|
97
|
+
explanations: Object.fromEntries(
|
|
98
|
+
Object.entries(data.explanations).filter(
|
|
99
|
+
(entry): entry is [string, string] =>
|
|
100
|
+
typeof entry[0] === "string" && typeof entry[1] === "string",
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
updatedAt: data.updatedAt ?? 0,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return new Map(Object.entries(latest?.explanations ?? {}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function persistCachedExplanations(
|
|
112
|
+
pi: ExtensionAPI,
|
|
113
|
+
cacheKey: string,
|
|
114
|
+
explanations: Map<string, string>,
|
|
115
|
+
): void {
|
|
116
|
+
pi.appendEntry(REVIEW_EXPLANATION_CACHE_ENTRY, {
|
|
117
|
+
cacheKey,
|
|
118
|
+
explanations: Object.fromEntries(explanations),
|
|
119
|
+
updatedAt: Date.now(),
|
|
120
|
+
} satisfies ReviewExplanationCacheEntry);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function getCachedAsk(
|
|
124
|
+
ctx: ExtensionCommandContext,
|
|
125
|
+
cacheKey: string,
|
|
126
|
+
): PersistedAsk | undefined {
|
|
127
|
+
let latest: ReviewAskCacheEntry | undefined;
|
|
128
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
129
|
+
if (
|
|
130
|
+
entry.type !== "custom" ||
|
|
131
|
+
entry.customType !== REVIEW_ASK_CACHE_ENTRY
|
|
132
|
+
) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const data = entry.data as Partial<ReviewAskCacheEntry> | undefined;
|
|
137
|
+
if (data?.cacheKey !== cacheKey) continue;
|
|
138
|
+
|
|
139
|
+
const ask = data.ask;
|
|
140
|
+
const validAsk =
|
|
141
|
+
ask &&
|
|
142
|
+
typeof ask === "object" &&
|
|
143
|
+
typeof ask.scopeKey === "string" &&
|
|
144
|
+
typeof ask.anchorLineId === "string" &&
|
|
145
|
+
typeof ask.text === "string"
|
|
146
|
+
? ask
|
|
147
|
+
: undefined;
|
|
148
|
+
|
|
149
|
+
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
150
|
+
latest = {
|
|
151
|
+
cacheKey: data.cacheKey,
|
|
152
|
+
ask: validAsk,
|
|
153
|
+
updatedAt: data.updatedAt ?? 0,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return latest?.ask;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function persistCachedAsk(
|
|
162
|
+
pi: ExtensionAPI,
|
|
163
|
+
cacheKey: string,
|
|
164
|
+
ask?: PersistedAsk,
|
|
165
|
+
): void {
|
|
166
|
+
pi.appendEntry(REVIEW_ASK_CACHE_ENTRY, {
|
|
167
|
+
cacheKey,
|
|
168
|
+
ask,
|
|
169
|
+
updatedAt: Date.now(),
|
|
170
|
+
} satisfies ReviewAskCacheEntry);
|
|
171
|
+
}
|
package/src/review/component.ts
CHANGED
|
@@ -41,6 +41,7 @@ import type {
|
|
|
41
41
|
SelectionBounds,
|
|
42
42
|
SplitDiffCell,
|
|
43
43
|
SplitDiffRow,
|
|
44
|
+
WorkspaceCommentSummary,
|
|
44
45
|
} from "./types.ts";
|
|
45
46
|
|
|
46
47
|
type InlineBoxPart = "top" | "body" | "bottom";
|
|
@@ -122,6 +123,9 @@ export class ReviewComponent {
|
|
|
122
123
|
private onExplanationsChanged?: (explanations: Map<string, string>) => void,
|
|
123
124
|
cachedAsk?: PersistedAsk,
|
|
124
125
|
private onAskChanged?: (ask?: PersistedAsk) => void,
|
|
126
|
+
private getWorkspaceCommentSummary?: (
|
|
127
|
+
comments: Map<string, ReviewComment>,
|
|
128
|
+
) => WorkspaceCommentSummary | undefined,
|
|
125
129
|
) {
|
|
126
130
|
const firstCommentable = this.lines.findIndex((line) => line.commentable);
|
|
127
131
|
this.navigation = new ReviewNavigationState(
|
|
@@ -394,11 +398,12 @@ export class ReviewComponent {
|
|
|
394
398
|
render(width: number): string[] {
|
|
395
399
|
const viewportHeight = this.getContentHeight();
|
|
396
400
|
const selectedLine = this.lines[this.selected];
|
|
401
|
+
const workspaceSummary = this.getWorkspaceCommentSummary?.(this.comments);
|
|
397
402
|
const output: string[] = [];
|
|
398
403
|
|
|
399
404
|
output.push(
|
|
400
405
|
this.renderStatusLine(
|
|
401
|
-
this.getHeaderText(selectedLine),
|
|
406
|
+
this.getHeaderText(selectedLine, workspaceSummary),
|
|
402
407
|
"Press h for help",
|
|
403
408
|
width,
|
|
404
409
|
),
|
|
@@ -409,15 +414,21 @@ export class ReviewComponent {
|
|
|
409
414
|
|
|
410
415
|
output.push(
|
|
411
416
|
truncateToWidth(
|
|
412
|
-
this.theme.fg(
|
|
417
|
+
this.theme.fg(
|
|
418
|
+
"muted",
|
|
419
|
+
this.getFooterText(selectedLine, workspaceSummary),
|
|
420
|
+
),
|
|
413
421
|
width,
|
|
414
422
|
),
|
|
415
423
|
);
|
|
416
424
|
return this.helpVisible ? this.renderHelpModal(output, width) : output;
|
|
417
425
|
}
|
|
418
426
|
|
|
419
|
-
private getHeaderText(
|
|
420
|
-
|
|
427
|
+
private getHeaderText(
|
|
428
|
+
selectedLine?: ReviewLine,
|
|
429
|
+
workspaceSummary?: WorkspaceCommentSummary,
|
|
430
|
+
): string {
|
|
431
|
+
const base = `${this.title} • ${this.lines.length} lines • ${this.comments.size} comments${this.formatWorkspaceSummary(workspaceSummary)}`;
|
|
421
432
|
|
|
422
433
|
if (this.editMode) {
|
|
423
434
|
return `${base} • editing ${this.editingCommentKey === GLOBAL_COMMENT_KEY ? "overall comment" : "inline comment"}`;
|
|
@@ -1209,7 +1220,10 @@ export class ReviewComponent {
|
|
|
1209
1220
|
: position;
|
|
1210
1221
|
}
|
|
1211
1222
|
|
|
1212
|
-
private getFooterText(
|
|
1223
|
+
private getFooterText(
|
|
1224
|
+
selectedLine?: ReviewLine,
|
|
1225
|
+
workspaceSummary?: WorkspaceCommentSummary,
|
|
1226
|
+
): string {
|
|
1213
1227
|
if (this.search.mode) {
|
|
1214
1228
|
return `Search: /${this.search.draftQuery} • Enter jump • Esc cancel`;
|
|
1215
1229
|
}
|
|
@@ -1224,7 +1238,28 @@ export class ReviewComponent {
|
|
|
1224
1238
|
const endLine = this.lines[selection.end]!;
|
|
1225
1239
|
return `Selected ${count} lines: ${formatLocation(startLine)} -> ${formatLocation(endLine)}`;
|
|
1226
1240
|
}
|
|
1227
|
-
|
|
1241
|
+
|
|
1242
|
+
const selectedText = `Selected: ${selectedLine ? formatLocation(selectedLine) : "(no selection)"}`;
|
|
1243
|
+
const workspaceText = this.formatWorkspaceSummary(workspaceSummary, false);
|
|
1244
|
+
return workspaceText ? `${selectedText} • ${workspaceText}` : selectedText;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private formatWorkspaceSummary(
|
|
1248
|
+
summary?: WorkspaceCommentSummary,
|
|
1249
|
+
includeVisible = true,
|
|
1250
|
+
): string {
|
|
1251
|
+
if (!summary) return "";
|
|
1252
|
+
|
|
1253
|
+
const parts: string[] = [];
|
|
1254
|
+
if (includeVisible && summary.visible > 0)
|
|
1255
|
+
parts.push(`${summary.visible} visible persisted`);
|
|
1256
|
+
if (summary.hiddenInCurrentFiles > 0) {
|
|
1257
|
+
parts.push(`${summary.hiddenInCurrentFiles} hidden in current files`);
|
|
1258
|
+
}
|
|
1259
|
+
if (summary.elsewhere > 0) parts.push(`${summary.elsewhere} elsewhere`);
|
|
1260
|
+
if (summary.stale > 0) parts.push(`${summary.stale} stale`);
|
|
1261
|
+
if (summary.orphaned > 0) parts.push(`${summary.orphaned} orphaned`);
|
|
1262
|
+
return parts.length > 0 ? ` • ${parts.join(" • ")}` : "";
|
|
1228
1263
|
}
|
|
1229
1264
|
|
|
1230
1265
|
private jumpHunk(direction: 1 | -1): void {
|
|
@@ -1340,7 +1375,7 @@ export class ReviewComponent {
|
|
|
1340
1375
|
const lineNumber =
|
|
1341
1376
|
side === "left" ? line.oldLineNumber : line.newLineNumber;
|
|
1342
1377
|
const prefix = `${commentMark} ${lineNumberCell(lineNumber)} `;
|
|
1343
|
-
let styled = this.renderDiffRowContent(line, prefix);
|
|
1378
|
+
let styled = this.renderDiffRowContent(line, prefix, index);
|
|
1344
1379
|
|
|
1345
1380
|
styled = truncateToWidth(styled, width);
|
|
1346
1381
|
const selection = this.getSelectionBounds();
|
|
@@ -1393,14 +1428,18 @@ export class ReviewComponent {
|
|
|
1393
1428
|
return styled;
|
|
1394
1429
|
}
|
|
1395
1430
|
|
|
1396
|
-
private renderDiffRowContent(
|
|
1431
|
+
private renderDiffRowContent(
|
|
1432
|
+
line: ReviewLine,
|
|
1433
|
+
prefix: string,
|
|
1434
|
+
index: number,
|
|
1435
|
+
): string {
|
|
1397
1436
|
switch (line.kind) {
|
|
1398
1437
|
case "add":
|
|
1399
|
-
return `${this.theme.fg("toolDiffAdded", prefix)}${this.getHighlightedDisplayText(line)}`;
|
|
1438
|
+
return `${this.theme.fg("toolDiffAdded", prefix)}${this.getHighlightedDisplayText(line, index)}`;
|
|
1400
1439
|
case "remove":
|
|
1401
|
-
return `${this.theme.fg("toolDiffRemoved", prefix)}${this.getHighlightedDisplayText(line)}`;
|
|
1440
|
+
return `${this.theme.fg("toolDiffRemoved", prefix)}${this.getHighlightedDisplayText(line, index)}`;
|
|
1402
1441
|
case "context":
|
|
1403
|
-
return `${this.theme.fg("toolDiffContext", prefix)}${this.getHighlightedDisplayText(line)}`;
|
|
1442
|
+
return `${this.theme.fg("toolDiffContext", prefix)}${this.getHighlightedDisplayText(line, index)}`;
|
|
1404
1443
|
case "hunk":
|
|
1405
1444
|
return this.theme.fg("accent", `${prefix}${this.getDisplayText(line)}`);
|
|
1406
1445
|
default:
|
|
@@ -1408,15 +1447,21 @@ export class ReviewComponent {
|
|
|
1408
1447
|
}
|
|
1409
1448
|
}
|
|
1410
1449
|
|
|
1411
|
-
private getHighlightedDisplayText(line: ReviewLine): string {
|
|
1450
|
+
private getHighlightedDisplayText(line: ReviewLine, index: number): string {
|
|
1412
1451
|
const code = this.getDisplayText(line);
|
|
1413
1452
|
if (!code) return code;
|
|
1414
1453
|
|
|
1415
1454
|
const lang = line.filePath ? getLanguageFromPath(line.filePath) : undefined;
|
|
1416
|
-
const cacheKey = `${line.id}\0${lang ?? ""}\0${code}`;
|
|
1455
|
+
const cacheKey = `${line.id}\0${lang ?? ""}\0${this.search.getHighlightCacheKey()}\0${code}`;
|
|
1417
1456
|
const cached = this.highlightedLineCache.get(cacheKey);
|
|
1418
1457
|
if (cached != null) return cached;
|
|
1419
1458
|
|
|
1459
|
+
const searchHighlighted = this.getSearchHighlightedDisplayText(code, index);
|
|
1460
|
+
if (searchHighlighted) {
|
|
1461
|
+
this.highlightedLineCache.set(cacheKey, searchHighlighted);
|
|
1462
|
+
return searchHighlighted;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1420
1465
|
let highlighted = code;
|
|
1421
1466
|
try {
|
|
1422
1467
|
highlighted = highlightCode(code, lang)[0] ?? code;
|
|
@@ -1428,6 +1473,32 @@ export class ReviewComponent {
|
|
|
1428
1473
|
return highlighted;
|
|
1429
1474
|
}
|
|
1430
1475
|
|
|
1476
|
+
private getSearchHighlightedDisplayText(
|
|
1477
|
+
code: string,
|
|
1478
|
+
index: number,
|
|
1479
|
+
): string | undefined {
|
|
1480
|
+
const matches = this.search.getMatchesForLine(index);
|
|
1481
|
+
if (matches.length === 0) return undefined;
|
|
1482
|
+
|
|
1483
|
+
const activeMatch = this.search.getActiveMatch();
|
|
1484
|
+
let result = "";
|
|
1485
|
+
let cursor = 0;
|
|
1486
|
+
for (const match of matches) {
|
|
1487
|
+
result += code.slice(cursor, match.start);
|
|
1488
|
+
const text = code.slice(match.start, match.end);
|
|
1489
|
+
const isActive =
|
|
1490
|
+
activeMatch?.lineIndex === match.lineIndex &&
|
|
1491
|
+
activeMatch.start === match.start &&
|
|
1492
|
+
activeMatch.end === match.end;
|
|
1493
|
+
result += isActive
|
|
1494
|
+
? this.theme.bg("selectedBg", this.theme.fg("warning", text))
|
|
1495
|
+
: this.theme.bg("selectedBg", this.theme.fg("accent", text));
|
|
1496
|
+
cursor = match.end;
|
|
1497
|
+
}
|
|
1498
|
+
result += code.slice(cursor);
|
|
1499
|
+
return result;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1431
1502
|
private renderDiffLine(
|
|
1432
1503
|
line: ReviewLine,
|
|
1433
1504
|
index: number,
|
|
@@ -1439,7 +1510,7 @@ export class ReviewComponent {
|
|
|
1439
1510
|
const commentMark = hasComment ? this.theme.fg("borderAccent", "│") : " ";
|
|
1440
1511
|
const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
|
|
1441
1512
|
const prefix = `${commentMark} ${numbers} `;
|
|
1442
|
-
let styled = this.renderDiffRowContent(line, prefix);
|
|
1513
|
+
let styled = this.renderDiffRowContent(line, prefix, index);
|
|
1443
1514
|
|
|
1444
1515
|
styled = truncateToWidth(styled, width);
|
|
1445
1516
|
const inSelection =
|
package/src/review/prompt.ts
CHANGED
|
@@ -36,16 +36,39 @@ export function formatCommentLocation(comment: ReviewComment): string {
|
|
|
36
36
|
export function buildReviewPrompt(
|
|
37
37
|
comments: ReviewComment[],
|
|
38
38
|
promptLabel: string,
|
|
39
|
+
): string {
|
|
40
|
+
return buildCommentPrompt(
|
|
41
|
+
comments,
|
|
42
|
+
`Address this local code review feedback for ${promptLabel}.`,
|
|
43
|
+
"diff",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildViewReviewPrompt(
|
|
48
|
+
comments: ReviewComment[],
|
|
49
|
+
promptLabel: string,
|
|
50
|
+
): string {
|
|
51
|
+
return buildCommentPrompt(
|
|
52
|
+
comments,
|
|
53
|
+
`Address this code review feedback for ${promptLabel}.`,
|
|
54
|
+
"ts",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildCommentPrompt(
|
|
59
|
+
comments: ReviewComment[],
|
|
60
|
+
intro: string,
|
|
61
|
+
codeFenceLanguage: string,
|
|
39
62
|
): string {
|
|
40
63
|
const body = comments
|
|
41
64
|
.map((comment) => {
|
|
42
65
|
const location = formatCommentLocation(comment);
|
|
43
66
|
const excerpt = comment.lineText.trim()
|
|
44
|
-
? `\n Excerpt:\n\n
|
|
67
|
+
? `\n Excerpt:\n\n\`\`\`${codeFenceLanguage}\n${comment.lineText}\n\`\`\``
|
|
45
68
|
: "";
|
|
46
69
|
return `- \`${location}\` — ${comment.text}${excerpt}`;
|
|
47
70
|
})
|
|
48
71
|
.join("\n");
|
|
49
72
|
|
|
50
|
-
return
|
|
73
|
+
return `${intro}\n\n## Review comments\n${body}\n\nPlease apply the feedback and summarize what changed.`;
|
|
51
74
|
}
|
package/src/review/search.ts
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { ReviewLine } from "./types.ts";
|
|
3
3
|
|
|
4
|
+
function getSearchableLineText(line: ReviewLine): string {
|
|
5
|
+
return (
|
|
6
|
+
line.kind === "add" || line.kind === "remove" || line.kind === "context"
|
|
7
|
+
? line.text.slice(1)
|
|
8
|
+
: line.text
|
|
9
|
+
).toLocaleLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
export type SearchInputResult = {
|
|
5
13
|
selected?: number;
|
|
6
14
|
};
|
|
7
15
|
|
|
16
|
+
export type SearchMatch = {
|
|
17
|
+
lineIndex: number;
|
|
18
|
+
start: number;
|
|
19
|
+
end: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
8
22
|
export class ReviewSearchState {
|
|
9
23
|
mode = false;
|
|
10
|
-
query = "";
|
|
11
24
|
draftQuery = "";
|
|
12
25
|
message = "";
|
|
26
|
+
private _query = "";
|
|
27
|
+
private activeMatchIndex = -1;
|
|
13
28
|
|
|
14
29
|
constructor(private readonly lines: ReviewLine[]) {}
|
|
15
30
|
|
|
31
|
+
get query(): string {
|
|
32
|
+
return this._query;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
set query(value: string) {
|
|
36
|
+
this._query = value;
|
|
37
|
+
this.activeMatchIndex = -1;
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
start(): void {
|
|
17
41
|
this.mode = true;
|
|
18
42
|
this.draftQuery = this.query;
|
|
@@ -63,46 +87,85 @@ export class ReviewSearchState {
|
|
|
63
87
|
const query = this.query.trim();
|
|
64
88
|
if (!query) return {};
|
|
65
89
|
|
|
66
|
-
const matches = this.
|
|
90
|
+
const matches = this.getMatches(query);
|
|
67
91
|
if (matches.length === 0) {
|
|
92
|
+
this.activeMatchIndex = -1;
|
|
68
93
|
this.message = `No matches for /${query}`;
|
|
69
94
|
return {};
|
|
70
95
|
}
|
|
71
96
|
|
|
72
97
|
this.message = "";
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
98
|
+
let nextIndex: number;
|
|
99
|
+
if (this.activeMatchIndex >= 0 && this.activeMatchIndex < matches.length) {
|
|
100
|
+
nextIndex =
|
|
101
|
+
(this.activeMatchIndex + direction + matches.length) % matches.length;
|
|
102
|
+
} else {
|
|
103
|
+
if (direction === 1) {
|
|
104
|
+
nextIndex = matches.findIndex((match) => match.lineIndex >= selected);
|
|
105
|
+
} else {
|
|
106
|
+
nextIndex = -1;
|
|
107
|
+
for (let index = matches.length - 1; index >= 0; index--) {
|
|
108
|
+
if (matches[index]!.lineIndex <= selected) {
|
|
109
|
+
nextIndex = index;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (nextIndex < 0) nextIndex = direction === 1 ? 0 : matches.length - 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.activeMatchIndex = nextIndex;
|
|
118
|
+
return { selected: matches[nextIndex]!.lineIndex };
|
|
82
119
|
}
|
|
83
120
|
|
|
84
|
-
getStatusText(
|
|
121
|
+
getStatusText(_selected: number): string {
|
|
85
122
|
if (this.message) return this.message;
|
|
86
123
|
if (!this.query) return "";
|
|
87
124
|
|
|
88
|
-
const matches = this.
|
|
125
|
+
const matches = this.getMatches(this.query);
|
|
89
126
|
if (matches.length === 0) return `No matches for /${this.query}`;
|
|
90
127
|
|
|
91
|
-
const current = matches.findIndex((index) => index === selected);
|
|
92
128
|
const position =
|
|
93
|
-
|
|
94
|
-
? `${
|
|
129
|
+
this.activeMatchIndex >= 0 && this.activeMatchIndex < matches.length
|
|
130
|
+
? `${this.activeMatchIndex + 1}/${matches.length}`
|
|
95
131
|
: `${matches.length} matches`;
|
|
96
132
|
return `Search /${this.query} • ${position} • n next • N previous • Esc clear search`;
|
|
97
133
|
}
|
|
98
134
|
|
|
99
|
-
|
|
135
|
+
getMatchesForLine(lineIndex: number): SearchMatch[] {
|
|
136
|
+
if (!this.query.trim()) return [];
|
|
137
|
+
return this.getMatches(this.query).filter(
|
|
138
|
+
(match) => match.lineIndex === lineIndex,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
getActiveMatch(): SearchMatch | undefined {
|
|
143
|
+
if (!this.query.trim()) return undefined;
|
|
144
|
+
const matches = this.getMatches(this.query);
|
|
145
|
+
return this.activeMatchIndex >= 0 && this.activeMatchIndex < matches.length
|
|
146
|
+
? matches[this.activeMatchIndex]
|
|
147
|
+
: undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getHighlightCacheKey(): string {
|
|
151
|
+
const active = this.getActiveMatch();
|
|
152
|
+
return active
|
|
153
|
+
? `${this.query}\0${active.lineIndex}:${active.start}-${active.end}`
|
|
154
|
+
: this.query;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private getMatches(query: string): SearchMatch[] {
|
|
100
158
|
const needle = query.toLocaleLowerCase();
|
|
101
159
|
if (!needle) return [];
|
|
102
160
|
|
|
103
|
-
const matches:
|
|
104
|
-
this.lines.forEach((line,
|
|
105
|
-
|
|
161
|
+
const matches: SearchMatch[] = [];
|
|
162
|
+
this.lines.forEach((line, lineIndex) => {
|
|
163
|
+
const haystack = getSearchableLineText(line);
|
|
164
|
+
let start = haystack.indexOf(needle);
|
|
165
|
+
while (start >= 0) {
|
|
166
|
+
matches.push({ lineIndex, start, end: start + needle.length });
|
|
167
|
+
start = haystack.indexOf(needle, start + needle.length);
|
|
168
|
+
}
|
|
106
169
|
});
|
|
107
170
|
return matches;
|
|
108
171
|
}
|
package/src/review/types.ts
CHANGED
|
@@ -48,6 +48,14 @@ export type PersistedAsk = {
|
|
|
48
48
|
text: string;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
export type WorkspaceCommentSummary = {
|
|
52
|
+
visible: number;
|
|
53
|
+
hiddenInCurrentFiles: number;
|
|
54
|
+
elsewhere: number;
|
|
55
|
+
stale: number;
|
|
56
|
+
orphaned: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
51
59
|
export type DiffRenderMode = "unified" | "split";
|
|
52
60
|
|
|
53
61
|
export type SplitDiffCell = {
|