git-reviewer 2.16.0 → 2.17.0

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
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-500-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-509-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  **English** · [简体中文](docs/README.zh-CN.md) · [日本語](docs/README.ja.md)
@@ -274,6 +274,32 @@ so the app shows you rather than telling you. **Reviewing, in four moves** opens
274
274
  by itself the first time you review anything, and lives behind the `?` in the
275
275
  header after that. Each move is a small animation of the gesture itself.
276
276
 
277
+ ### Search across the change
278
+
279
+ <kbd>Ctrl</kbd>/<kbd>⌘</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd> searches every changed file at once, the way
280
+ an IDE's find navigator searches a project. Each result says which side of
281
+ the change it is on: added, deleted, unchanged code in a changed file, or,
282
+ with **Whole repository**, code elsewhere that the change did not touch.
283
+ That answers "this function is new: where is it called, and where was it
284
+ called before?" without leaving the review. Filter to **Deleted** for the
285
+ second half.
286
+
287
+ - **Match** contains, whole word, starts with, ends with, or a regular
288
+ expression; **Aa** matches case.
289
+ - **Refs** is a references search. Select a name in the diff and press the
290
+ shortcut. Results are split into the definition and the uses that were
291
+ added, removed and left unchanged. If the definition changed while some
292
+ uses didn't, it says so, since that is often where a change breaks
293
+ something.
294
+ - <kbd>↑</kbd>/<kbd>↓</kbd> and <kbd>Enter</kbd> go to a result,
295
+ <kbd>⌘</kbd>/<kbd>Ctrl</kbd>+<kbd>G</kbd> steps through them, marks on the
296
+ diff's edge show the matches in the open file, and **Comment** starts a
297
+ comment on that line.
298
+
299
+ Repository search runs `git grep` against the exact tree under review: the
300
+ working tree, the index, or the head of a comparison. Its regular
301
+ expressions are git's extended dialect.
302
+
277
303
  ## What it writes
278
304
 
279
305
  Two files, both plain text you can read without this app, both in a per-user
@@ -454,6 +480,7 @@ The UI is a client of this; nothing is hidden from you.
454
480
  | `POST /api/save-comments` | Persist the current comments. Send back the `lastUpdated` you loaded and the save is conditional: if the file has moved on since, it answers `409` with the current `comments` and `lastUpdated` instead of overwriting them |
455
481
  | `GET /api/load-comments/:repoId` | Restore saved comments, with `lastUpdated`, the version token a conditional save sends back |
456
482
  | `POST /api/submit-review` | Render the saved comments to a review file |
483
+ | `GET /api/search/:repoId` | Search the review: `q`, `mode` (contains, word, prefix, suffix, regex), `case=1`, `scope` (changes, files, repo) and `symbol=1` for a references search. Each result has its `side` of the change and the `diffIndex` of its line in the file's diff |
457
484
  | `GET /api/events/:repoId` | Server-sent events for an open review: `comments` whenever the saved comments change, whoever changed them; `agents` as agents connect, leave or start on a thread; `partial` with an answer still being written |
458
485
  | `GET /api/agent/sessions/:repoId` | The Claude Code sessions that ran in this repository, newest first |
459
486
  | `POST /api/agent/connect` | `{repoId, sessionId, fork}`: have that session answer the review's comments in the background. `fork` defaults to `true`, a copy of the session |
@@ -528,7 +555,7 @@ rather than a public issue.
528
555
 
529
556
  ```bash
530
557
  npm install # 3 dependencies, no build step, ~6MB
531
- npm test # 500 tests
558
+ npm test # 509 tests
532
559
  npm run test:watch
533
560
  npm run test:coverage
534
561
  ```
