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 +29 -2
- package/lib/search.js +319 -0
- package/package.json +1 -1
- package/public/app.js +379 -0
- package/public/index.html +47 -0
- package/public/style.css +112 -0
- package/server.js +124 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](test/)
|
|
7
7
|
[](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 #
|
|
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.
|
|
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);
|