pi-diff-review 0.1.20 → 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 CHANGED
@@ -78,7 +78,7 @@ Open one or more files or folders with `/view`:
78
78
  - `t` toggles inline comments/explanations
79
79
  - `v` toggles the diff between unified and side-by-side split rendering
80
80
  - `?` toggles an AI-generated explanation for the current hunk
81
- - `/` searches diff lines; `n/N` moves between matches while a search is active
81
+ - `/` searches visible diff text, highlights matches, and `n/N` moves between them while a search is active
82
82
  - `J/K` to extend a highlighted selection into a comment range
83
83
  - `esc` clears the active selection, or exits review when no selection is active
84
84
  - `n/p` to jump hunks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1375,7 +1375,7 @@ export class ReviewComponent {
1375
1375
  const lineNumber =
1376
1376
  side === "left" ? line.oldLineNumber : line.newLineNumber;
1377
1377
  const prefix = `${commentMark} ${lineNumberCell(lineNumber)} `;
1378
- let styled = this.renderDiffRowContent(line, prefix);
1378
+ let styled = this.renderDiffRowContent(line, prefix, index);
1379
1379
 
1380
1380
  styled = truncateToWidth(styled, width);
1381
1381
  const selection = this.getSelectionBounds();
@@ -1428,14 +1428,18 @@ export class ReviewComponent {
1428
1428
  return styled;
1429
1429
  }
1430
1430
 
1431
- private renderDiffRowContent(line: ReviewLine, prefix: string): string {
1431
+ private renderDiffRowContent(
1432
+ line: ReviewLine,
1433
+ prefix: string,
1434
+ index: number,
1435
+ ): string {
1432
1436
  switch (line.kind) {
1433
1437
  case "add":
1434
- return `${this.theme.fg("toolDiffAdded", prefix)}${this.getHighlightedDisplayText(line)}`;
1438
+ return `${this.theme.fg("toolDiffAdded", prefix)}${this.getHighlightedDisplayText(line, index)}`;
1435
1439
  case "remove":
1436
- return `${this.theme.fg("toolDiffRemoved", prefix)}${this.getHighlightedDisplayText(line)}`;
1440
+ return `${this.theme.fg("toolDiffRemoved", prefix)}${this.getHighlightedDisplayText(line, index)}`;
1437
1441
  case "context":
1438
- return `${this.theme.fg("toolDiffContext", prefix)}${this.getHighlightedDisplayText(line)}`;
1442
+ return `${this.theme.fg("toolDiffContext", prefix)}${this.getHighlightedDisplayText(line, index)}`;
1439
1443
  case "hunk":
1440
1444
  return this.theme.fg("accent", `${prefix}${this.getDisplayText(line)}`);
1441
1445
  default:
@@ -1443,15 +1447,21 @@ export class ReviewComponent {
1443
1447
  }
1444
1448
  }
1445
1449
 
1446
- private getHighlightedDisplayText(line: ReviewLine): string {
1450
+ private getHighlightedDisplayText(line: ReviewLine, index: number): string {
1447
1451
  const code = this.getDisplayText(line);
1448
1452
  if (!code) return code;
1449
1453
 
1450
1454
  const lang = line.filePath ? getLanguageFromPath(line.filePath) : undefined;
1451
- const cacheKey = `${line.id}\0${lang ?? ""}\0${code}`;
1455
+ const cacheKey = `${line.id}\0${lang ?? ""}\0${this.search.getHighlightCacheKey()}\0${code}`;
1452
1456
  const cached = this.highlightedLineCache.get(cacheKey);
1453
1457
  if (cached != null) return cached;
1454
1458
 
1459
+ const searchHighlighted = this.getSearchHighlightedDisplayText(code, index);
1460
+ if (searchHighlighted) {
1461
+ this.highlightedLineCache.set(cacheKey, searchHighlighted);
1462
+ return searchHighlighted;
1463
+ }
1464
+
1455
1465
  let highlighted = code;
1456
1466
  try {
1457
1467
  highlighted = highlightCode(code, lang)[0] ?? code;
@@ -1463,6 +1473,32 @@ export class ReviewComponent {
1463
1473
  return highlighted;
1464
1474
  }
1465
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
+
1466
1502
  private renderDiffLine(
1467
1503
  line: ReviewLine,
1468
1504
  index: number,
@@ -1474,7 +1510,7 @@ export class ReviewComponent {
1474
1510
  const commentMark = hasComment ? this.theme.fg("borderAccent", "│") : " ";
1475
1511
  const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
1476
1512
  const prefix = `${commentMark} ${numbers} `;
1477
- let styled = this.renderDiffRowContent(line, prefix);
1513
+ let styled = this.renderDiffRowContent(line, prefix, index);
1478
1514
 
1479
1515
  styled = truncateToWidth(styled, width);
1480
1516
  const inSelection =
@@ -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.getMatchIndexes(query);
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
- 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
- };
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(selected: number): string {
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.getMatchIndexes(this.query);
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
- current >= 0
94
- ? `${current + 1}/${matches.length}`
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
- private getMatchIndexes(query: string): number[] {
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: number[] = [];
104
- this.lines.forEach((line, index) => {
105
- if (line.text.toLocaleLowerCase().includes(needle)) matches.push(index);
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
  }