package/lib/search.js ADDED
@@ -0,0 +1,319 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+
5
+ /**
6
+ * Finding text across a review: every changed file at once, the way an IDE's
7
+ * find navigator searches a project rather than the open file.
8
+ *
9
+ * The question it is built for is the one a reviewer cannot answer from one
10
+ * file: "this function is new here -- where is it called, and where was it
11
+ * called before this change?" So every result says which side of the change
12
+ * it is on: a line this change added, one it deleted, unchanged code inside a
13
+ * changed file, or code in the rest of the repository the change did not
14
+ * touch. A references search goes one step further and tells definitions from
15
+ * uses, and says when a definition changed but its callers did not.
16
+ */
17
+
18
+ // What counts as part of an identifier, for whole-word matching. `$` is in
19
+ // it because JavaScript identifiers use it.
20
+ const IDENT = 'A-Za-z0-9_$';
21
+ const MODES = ['contains', 'word', 'prefix', 'suffix', 'regex'];
22
+ const SCOPES = ['changes', 'files', 'repo'];
23
+
24
+ const MAX_RESULTS = 2000;
25
+ const MAX_PER_FILE = 300;
26
+ const MAX_LINE = 2000;
27
+ const MAX_RANGES = 20;
28
+
29
+ /** A search that cannot run as asked; the message is for the person who typed it. */
30
+ class SearchError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = 'SearchError';
34
+ }
35
+ }
36
+
37
+ const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+
39
+ /**
40
+ * The matcher for a query, as a global regular expression.
41
+ *
42
+ * @param {string} query
43
+ * @param {object} [options]
44
+ * @param {string} [options.mode] contains, word, prefix, suffix or regex
45
+ * @param {boolean} [options.caseSensitive]
46
+ * @returns {RegExp}
47
+ * @throws {SearchError}
48
+ */
49
+ function buildMatcher(query, { mode = 'contains', caseSensitive = false } = {}) {
50
+ if (typeof query !== 'string' || query.trim() === '') throw new SearchError('Type something to search for.');
51
+ if (query.length > 500) throw new SearchError('That search is longer than 500 characters.');
52
+ if (!MODES.includes(mode)) throw new SearchError(`Unknown match mode "${mode}".`);
53
+
54
+ const literal = escapeRegExp(query);
55
+ const source = {
56
+ contains: literal,
57
+ word: `(?<![${IDENT}])${literal}(?![${IDENT}])`,
58
+ prefix: `(?<![${IDENT}])${literal}`,
59
+ suffix: `${literal}(?![${IDENT}])`,
60
+ regex: query
61
+ }[mode];
62
+
63
+ try {
64
+ return new RegExp(source, caseSensitive ? 'g' : 'gi');
65
+ } catch (error) {
66
+ throw new SearchError(`That is not a valid regular expression: ${error.message.replace(/^Invalid regular expression: /, '')}`);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Where a matcher matches on one line, as [start, end) pairs, so the page can
72
+ * mark exactly what matched. Very long lines -- minified bundles -- are
73
+ * searched only as far as MAX_LINE characters.
74
+ *
75
+ * @param {string} text
76
+ * @param {RegExp} matcher a global RegExp from buildMatcher
77
+ * @returns {Array<[number, number]>}
78
+ */
79
+ function matchRanges(text, matcher) {
80
+ const line = text.length > MAX_LINE ? text.slice(0, MAX_LINE) : text;
81
+ const ranges = [];
82
+ matcher.lastIndex = 0;
83
+ let match;
84
+ while ((match = matcher.exec(line)) !== null) {
85
+ if (match[0].length === 0) {
86
+ matcher.lastIndex += 1; // an empty regex match would never advance
87
+ continue;
88
+ }
89
+ ranges.push([match.index, match.index + match[0].length]);
90
+ if (ranges.length >= MAX_RANGES) break;
91
+ }
92
+ return ranges;
93
+ }
94
+
95
+ /**
96
+ * Whether a line defines `name`, as far as one line can show it, in the
97
+ * languages people most often review: a function, class, method, type or
98
+ * variable declared under that name. A heuristic, and labelled as one in the
99
+ * page: a references search sorts by it, never hides by it.
100
+ *
101
+ * @param {string} text
102
+ * @param {string} name
103
+ * @returns {boolean}
104
+ */
105
+ function definesName(text, name) {
106
+ const n = escapeRegExp(name);
107
+ const end = `(?![${IDENT}])`;
108
+ const declared = new RegExp(
109
+ `(?:^|[^${IDENT}.])(?:function\\*?|def|class|interface|type|struct|enum|trait|fn|func|const|let|var|val|module)\\s+${n}${end}`
110
+ );
111
+ const assigned = new RegExp(
112
+ `(?:^|[\\s{,;(])${n}\\s*[:=]\\s*(?:async\\s+)?(?:function\\b|\\([^)]*\\)\\s*=>|[A-Za-z_$][\\w$]*\\s*=>)`
113
+ );
114
+ const method = new RegExp(
115
+ `^\\s*(?:(?:public|private|protected|static|async|override|export|default|get|set)\\s+)*\\*?${n}\\s*\\([^)]*\\)\\s*(?::\\s*[^{]+)?\\{\\s*$`
116
+ );
117
+ return declared.test(text) || assigned.test(text) || method.test(text);
118
+ }
119
+
120
+ /**
121
+ * The side of the change a diff line is on.
122
+ *
123
+ * @param {'add'|'delete'|'unchanged'} type
124
+ * @returns {'added'|'deleted'|'context'}
125
+ */
126
+ function sideOf(type) {
127
+ return type === 'add' ? 'added' : type === 'delete' ? 'deleted' : 'context';
128
+ }
129
+
130
+ /**
131
+ * Search the changed files of a review.
132
+ *
133
+ * @param {Array<{path: string, lines: Array<{type: string, oldLine: number|null, newLine: number|null, content: string}>}>} files
134
+ * each changed file's diff, as the page draws it: every line of the file,
135
+ * with what was added and deleted marked
136
+ * @param {RegExp} matcher
137
+ * @param {object} [options]
138
+ * @param {boolean} [options.changedOnly] only lines the change added or deleted
139
+ * @returns {{results: object[], truncated: boolean}}
140
+ */
141
+ function searchDiffs(files, matcher, { changedOnly = false } = {}) {
142
+ const results = [];
143
+ let truncated = false;
144
+
145
+ for (const file of files) {
146
+ let inFile = 0;
147
+ for (let index = 0; index < file.lines.length; index++) {
148
+ const line = file.lines[index];
149
+ if (changedOnly && line.type !== 'add' && line.type !== 'delete') continue;
150
+ const ranges = matchRanges(line.content, matcher);
151
+ if (ranges.length === 0) continue;
152
+
153
+ if (results.length >= MAX_RESULTS || inFile >= MAX_PER_FILE) {
154
+ truncated = true;
155
+ if (results.length >= MAX_RESULTS) return { results, truncated };
156
+ break;
157
+ }
158
+ results.push({
159
+ file: file.path,
160
+ side: sideOf(line.type),
161
+ line: line.newLine ?? line.oldLine,
162
+ oldLine: line.oldLine,
163
+ newLine: line.newLine,
164
+ diffIndex: index,
165
+ text: line.content.slice(0, MAX_LINE),
166
+ ranges
167
+ });
168
+ inFile += 1;
169
+ }
170
+ }
171
+
172
+ return { results, truncated };
173
+ }
174
+
175
+ /**
176
+ * The git grep arguments for a query, as close to the matcher as git's own
177
+ * dialect allows. Whatever git finds is filtered through the real matcher
178
+ * afterwards, so git only has to be no narrower than it.
179
+ *
180
+ * @param {string} query
181
+ * @param {{mode: string, caseSensitive: boolean}} options
182
+ * @returns {string[]}
183
+ */
184
+ function grepPattern(query, { mode, caseSensitive }) {
185
+ const literal = query.replace(/[.^$*+?()[\]{}|\\]/g, '\\$&');
186
+ const args = caseSensitive ? [] : ['-i'];
187
+ if (mode === 'contains' || mode === 'word' || mode === 'prefix' || mode === 'suffix') {
188
+ // A fixed-string search for the text is never narrower than any of
189
+ // these, and git does it fastest; the boundaries are checked in JS.
190
+ return [...args, '-F', '-e', query];
191
+ }
192
+ return [...args, '-E', '-e', mode === 'regex' ? query : literal];
193
+ }
194
+
195
+ /**
196
+ * Lines matching the query in the repository's files, on the reviewed side.
197
+ *
198
+ * Runs `git grep` against the exact tree under review -- the working tree, the
199
+ * index, or the head commit of a comparison -- and stops reading once it has
200
+ * enough, so a match in every file of a large repository costs the first
201
+ * MAX_RESULTS lines and no more.
202
+ *
203
+ * @param {object} options
204
+ * @param {string} options.repoPath
205
+ * @param {{mode: string, range: {head: string}|null}} options.session
206
+ * @param {string} options.query
207
+ * @param {{mode: string, caseSensitive: boolean}} options.match
208
+ * @param {RegExp} options.matcher
209
+ * @param {Set<string>} options.exclude paths already searched through their diff
210
+ * @param {number} [options.limit]
211
+ * @returns {Promise<{results: object[], truncated: boolean}>}
212
+ */
213
+ function grepRepository({ repoPath, session, query, match, matcher, exclude, limit = MAX_RESULTS }) {
214
+ const where = session.mode === 'staged' ? ['--cached']
215
+ : session.range ? [session.range.head]
216
+ : [];
217
+ const rev = session.range && session.mode !== 'staged' ? session.range.head : null;
218
+ const args = ['grep', '-z', '-n', '-I', '--no-color', ...grepPattern(query, match), ...where, '--'];
219
+
220
+ return new Promise((resolve, reject) => {
221
+ const child = spawn('git', args, { cwd: repoPath, windowsHide: true });
222
+ const results = [];
223
+ const perFile = new Map();
224
+ let buffered = '';
225
+ let truncated = false;
226
+ let done = false;
227
+
228
+ const finish = () => {
229
+ if (done) return;
230
+ done = true;
231
+ resolve({ results, truncated });
232
+ };
233
+
234
+ child.stdout.setEncoding('utf-8');
235
+ child.stdout.on('data', chunk => {
236
+ if (done) return;
237
+ buffered += chunk;
238
+ let newline;
239
+ while ((newline = buffered.indexOf('\n')) >= 0) {
240
+ const record = buffered.slice(0, newline);
241
+ buffered = buffered.slice(newline + 1);
242
+ const [location, lineNumber, ...rest] = record.split('\0');
243
+ if (lineNumber === undefined) continue;
244
+ const file = rev && location.startsWith(`${rev}:`) ? location.slice(rev.length + 1) : location;
245
+ if (exclude.has(file)) continue;
246
+ const text = rest.join('\0');
247
+ const ranges = matchRanges(text, matcher);
248
+ if (ranges.length === 0) continue;
249
+
250
+ const count = perFile.get(file) ?? 0;
251
+ if (count >= MAX_PER_FILE) {
252
+ truncated = true;
253
+ continue;
254
+ }
255
+ perFile.set(file, count + 1);
256
+ results.push({
257
+ file,
258
+ side: 'untouched',
259
+ line: Number(lineNumber),
260
+ oldLine: null,
261
+ newLine: Number(lineNumber),
262
+ diffIndex: null,
263
+ text: text.slice(0, MAX_LINE),
264
+ ranges
265
+ });
266
+ if (results.length >= limit) {
267
+ truncated = true;
268
+ child.kill();
269
+ finish();
270
+ return;
271
+ }
272
+ }
273
+ });
274
+ child.on('error', error => {
275
+ if (!done) {
276
+ done = true;
277
+ reject(error);
278
+ }
279
+ });
280
+ // git grep exits 1 when nothing matched, which is an answer, not a failure.
281
+ child.on('close', finish);
282
+ });
283
+ }
284
+
285
+ /**
286
+ * What a references search found, in the terms a reviewer asks it.
287
+ *
288
+ * @param {object[]} results with `role` set
289
+ * @returns {{definitions: number, added: number, removed: number, unchanged: number, warning: string|null}}
290
+ */
291
+ function summarizeReferences(results) {
292
+ const uses = results.filter(result => result.role === 'reference');
293
+ const definitions = results.filter(result => result.role === 'definition');
294
+ const summary = {
295
+ definitions: definitions.length,
296
+ added: uses.filter(result => result.side === 'added').length,
297
+ removed: uses.filter(result => result.side === 'deleted').length,
298
+ unchanged: uses.filter(result => result.side === 'context' || result.side === 'untouched').length,
299
+ warning: null
300
+ };
301
+ const definitionChanged = definitions.some(result => result.side === 'added' || result.side === 'deleted');
302
+ if (definitionChanged && summary.unchanged > 0) {
303
+ summary.warning = `The definition changed, and ${summary.unchanged} ${summary.unchanged === 1 ? 'use was' : 'uses were'} left as they were. Check that they still fit.`;
304
+ }
305
+ return summary;
306
+ }
307
+
308
+ module.exports = {
309
+ buildMatcher,
310
+ matchRanges,
311
+ definesName,
312
+ searchDiffs,
313
+ grepRepository,
314
+ summarizeReferences,
315
+ SearchError,
316
+ MODES,
317
+ SCOPES,
318
+ MAX_RESULTS
319
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "Review your coding agent's changes like a pull request — locally, with inline comments and threaded follow-ups, then hand the marked-up review back to the agent",
5
5
  "author": "Dheeraj Jha",
6
6
  "license": "MIT",
package/public/app.js CHANGED
@@ -1169,6 +1169,8 @@ async function loadRepo(scope = {}) {
1169
1169
  // From here on, comments written anywhere else -- another tab, an agent
1170
1170
  // answering in a thread -- arrive as they are written.
1171
1171
  openReviewEvents(currentRepoId);
1172
+ document.getElementById('searchButton')?.classList.remove('hidden');
1173
+ if (searchState.open) runSearch();
1172
1174
 
1173
1175
  // Open something. A loaded repository used to sit behind an empty pane
1174
1176
  // until a file was clicked, which reads as a failure rather than as a
@@ -1524,6 +1526,7 @@ function displayCode(filePath, diffLines, binary) {
1524
1526
 
1525
1527
  codeViewer.innerHTML = html;
1526
1528
  codeSection.classList.remove('hidden');
1529
+ renderSearchMinimap();
1527
1530
 
1528
1531
  // Add text selection handler
1529
1532
  codeViewer.removeEventListener('mousedown', trackShiftKeyDown);
@@ -2219,6 +2222,8 @@ async function cleanupRepo() {
2219
2222
  // Reset application
2220
2223
  function resetApp() {
2221
2224
  closeReviewEvents();
2225
+ closeSearch();
2226
+ document.getElementById('searchButton')?.classList.add('hidden');
2222
2227
  currentRepoId = null;
2223
2228
  currentFiles = [];
2224
2229
  currentFile = null;
@@ -2887,9 +2892,17 @@ function showPartialAnswer(key) {
2887
2892
  else box.querySelector('.comment-text')?.insertAdjacentHTML('afterend', html);
2888
2893
  }
2889
2894
 
2895
+ // What the threads last showed about agents, so a report that changes
2896
+ // nothing a reader can see -- the first one on connecting, say -- does not
2897
+ // redraw the code (and throw away a scroll, a flash or a selection).
2898
+ let pendingSignature = '';
2899
+
2890
2900
  /** Redraw every thread's pending state after who-is-answering-what changed. */
2891
2901
  function refreshPendingAnswers() {
2892
2902
  renderAgentButton();
2903
+ const signature = JSON.stringify([agentConnected(), [...threadsBeingAnswered()].sort()]);
2904
+ if (signature === pendingSignature) return;
2905
+ pendingSignature = signature;
2893
2906
  updateCommentsSidebar();
2894
2907
  commentsPaneStale = true;
2895
2908
  redrawCommentsPane();
@@ -3099,6 +3112,371 @@ async function copyConnectCommand(index) {
3099
3112
  }
3100
3113
  }
3101
3114
 
3115
+ /*
3116
+ * Search across the change.
3117
+ *
3118
+ * Every changed file at once, and with "Whole repository" the code around
3119
+ * the change too, so "this function is new -- where is it called, and where
3120
+ * was it called before?" has an answer without leaving the review. Each
3121
+ * result says which side of the change it is on; a references search also
3122
+ * tells the definition from the uses. The server does the searching (see
3123
+ * lib/search.js); this is the find navigator around it.
3124
+ */
3125
+
3126
+ const searchState = {
3127
+ open: false,
3128
+ results: [],
3129
+ summary: null,
3130
+ side: 'all',
3131
+ selected: -1,
3132
+ collapsed: new Set(),
3133
+ previews: new Map(),
3134
+ timer: null,
3135
+ controller: null,
3136
+ query: ''
3137
+ };
3138
+
3139
+ const SEARCH_HISTORY_KEY = 'reviewer.searchHistory';
3140
+
3141
+ function searchHistory() {
3142
+ try {
3143
+ const stored = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]');
3144
+ return Array.isArray(stored) ? stored.filter(item => typeof item === 'string').slice(0, 10) : [];
3145
+ } catch {
3146
+ return [];
3147
+ }
3148
+ }
3149
+
3150
+ function rememberSearch(query) {
3151
+ const history = [query, ...searchHistory().filter(item => item !== query)].slice(0, 10);
3152
+ try {
3153
+ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
3154
+ } catch {
3155
+ /* a private window: history is a convenience */
3156
+ }
3157
+ renderSearchHistory();
3158
+ }
3159
+
3160
+ function renderSearchHistory() {
3161
+ const list = document.getElementById('searchHistory');
3162
+ if (!list) return;
3163
+ list.replaceChildren(...searchHistory().map(item => Object.assign(document.createElement('option'), { value: item })));
3164
+ }
3165
+
3166
+ /**
3167
+ * Open the search, taking the sidebar's place. With code selected in the
3168
+ * diff, the selection becomes the query and a references search runs for
3169
+ * it: select a function's name, press Ctrl+Shift+F, see where it is used.
3170
+ */
3171
+ function openSearch(prefill = null) {
3172
+ if (!currentRepoId) return;
3173
+ const sidebar = document.getElementById('sidebar');
3174
+ document.getElementById('searchPanel').classList.remove('hidden');
3175
+ sidebar.classList.add('searching');
3176
+ searchState.open = true;
3177
+ renderSearchHistory();
3178
+
3179
+ const input = document.getElementById('searchInput');
3180
+ if (prefill) {
3181
+ input.value = prefill;
3182
+ const identifier = /^[A-Za-z_$][\w$]*$/.test(prefill);
3183
+ document.getElementById('searchRefs').setAttribute('aria-pressed', identifier ? 'true' : 'false');
3184
+ runSearch();
3185
+ }
3186
+ input.focus();
3187
+ input.select();
3188
+ }
3189
+
3190
+ function closeSearch() {
3191
+ document.getElementById('searchPanel').classList.add('hidden');
3192
+ document.getElementById('sidebar').classList.remove('searching');
3193
+ searchState.open = false;
3194
+ renderSearchMinimap();
3195
+ }
3196
+
3197
+ function scheduleSearch() {
3198
+ clearTimeout(searchState.timer);
3199
+ searchState.timer = setTimeout(runSearch, 160);
3200
+ }
3201
+
3202
+ /** Ask the server, abandoning whatever search this one replaces. */
3203
+ async function runSearch() {
3204
+ const query = document.getElementById('searchInput').value;
3205
+ const summary = document.getElementById('searchSummary');
3206
+ searchState.controller?.abort();
3207
+ if (!currentRepoId || query.trim() === '') {
3208
+ searchState.results = [];
3209
+ searchState.summary = null;
3210
+ searchState.query = '';
3211
+ summary.textContent = '';
3212
+ renderSearchResults();
3213
+ return;
3214
+ }
3215
+
3216
+ const symbol = document.getElementById('searchRefs').getAttribute('aria-pressed') === 'true';
3217
+ const params = new URLSearchParams({
3218
+ q: query,
3219
+ mode: document.getElementById('searchMode').value,
3220
+ scope: document.getElementById('searchScope').value,
3221
+ case: document.getElementById('searchCase').getAttribute('aria-pressed') === 'true' ? '1' : '0',
3222
+ ...(symbol ? { symbol: '1' } : {})
3223
+ });
3224
+ const controller = new AbortController();
3225
+ searchState.controller = controller;
3226
+ summary.textContent = 'Searching…';
3227
+
3228
+ try {
3229
+ const response = await fetch(`${API_BASE}/search/${currentRepoId}?${params}`, { signal: controller.signal });
3230
+ const data = await response.json();
3231
+ if (controller.signal.aborted) return;
3232
+ if (!response.ok) {
3233
+ summary.replaceChildren(Object.assign(document.createElement('span'), { className: 'search-error', textContent: data.error || 'Search failed.' }));
3234
+ searchState.results = [];
3235
+ renderSearchResults();
3236
+ return;
3237
+ }
3238
+ searchState.results = data.results;
3239
+ searchState.summary = data;
3240
+ searchState.query = query;
3241
+ searchState.selected = data.results.length > 0 ? 0 : -1;
3242
+ searchState.previews.clear();
3243
+ renderSearchSummary();
3244
+ renderSearchResults();
3245
+ } catch (error) {
3246
+ if (error.name !== 'AbortError') summary.textContent = `Search failed: ${error.message}`;
3247
+ }
3248
+ }
3249
+
3250
+ function sideMatches(result) {
3251
+ const side = searchState.side;
3252
+ if (side === 'all') return true;
3253
+ if (side === 'unchanged') return result.side === 'context' || result.side === 'untouched';
3254
+ return result.side === side;
3255
+ }
3256
+
3257
+ function visibleResults() {
3258
+ return searchState.results.filter(sideMatches);
3259
+ }
3260
+
3261
+ function renderSearchSummary() {
3262
+ const data = searchState.summary;
3263
+ const summary = document.getElementById('searchSummary');
3264
+ if (!data) return;
3265
+ const shown = visibleResults().length;
3266
+ const parts = [`${shown} ${shown === 1 ? 'result' : 'results'} in ${data.files} ${data.files === 1 ? 'file' : 'files'}`];
3267
+ if (data.truncated) parts.push('more not shown');
3268
+ parts.push(`${data.ms} ms`);
3269
+ summary.replaceChildren(document.createTextNode(parts.join(' · ')));
3270
+ if (data.summary) {
3271
+ const s = data.summary;
3272
+ summary.append(document.createElement('br'), document.createTextNode(
3273
+ `${s.definitions} ${s.definitions === 1 ? 'definition' : 'definitions'} · ${s.added} added · ${s.removed} removed · ${s.unchanged} unchanged ${s.unchanged === 1 ? 'use' : 'uses'}`
3274
+ ));
3275
+ if (s.warning) summary.append(Object.assign(document.createElement('span'), { className: 'search-warning', textContent: s.warning }));
3276
+ }
3277
+ }
3278
+
3279
+ /** A result's line, with what matched marked. Each piece is escaped on its own. */
3280
+ function markedText(result) {
3281
+ let html = '';
3282
+ let at = 0;
3283
+ for (const [start, end] of result.ranges) {
3284
+ html += escapeHtml(result.text.slice(at, start)) + `<mark>${escapeHtml(result.text.slice(start, end))}</mark>`;
3285
+ at = end;
3286
+ }
3287
+ return html + escapeHtml(result.text.slice(at));
3288
+ }
3289
+
3290
+ const SIDE_MARK = { added: '+', deleted: '−', context: '·', untouched: '·' };
3291
+ const SIDE_NAME = { added: 'added', deleted: 'deleted', context: 'unchanged', untouched: 'outside the change' };
3292
+
3293
+ function renderSearchResults() {
3294
+ const container = document.getElementById('searchResults');
3295
+ const results = visibleResults();
3296
+ const groups = new Map();
3297
+ results.forEach((result, index) => {
3298
+ if (!groups.has(result.file)) groups.set(result.file, []);
3299
+ groups.get(result.file).push({ result, index });
3300
+ });
3301
+
3302
+ let html = '';
3303
+ for (const [file, hits] of groups) {
3304
+ const expanded = !searchState.collapsed.has(file);
3305
+ const reviewed = currentFiles.some(f => f.path === file);
3306
+ html += `<button type="button" class="search-file" data-file="${escapeHtml(file)}" aria-expanded="${expanded}">` +
3307
+ `<span>${escapeHtml(file)}${reviewed ? '' : ' <span class="elsewhere">not in this review</span>'}</span>` +
3308
+ `<span class="count">${hits.length}</span></button><div class="search-group">`;
3309
+ for (const { result, index } of hits) {
3310
+ const canComment = reviewed && result.diffIndex !== null;
3311
+ html += `<div class="search-hit${index === searchState.selected ? ' selected' : ''}" role="option" data-index="${index}" data-side="${result.side}"` +
3312
+ ` title="Line ${result.line}, ${SIDE_NAME[result.side]}">` +
3313
+ `<span class="side">${SIDE_MARK[result.side]}</span><span class="num">${Number(result.line)}</span>` +
3314
+ `<span class="text">${result.role === 'definition' ? '<span class="role">def </span>' : ''}${markedText(result)}</span>` +
3315
+ `${canComment ? `<button type="button" class="hit-comment" data-comment="${index}" title="Comment on this line">Comment</button>` : ''}</div>`;
3316
+ const preview = searchState.previews.get(index);
3317
+ if (preview) html += `<div class="search-preview">${preview}</div>`;
3318
+ }
3319
+ html += '</div>';
3320
+ }
3321
+ if (searchState.query && results.length === 0) {
3322
+ html = '<div class="search-summary" style="padding: 8px 14px">Nothing matches on this side of the change.</div>';
3323
+ }
3324
+ container.innerHTML = html;
3325
+ container.querySelector('.search-hit.selected')?.scrollIntoView({ block: 'nearest' });
3326
+ renderSearchMinimap();
3327
+ }
3328
+
3329
+ function moveSearchSelection(delta) {
3330
+ const count = visibleResults().length;
3331
+ if (count === 0) return;
3332
+ searchState.selected = (searchState.selected + delta + count) % count;
3333
+ renderSearchResults();
3334
+ }
3335
+
3336
+ /**
3337
+ * Go to a result: open its file in the diff and bring the line into view.
3338
+ * A line outside the review has no diff to show, so its neighbourhood opens
3339
+ * under it instead.
3340
+ */
3341
+ async function openSearchResult(index, { comment = false } = {}) {
3342
+ const result = visibleResults()[index];
3343
+ if (!result) return;
3344
+ searchState.selected = index;
3345
+
3346
+ const fileIndex = currentFiles.findIndex(f => f.path === result.file);
3347
+ if (fileIndex === -1 || result.diffIndex === null) {
3348
+ await togglePreview(index, result);
3349
+ return;
3350
+ }
3351
+
3352
+ if (currentFile !== result.file) await loadFile(result.file, fileIndex);
3353
+ renderSearchResults();
3354
+ const line = document.querySelector(`.line[data-diff-index="${Number(result.diffIndex)}"]`);
3355
+ if (!line) return;
3356
+ line.scrollIntoView({ block: 'center' });
3357
+ line.classList.remove('search-flash');
3358
+ void line.offsetWidth;
3359
+ line.classList.add('search-flash');
3360
+ if (comment) {
3361
+ const [start, end] = result.ranges[0] ?? [0, 0];
3362
+ toggleCommentInputWithSelection(result.diffIndex, end > start ? result.text.slice(start, end) : null);
3363
+ }
3364
+ }
3365
+
3366
+ async function togglePreview(index, result) {
3367
+ if (searchState.previews.has(index)) {
3368
+ searchState.previews.delete(index);
3369
+ renderSearchResults();
3370
+ return;
3371
+ }
3372
+ try {
3373
+ const response = await fetch(`${API_BASE}/file-full/${currentRepoId}/${result.file}`);
3374
+ const data = await response.json();
3375
+ if (!response.ok) throw new Error(data.error);
3376
+ const from = Math.max(0, result.line - 4);
3377
+ const lines = data.lines.slice(from, result.line + 3).map((text, i) => {
3378
+ const number = from + i + 1;
3379
+ const row = `${String(number).padStart(5)} ${escapeHtml(text)}`;
3380
+ return number === result.line ? `<span class="focus">${row}</span>` : row;
3381
+ });
3382
+ searchState.previews.set(index, lines.join('\n'));
3383
+ } catch (error) {
3384
+ searchState.previews.set(index, escapeHtml(`Could not read ${result.file}: ${error.message}`));
3385
+ }
3386
+ renderSearchResults();
3387
+ }
3388
+
3389
+ /** Marks on the diff's edge for every result in the open file. */
3390
+ function renderSearchMinimap() {
3391
+ const strip = document.getElementById('searchMinimap');
3392
+ if (!strip) return;
3393
+ const total = currentDiffLines.length;
3394
+ const here = searchState.open && total > 0
3395
+ ? visibleResults().map((result, index) => ({ result, index })).filter(({ result }) => result.file === currentFile && result.diffIndex !== null)
3396
+ : [];
3397
+ strip.classList.toggle('hidden', here.length === 0);
3398
+ strip.innerHTML = here.map(({ result, index }) =>
3399
+ `<span data-index="${index}" data-side="${result.side}" style="top: ${((result.diffIndex / total) * 100).toFixed(2)}%" title="Line ${Number(result.line)}"></span>`
3400
+ ).join('');
3401
+ }
3402
+
3403
+ function initSearch() {
3404
+ const input = document.getElementById('searchInput');
3405
+ input.addEventListener('input', scheduleSearch);
3406
+ input.addEventListener('keydown', event => {
3407
+ if (event.key === 'ArrowDown') { event.preventDefault(); moveSearchSelection(1); }
3408
+ if (event.key === 'ArrowUp') { event.preventDefault(); moveSearchSelection(-1); }
3409
+ if (event.key === 'Enter') {
3410
+ event.preventDefault();
3411
+ const query = input.value.trim();
3412
+ if (query) rememberSearch(query);
3413
+ if (searchState.query !== input.value) runSearch();
3414
+ else openSearchResult(Math.max(0, searchState.selected));
3415
+ }
3416
+ if (event.key === 'Escape') { event.preventDefault(); closeSearch(); }
3417
+ });
3418
+ for (const id of ['searchCase', 'searchRefs']) {
3419
+ document.getElementById(id).addEventListener('click', event => {
3420
+ const button = event.currentTarget;
3421
+ button.setAttribute('aria-pressed', button.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
3422
+ runSearch();
3423
+ input.focus(); // the arrow keys and Enter keep working on the results
3424
+ });
3425
+ }
3426
+ for (const id of ['searchMode', 'searchScope']) {
3427
+ document.getElementById(id).addEventListener('change', () => {
3428
+ runSearch();
3429
+ input.focus();
3430
+ });
3431
+ }
3432
+ document.querySelector('.search-sides').addEventListener('click', event => {
3433
+ const button = event.target.closest('button[data-side]');
3434
+ if (!button) return;
3435
+ searchState.side = button.dataset.side;
3436
+ document.querySelectorAll('.search-sides button').forEach(b => b.classList.toggle('active', b === button));
3437
+ searchState.selected = visibleResults().length > 0 ? 0 : -1;
3438
+ renderSearchSummary();
3439
+ renderSearchResults();
3440
+ });
3441
+ document.getElementById('searchResults').addEventListener('click', event => {
3442
+ const commentButton = event.target.closest('[data-comment]');
3443
+ if (commentButton) {
3444
+ event.stopPropagation();
3445
+ openSearchResult(Number(commentButton.dataset.comment), { comment: true });
3446
+ return;
3447
+ }
3448
+ const header = event.target.closest('.search-file');
3449
+ if (header) {
3450
+ const file = header.dataset.file;
3451
+ if (searchState.collapsed.has(file)) searchState.collapsed.delete(file);
3452
+ else searchState.collapsed.add(file);
3453
+ renderSearchResults();
3454
+ return;
3455
+ }
3456
+ const hit = event.target.closest('.search-hit');
3457
+ if (hit) openSearchResult(Number(hit.dataset.index));
3458
+ });
3459
+ document.getElementById('searchMinimap').addEventListener('click', event => {
3460
+ const mark = event.target.closest('span[data-index]');
3461
+ if (mark) openSearchResult(Number(mark.dataset.index));
3462
+ });
3463
+
3464
+ // Ctrl/Cmd+Shift+F from anywhere; Ctrl/Cmd+G and Shift+Ctrl/Cmd+G step
3465
+ // through the results while the search is open, as in an IDE.
3466
+ document.addEventListener('keydown', event => {
3467
+ const mod = event.metaKey || event.ctrlKey;
3468
+ if (mod && event.shiftKey && event.key.toLowerCase() === 'f') {
3469
+ event.preventDefault();
3470
+ const selection = window.getSelection?.().toString().trim();
3471
+ openSearch(selection && !selection.includes('\n') && selection.length <= 200 ? selection : null);
3472
+ } else if (mod && event.key.toLowerCase() === 'g' && searchState.open && visibleResults().length > 0) {
3473
+ event.preventDefault();
3474
+ moveSearchSelection(event.shiftKey ? -1 : 1);
3475
+ openSearchResult(searchState.selected);
3476
+ }
3477
+ });
3478
+ }
3479
+
3102
3480
  // Allow Enter key to load repo
3103
3481
  /**
3104
3482
  * Tell the server this tab exists, by holding a connection open for as long
@@ -3137,6 +3515,7 @@ function stopWatchingFromThisTab() {
3137
3515
 
3138
3516
  document.addEventListener('DOMContentLoaded', () => {
3139
3517
  watchFromThisTab();
3518
+ initSearch();
3140
3519
 
3141
3520
  // A tab you were only reading catches up on other tabs' comments when it
3142
3521
  // comes back into view. See refreshCommentsFromElsewhere.
package/public/index.html CHANGED
@@ -25,6 +25,13 @@
25
25
  <span id="agentButtonLabel">Ask Claude Code</span>
26
26
  </button>
27
27
 
28
+ <button id="searchButton" class="icon-button hidden" onclick="openSearch()"
29
+ title="Search the change (Ctrl+Shift+F)" aria-label="Search the change">
30
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
31
+ <path d="M10.68 11.74a6 6 0 01-7.92-8.98 6 6 0 018.98 7.92l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04zM11.5 7a4.5 4.5 0 10-9 0 4.5 4.5 0 009 0z"></path>
32
+ </svg>
33
+ </button>
34
+
28
35
  <button id="helpButton" class="icon-button" onclick="openHelp()"
29
36
  title="How to review" aria-label="How to review">
30
37
  <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
@@ -236,6 +243,45 @@
236
243
  </div>
237
244
 
238
245
  <aside id="sidebar" class="sidebar hidden">
246
+ <!-- Find across the whole change, the way an IDE's find navigator
247
+ searches a project: it takes the sidebar's place while open. -->
248
+ <section id="searchPanel" class="search-panel hidden" aria-label="Search the change">
249
+ <div class="search-head">
250
+ <h2>Search</h2>
251
+ <button class="search-back" type="button" onclick="closeSearch()" title="Back to the files (Esc)">Files</button>
252
+ </div>
253
+ <div class="search-box">
254
+ <input id="searchInput" type="search" list="searchHistory" placeholder="Find in the change"
255
+ spellcheck="false" autocomplete="off" aria-label="Find in the change">
256
+ <datalist id="searchHistory"></datalist>
257
+ <button id="searchCase" class="search-toggle" type="button" aria-pressed="false" title="Match case">Aa</button>
258
+ <button id="searchRefs" class="search-toggle" type="button" aria-pressed="false"
259
+ title="Find references: the definition, and uses added, removed and left unchanged">Refs</button>
260
+ </div>
261
+ <div class="search-options">
262
+ <select id="searchMode" aria-label="How to match">
263
+ <option value="contains">Contains</option>
264
+ <option value="word">Whole word</option>
265
+ <option value="prefix">Starts with</option>
266
+ <option value="suffix">Ends with</option>
267
+ <option value="regex">Regular expression</option>
268
+ </select>
269
+ <select id="searchScope" aria-label="Where to search">
270
+ <option value="changes">Changed lines</option>
271
+ <option value="files" selected>Changed files</option>
272
+ <option value="repo">Whole repository</option>
273
+ </select>
274
+ </div>
275
+ <div class="search-sides" role="group" aria-label="Show results">
276
+ <button type="button" data-side="all" class="active">All</button>
277
+ <button type="button" data-side="added">Added</button>
278
+ <button type="button" data-side="deleted">Deleted</button>
279
+ <button type="button" data-side="unchanged">Unchanged</button>
280
+ </div>
281
+ <div id="searchSummary" class="search-summary" role="status" aria-live="polite"></div>
282
+ <div id="searchResults" class="search-results" role="listbox" aria-label="Search results"></div>
283
+ </section>
284
+
239
285
  <!-- The commits a review is made of, when it is made of commits. Click
240
286
  one to review it on its own; shift-click a second to review the run
241
287
  between them -- the same gesture a pull request's commit picker
@@ -270,6 +316,7 @@
270
316
  </button>
271
317
  </div>
272
318
  <div id="codeViewer" class="code-viewer"></div>
319
+ <div id="searchMinimap" class="search-minimap hidden" aria-hidden="true"></div>
273
320
  </main>
274
321
 
275
322
  <div id="commentsResizeHandle" class="resize-handle hidden"></div>
package/public/style.css CHANGED
@@ -2493,3 +2493,115 @@ button:disabled {
2493
2493
  .agent-session-actions { flex-direction: row; }
2494
2494
  .agent-button { padding: 7px 9px; }
2495
2495
  }
2496
+
2497
+ /* Search across the change
2498
+ *
2499
+ * Results are code, so they are set in the diff's monospace, and each one
2500
+ * says which side of the change it is on with the diff's own colours.
2501
+ */
2502
+ .code-section { position: relative; }
2503
+ .sidebar.searching > :not(#searchPanel):not(.resize-handle) { display: none !important; }
2504
+ .search-panel {
2505
+ display: flex;
2506
+ flex-direction: column;
2507
+ gap: 8px;
2508
+ padding: 12px 14px;
2509
+ min-height: 0;
2510
+ flex: 1;
2511
+ }
2512
+ .search-head { display: flex; align-items: center; justify-content: space-between; }
2513
+ .search-head h2 { font-size: 14px; font-weight: 600; color: #24292e; }
2514
+ .search-panel .search-back {
2515
+ padding: 3px 9px; border: 1px solid #d1d5da; border-radius: 6px;
2516
+ background: white; color: #24292e; font: inherit; font-size: 12px; cursor: pointer;
2517
+ }
2518
+ .search-panel .search-back:hover { background: #f3f4f6; }
2519
+ .search-box { display: flex; gap: 6px; }
2520
+ .search-box input {
2521
+ flex: 1; min-width: 0; padding: 7px 9px;
2522
+ border: 1px solid #d1d5da; border-radius: 6px; font-size: 13px;
2523
+ font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
2524
+ }
2525
+ .search-box input:focus { outline: 2px solid #0366d6; outline-offset: -1px; }
2526
+ .search-panel .search-toggle {
2527
+ padding: 0 8px; border: 1px solid #d1d5da; border-radius: 6px;
2528
+ background: white; color: #57606a; cursor: pointer;
2529
+ font-family: inherit; font-size: 12px; font-weight: 600; line-height: 1;
2530
+ }
2531
+ .search-panel .search-toggle:hover { background: #f3f4f6; }
2532
+ .search-panel .search-toggle[aria-pressed="true"] { background: #0366d6; border-color: #0366d6; color: white; }
2533
+ .search-options { display: flex; gap: 6px; }
2534
+ .search-options select {
2535
+ flex: 1; min-width: 0; padding: 5px 6px; font-size: 12px;
2536
+ border: 1px solid #d1d5da; border-radius: 6px; background: white;
2537
+ }
2538
+ .search-sides { display: flex; gap: 4px; flex-wrap: wrap; }
2539
+ .search-panel .search-sides button {
2540
+ padding: 3px 9px; border: 1px solid #d1d5da; border-radius: 999px;
2541
+ background: white; color: #57606a; font-size: 11.5px; font-weight: 500; cursor: pointer;
2542
+ }
2543
+ .search-panel .search-sides button:hover { background: #f3f4f6; }
2544
+ .search-panel .search-sides button.active { background: #24292e; border-color: #24292e; color: white; }
2545
+ .search-summary { font-size: 12px; color: #57606a; line-height: 1.45; }
2546
+ .search-summary .search-warning { display: block; margin-top: 4px; color: #9a6700; }
2547
+ .search-summary .search-error { color: #cf222e; }
2548
+ .search-results { flex: 1; min-height: 0; overflow-y: auto; margin: 0 -14px; }
2549
+ .search-panel .search-file {
2550
+ display: flex; align-items: center; gap: 6px; width: 100%;
2551
+ padding: 6px 14px; border: none; border-radius: 0; background: #f6f8fa; cursor: pointer;
2552
+ font-family: inherit; font-size: 12px; font-weight: 600; line-height: 1.3; color: #24292e; text-align: left;
2553
+ white-space: normal; position: sticky; top: 0; transform: none;
2554
+ }
2555
+ .search-panel .search-file:hover { background: #eef1f4; }
2556
+ .search-file .count {
2557
+ margin-left: auto; padding: 1px 7px; border-radius: 999px;
2558
+ background: #e1e4e8; color: #57606a; font-weight: 500; font-size: 11px;
2559
+ }
2560
+ .search-file .elsewhere { font-weight: 400; color: #8b949e; }
2561
+ .search-file[aria-expanded="false"] + .search-group { display: none; }
2562
+ .search-hit {
2563
+ position: relative;
2564
+ display: grid; grid-template-columns: 14px 34px minmax(0, 1fr); gap: 6px; align-items: baseline;
2565
+ padding: 3px 14px; cursor: pointer;
2566
+ font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 11.5px;
2567
+ }
2568
+ .search-hit:hover { background: #f3f6fa; }
2569
+ .search-hit.selected { background: #ddf4ff; }
2570
+ .search-hit .side { text-align: center; font-weight: 700; }
2571
+ .search-hit[data-side="added"] .side { color: #1a7f37; }
2572
+ .search-hit[data-side="deleted"] .side { color: #cf222e; }
2573
+ .search-hit[data-side="context"] .side { color: #8b949e; }
2574
+ .search-hit[data-side="untouched"] .side { color: #0969da; }
2575
+ .search-hit .num { color: #8b949e; text-align: right; font-variant-numeric: tabular-nums; }
2576
+ .search-hit .text { white-space: pre; overflow: hidden; text-overflow: ellipsis; color: #24292e; }
2577
+ .search-hit[data-side="deleted"] .text { color: #82071e; }
2578
+ .search-hit mark { background: #fff8c5; color: inherit; border-radius: 2px; box-shadow: 0 0 0 1px #d4a72c; }
2579
+ .search-hit .role {
2580
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
2581
+ font-size: 10.5px; color: #8250df; font-weight: 600;
2582
+ }
2583
+ .search-panel .hit-comment {
2584
+ position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
2585
+ visibility: hidden; padding: 1px 6px; border: 1px solid #d1d5da; border-radius: 4px;
2586
+ background: white; color: #24292e; font: 11px/1.3 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; cursor: pointer;
2587
+ }
2588
+ .search-panel .hit-comment:hover { background: #f3f4f6; }
2589
+ .search-hit:hover .hit-comment, .search-hit.selected .hit-comment { visibility: visible; }
2590
+ .search-preview {
2591
+ margin: 2px 14px 6px 34px; padding: 6px 8px; border-left: 2px solid #0969da;
2592
+ background: #f6f8fa; font: 11px/1.5 SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
2593
+ white-space: pre; overflow-x: auto; color: #24292e;
2594
+ }
2595
+ .search-preview .focus { background: #fff8c5; display: inline-block; min-width: 100%; }
2596
+ .line.search-flash { animation: search-flash 1.6s ease-out; }
2597
+ @keyframes search-flash { 0%, 30% { box-shadow: inset 4px 0 0 #0969da; background: #ddf4ff; } }
2598
+ @media (prefers-reduced-motion: reduce) { .line.search-flash { animation: none; box-shadow: inset 4px 0 0 #0969da; } }
2599
+ .search-minimap {
2600
+ position: absolute; top: 58px; right: 2px; bottom: 6px; width: 8px; pointer-events: none;
2601
+ }
2602
+ .search-minimap span {
2603
+ position: absolute; right: 0; width: 8px; height: 3px; border-radius: 1px;
2604
+ background: #d4a72c; pointer-events: auto; cursor: pointer;
2605
+ }
2606
+ .search-minimap span[data-side="added"] { background: #2da44e; }
2607
+ .search-minimap span[data-side="deleted"] { background: #cf222e; }
package/server.js CHANGED
@@ -30,6 +30,9 @@ const { listClaudeSessions, findClaudeSession } = require('./lib/claude-sessions
30
30
  const { BackgroundSession } = require('./lib/claude-background');
31
31
  const { Answerer } = require('./lib/answerer');
32
32
  const { resolveClaudeCommand } = require('./lib/claude-command');
33
+ const {
34
+ buildMatcher, searchDiffs, grepRepository, definesName, summarizeReferences, SearchError, SCOPES, MAX_RESULTS
35
+ } = require('./lib/search');
33
36
 
34
37
  const DEFAULT_PORT = 4500;
35
38
  const DEFAULT_HOST = '127.0.0.1';
@@ -988,6 +991,11 @@ function createApp(options = {}) {
988
991
  sessions.get(repoId).foldedDirectories = new Map(
989
992
  files.filter(file => file.directory).map(file => [file.path, file.fileCount])
990
993
  );
994
+ // What a search of the review looks through: the files it shows, less
995
+ // folded directories and binaries, which have no lines to match.
996
+ sessions.get(repoId).files = files
997
+ .filter(file => !file.directory && file.status !== 'B')
998
+ .map(file => file.path);
991
999
  // stderr, not stdout. `reviewer | claude -p ...` puts the finished
992
1000
  // review on stdout, and anything else written there lands in the middle
993
1001
  // of it. Progress is for the person watching; stdout is for the pipe.
@@ -1429,6 +1437,122 @@ function createApp(options = {}) {
1429
1437
  res.json({ disconnected: Boolean(answerer) });
1430
1438
  });
1431
1439
 
1440
+ // A review's diffs are built once and searched as the reviewer types; this
1441
+ // long is long enough for a burst of typing and short enough to notice an
1442
+ // edit made since.
1443
+ const SEARCH_CACHE_MS = 15000;
1444
+
1445
+ /**
1446
+ * Every changed file's diff, as the page draws it, for searching.
1447
+ *
1448
+ * @param {object} session
1449
+ * @returns {Promise<Array<{path: string, lines: object[]}>>}
1450
+ */
1451
+ async function diffsForSearch(session) {
1452
+ const cached = session.searchCache;
1453
+ if (cached && Date.now() - cached.at < SEARCH_CACHE_MS) return cached.files;
1454
+
1455
+ const git = gitFactory(session.repoPath);
1456
+ const paths = session.files ?? [];
1457
+ const files = [];
1458
+ // A few at a time: a review of two hundred files should not start two
1459
+ // hundred git processes at once.
1460
+ for (let i = 0; i < paths.length; i += 8) {
1461
+ const batch = await Promise.all(paths.slice(i, i + 8).map(async filePath => {
1462
+ try {
1463
+ const diffText = await diffForFile(git, session, filePath);
1464
+ if (isBinaryDiff(diffText)) return null;
1465
+ const content = await readFileForMode(session, filePath);
1466
+ return { path: filePath, lines: parseDiff(diffText, content) };
1467
+ } catch {
1468
+ return null; // gone since the review was opened
1469
+ }
1470
+ }));
1471
+ files.push(...batch.filter(Boolean));
1472
+ }
1473
+
1474
+ session.searchCache = { at: Date.now(), files };
1475
+ return files;
1476
+ }
1477
+
1478
+ /**
1479
+ * Search the review: every changed file at once, and with `scope=repo` the
1480
+ * rest of the repository at the tree under review.
1481
+ *
1482
+ * `q` is the text; `mode` is contains, word, prefix, suffix or regex;
1483
+ * `case=1` matches case; `scope` is changes (lines the change added or
1484
+ * deleted), files (every line of the changed files) or repo. `symbol=1`
1485
+ * is a references search: whole word, case-sensitive, and each result is
1486
+ * marked as a definition or a use. Results say which side of the change
1487
+ * they are on, and `diffIndex` is the line's position in the diff the
1488
+ * page draws for that file.
1489
+ */
1490
+ app.get('/api/search/:repoId', async (req, res) => {
1491
+ const session = requireSession(res, req.params.repoId);
1492
+ if (!session) return;
1493
+
1494
+ const pick = name => (typeof req.query[name] === 'string' ? req.query[name] : '');
1495
+ const query = pick('q');
1496
+ const symbol = pick('symbol') === '1';
1497
+ const scope = pick('scope') || 'files';
1498
+ const match = { mode: symbol ? 'word' : (pick('mode') || 'contains'), caseSensitive: symbol || pick('case') === '1' };
1499
+ if (!SCOPES.includes(scope)) {
1500
+ return res.status(400).json({ error: `Unknown scope "${scope}".` });
1501
+ }
1502
+
1503
+ let matcher;
1504
+ try {
1505
+ matcher = buildMatcher(query, match);
1506
+ } catch (error) {
1507
+ if (error instanceof SearchError) return res.status(400).json({ error: error.message });
1508
+ throw error;
1509
+ }
1510
+
1511
+ try {
1512
+ const started = Date.now();
1513
+ const files = await diffsForSearch(session);
1514
+ const found = searchDiffs(files, matcher, { changedOnly: scope === 'changes' });
1515
+ let { results, truncated } = found;
1516
+
1517
+ if (scope === 'repo' && results.length < MAX_RESULTS) {
1518
+ const elsewhere = await grepRepository({
1519
+ repoPath: session.repoPath,
1520
+ session,
1521
+ query,
1522
+ match,
1523
+ matcher,
1524
+ exclude: new Set(files.map(file => file.path)),
1525
+ limit: MAX_RESULTS - results.length
1526
+ });
1527
+ results = results.concat(elsewhere.results);
1528
+ truncated = truncated || elsewhere.truncated;
1529
+ }
1530
+
1531
+ let summary = null;
1532
+ if (symbol) {
1533
+ for (const result of results) result.role = definesName(result.text, query) ? 'definition' : 'reference';
1534
+ summary = summarizeReferences(results);
1535
+ }
1536
+
1537
+ res.json({
1538
+ query,
1539
+ scope,
1540
+ mode: match.mode,
1541
+ caseSensitive: match.caseSensitive,
1542
+ symbol,
1543
+ results,
1544
+ truncated,
1545
+ files: new Set(results.map(result => result.file)).size,
1546
+ searched: files.length,
1547
+ ms: Date.now() - started,
1548
+ summary
1549
+ });
1550
+ } catch (error) {
1551
+ console.error('Search error:', error);
1552
+ res.status(500).json({ error: error.message });
1553
+ }
1554
+ });
1555
+
1432
1556
  /** Drop a session. Saved comments on disk are left alone. */
1433
1557
  app.delete('/api/cleanup/:repoId', (req, res) => {
1434
1558
  sessions.delete(req.params.repoId);