git-reviewer 2.15.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 +93 -5
- package/bin/reviewer.js +256 -1
- package/lib/agent.js +12 -2
- package/lib/answerer.js +191 -0
- package/lib/channel.js +286 -0
- package/lib/claude-background.js +316 -0
- package/lib/claude-command.js +90 -0
- package/lib/claude-sessions.js +229 -0
- package/lib/cli.js +147 -7
- package/lib/comments.js +175 -10
- package/lib/presence.js +223 -0
- package/lib/search.js +319 -0
- package/lib/store.js +284 -9
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +47 -0
- package/package.json +1 -1
- package/public/app.js +1118 -107
- package/public/index.html +80 -0
- package/public/style.css +383 -0
- package/server.js +431 -34
package/public/app.js
CHANGED
|
@@ -10,23 +10,306 @@ let currentFiles = [];
|
|
|
10
10
|
let currentFile = null;
|
|
11
11
|
let currentDiffLines = [];
|
|
12
12
|
let comments = [];
|
|
13
|
+
// The `lastUpdated` stamp `comments` was loaded (or last saved) at, and the
|
|
14
|
+
// persisted snapshot that state is based on. Together they are what let a
|
|
15
|
+
// save notice that another tab -- or a writer outside the page -- got there
|
|
16
|
+
// first, and merge instead of overwriting (#107).
|
|
17
|
+
let commentsLastUpdated = null;
|
|
18
|
+
let commentsBase = [];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A comment's identity for merging: its key. Every comment has one -- minted
|
|
22
|
+
* here when it is written, or given by the server to one saved before keys
|
|
23
|
+
* existed -- so where it sits and what it says is only a fallback.
|
|
24
|
+
*/
|
|
25
|
+
function commentKey(comment) {
|
|
26
|
+
return comment.key ?? JSON.stringify([comment.file, comment.line, comment.text]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A new key for a comment or a follow-up: eight characters, the same shape
|
|
31
|
+
* the server mints. Minted here rather than on save so that a comment has its
|
|
32
|
+
* identity from the moment it exists -- an agent may answer it before this
|
|
33
|
+
* tab has saved anything else.
|
|
34
|
+
*/
|
|
35
|
+
function mintKey() {
|
|
36
|
+
const bytes = new Uint8Array(6);
|
|
37
|
+
crypto.getRandomValues(bytes);
|
|
38
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The identity a follow-up is merged by. Mirrors `followUpIdentity` in lib/threads.js. */
|
|
42
|
+
function followUpIdentity(followUp) {
|
|
43
|
+
return followUp.key ?? JSON.stringify([followUp.timestamp ?? null, followUp.text, followUp.by ?? null]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every follow-up in either list, once. Mirrors `unionFollowUps` in
|
|
48
|
+
* lib/threads.js: follow-ups are only ever added, so one present on either
|
|
49
|
+
* side is one somebody wrote, and a union is the merge that cannot lose an
|
|
50
|
+
* answer. `ours` keeps its order; the rest are placed by timestamp.
|
|
51
|
+
*/
|
|
52
|
+
function unionFollowUps(ours = [], theirs = []) {
|
|
53
|
+
const merged = [...ours];
|
|
54
|
+
const seen = new Set(ours.map(followUpIdentity));
|
|
55
|
+
for (const followUp of theirs) {
|
|
56
|
+
const identity = followUpIdentity(followUp);
|
|
57
|
+
if (seen.has(identity)) continue;
|
|
58
|
+
seen.add(identity);
|
|
59
|
+
const at = followUp.timestamp ?? '';
|
|
60
|
+
let index = merged.length;
|
|
61
|
+
if (at) {
|
|
62
|
+
while (index > 0 && (merged[index - 1].timestamp ?? '') > at) index--;
|
|
63
|
+
}
|
|
64
|
+
merged.splice(index, 0, followUp);
|
|
65
|
+
}
|
|
66
|
+
return merged;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Add the reviewer's reply to a thread.
|
|
71
|
+
*
|
|
72
|
+
* A new array, never a push. The thread's array can be the very one
|
|
73
|
+
* `commentsBase` holds -- loading and merging both copy comments shallowly --
|
|
74
|
+
* and pushing onto it changed the base along with the comment, so the merge
|
|
75
|
+
* after a refused save saw no change here and took the server's copy of the
|
|
76
|
+
* thread: the reply was gone from the page and from disk.
|
|
77
|
+
*/
|
|
78
|
+
function appendFollowUp(comment, text) {
|
|
79
|
+
comment.followUps = [...(comment.followUps ?? []), {
|
|
80
|
+
key: mintKey(),
|
|
81
|
+
text,
|
|
82
|
+
timestamp: new Date().toISOString()
|
|
83
|
+
}];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A copy of comments as persisted, sharing nothing with what it was taken
|
|
88
|
+
* from: what `commentsBase` holds must not change when the live comments do.
|
|
89
|
+
*/
|
|
90
|
+
function snapshotComments(list) {
|
|
91
|
+
return JSON.parse(JSON.stringify(list.map(toPersistedComment)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reduce a live comment to the shape that gets persisted, for `commentsBase`. */
|
|
95
|
+
function toPersistedComment(comment) {
|
|
96
|
+
const persisted = {
|
|
97
|
+
...(comment.key ? { key: comment.key } : {}),
|
|
98
|
+
file: comment.file,
|
|
99
|
+
line: comment.line,
|
|
100
|
+
lineContent: comment.lineContent,
|
|
101
|
+
text: comment.text
|
|
102
|
+
};
|
|
103
|
+
if (comment.selectedText) persisted.selectedText = comment.selectedText;
|
|
104
|
+
if (Array.isArray(comment.followUps) && comment.followUps.length > 0) {
|
|
105
|
+
persisted.followUps = comment.followUps;
|
|
106
|
+
}
|
|
107
|
+
return persisted;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Merge this tab's comments with comments that arrived from elsewhere.
|
|
112
|
+
*
|
|
113
|
+
* `baseComments` is what both sides started from, so it is what tells an
|
|
114
|
+
* arrival from a local edit apart: a comment in the remote list but not in
|
|
115
|
+
* the base arrived; a base comment missing on either side was deleted on
|
|
116
|
+
* that side and stays deleted, unless the other side changed it (a new
|
|
117
|
+
* follow-up is a change, and losing it would be the bug again); a local
|
|
118
|
+
* comment missing from the base is new and is kept.
|
|
119
|
+
*
|
|
120
|
+
* Where both sides hold a comment, one this tab has not touched since the
|
|
121
|
+
* base is taken as it now stands -- that is how an agent's answer, or an edit
|
|
122
|
+
* made in another tab, arrives. One changed here as well keeps this tab's
|
|
123
|
+
* text, and the follow-ups of both sides, so neither a follow-up added here
|
|
124
|
+
* nor an answer added there is undone.
|
|
125
|
+
*/
|
|
126
|
+
function mergeCommentsForRetry(localComments, baseComments, remoteComments) {
|
|
127
|
+
const localByKey = new Map(localComments.map(comment => [commentKey(comment), comment]));
|
|
128
|
+
const baseByKey = new Map(baseComments.map(comment => [commentKey(comment), comment]));
|
|
129
|
+
const remoteKeys = new Set(remoteComments.map(commentKey));
|
|
130
|
+
const removedLocally = new Set(
|
|
131
|
+
baseComments.filter(comment => !localByKey.has(commentKey(comment))).map(commentKey)
|
|
132
|
+
);
|
|
133
|
+
const changedLocally = key =>
|
|
134
|
+
!baseByKey.has(key) ||
|
|
135
|
+
JSON.stringify(toPersistedComment(localByKey.get(key))) !==
|
|
136
|
+
JSON.stringify(toPersistedComment(baseByKey.get(key)));
|
|
137
|
+
|
|
138
|
+
const merged = [];
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const remote of remoteComments) {
|
|
141
|
+
const key = commentKey(remote);
|
|
142
|
+
if (removedLocally.has(key) || seen.has(key)) continue;
|
|
143
|
+
const local = localByKey.get(key);
|
|
144
|
+
if (!local) {
|
|
145
|
+
merged.push({ ...remote, diffLineIndex: null, matchType: remote.matchType || 'exact' });
|
|
146
|
+
} else if (changedLocally(key)) {
|
|
147
|
+
merged.push({ ...local, followUps: unionFollowUps(local.followUps, remote.followUps) });
|
|
148
|
+
} else {
|
|
149
|
+
// Where this tab drew it stays; what it says comes from the remote.
|
|
150
|
+
merged.push({
|
|
151
|
+
...remote,
|
|
152
|
+
diffLineIndex: local.diffLineIndex,
|
|
153
|
+
matchType: local.matchType,
|
|
154
|
+
newLineContent: local.newLineContent
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
seen.add(key);
|
|
158
|
+
}
|
|
159
|
+
for (const local of localComments) {
|
|
160
|
+
const key = commentKey(local);
|
|
161
|
+
if (seen.has(key)) continue;
|
|
162
|
+
// In the base but not in the remote: deleted elsewhere. Keep it only
|
|
163
|
+
// when this tab changed it in the meantime.
|
|
164
|
+
if (baseByKey.has(key) && !remoteKeys.has(key) && !changedLocally(key)) continue;
|
|
165
|
+
merged.push(local);
|
|
166
|
+
seen.add(key);
|
|
167
|
+
}
|
|
168
|
+
return merged;
|
|
169
|
+
}
|
|
13
170
|
|
|
14
171
|
// Auto-save comments to backend
|
|
15
172
|
async function saveCommentsToBackend() {
|
|
16
173
|
if (!currentRepoId) return;
|
|
17
174
|
|
|
175
|
+
// On a 409 the file has moved on since this tab loaded: take the current
|
|
176
|
+
// comments from the refusal, merge them with this tab's, and retry with
|
|
177
|
+
// the fresh stamp. A bounded number of retries, so two tabs racing in a
|
|
178
|
+
// loop cannot spin forever; if they are exhausted the next edit saves
|
|
179
|
+
// again, now based on the merged state.
|
|
180
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
181
|
+
try {
|
|
182
|
+
const sent = snapshotComments(comments);
|
|
183
|
+
const response = await fetch(`${API_BASE}/save-comments`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
repoId: currentRepoId,
|
|
188
|
+
comments: sent, // Send empty array if no comments
|
|
189
|
+
lastUpdated: commentsLastUpdated
|
|
190
|
+
})
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (response.status === 409) {
|
|
194
|
+
const data = await response.json().catch(() => ({}));
|
|
195
|
+
adoptRemoteComments(Array.isArray(data.comments) ? data.comments : [], data.lastUpdated ?? null);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
console.error('Failed to save comments:', response.status);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const data = await response.json().catch(() => ({}));
|
|
205
|
+
if (Array.isArray(data.comments)) {
|
|
206
|
+
// What was written, which can hold more than what was sent: the
|
|
207
|
+
// server keeps every follow-up already on disk, and an agent may have
|
|
208
|
+
// answered in a thread this tab had not heard about yet.
|
|
209
|
+
adoptRemoteComments(data.comments, data.lastUpdated ?? commentsLastUpdated, sent);
|
|
210
|
+
} else {
|
|
211
|
+
if (data.lastUpdated) commentsLastUpdated = data.lastUpdated;
|
|
212
|
+
commentsBase = snapshotComments(sent);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.error('Failed to save comments:', error);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.error('Failed to save comments: still changing elsewhere after retries');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Pick up comments written somewhere else while this tab was out of sight.
|
|
226
|
+
*
|
|
227
|
+
* A tab only learned about another tab's comments when it saved something
|
|
228
|
+
* itself, so a tab you were only reading kept showing a stale review (#107).
|
|
229
|
+
* Coming back into view is the moment someone looks again, so that is when
|
|
230
|
+
* it asks. Every edit is saved as it is made, so there is nothing unsaved to
|
|
231
|
+
* protect -- except text being typed into an open box, which redrawing the
|
|
232
|
+
* code pane would throw away. While one is open only the sidebar is brought
|
|
233
|
+
* up to date, and the pane catches up the next time a file is drawn.
|
|
234
|
+
*/
|
|
235
|
+
// Set when comments from elsewhere were merged in but the code pane could not
|
|
236
|
+
// be redrawn because someone was typing; cleared whenever the pane is drawn.
|
|
237
|
+
let commentsPaneStale = false;
|
|
238
|
+
|
|
239
|
+
async function refreshCommentsFromElsewhere() {
|
|
240
|
+
if (!currentRepoId || document.visibilityState !== 'visible') return;
|
|
241
|
+
const repoId = currentRepoId;
|
|
242
|
+
|
|
243
|
+
let data;
|
|
18
244
|
try {
|
|
19
|
-
await fetch(`${API_BASE}/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
245
|
+
const response = await fetch(`${API_BASE}/load-comments/${repoId}`);
|
|
246
|
+
if (!response.ok) return;
|
|
247
|
+
data = await response.json();
|
|
248
|
+
} catch {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (currentRepoId !== repoId) return;
|
|
252
|
+
|
|
253
|
+
const lastUpdated = data.lastUpdated ?? null;
|
|
254
|
+
if (lastUpdated !== commentsLastUpdated) {
|
|
255
|
+
adoptRemoteComments(Array.isArray(data.comments) ? data.comments : [], lastUpdated);
|
|
256
|
+
} else {
|
|
257
|
+
redrawCommentsPane();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Bring this tab up to date with comments as they stand on the server.
|
|
263
|
+
*
|
|
264
|
+
* Shared by a refused save, a save's reply, the live feed and a tab coming
|
|
265
|
+
* back into view: each is a list from the server and the base it should be
|
|
266
|
+
* merged against. The sidebar is redrawn at once; the code pane as soon as
|
|
267
|
+
* nobody is typing in it.
|
|
268
|
+
*
|
|
269
|
+
* @param {object[]} remoteComments
|
|
270
|
+
* @param {string|null} lastUpdated
|
|
271
|
+
* @param {object[]} [base] what this tab last knew the server to hold
|
|
272
|
+
*/
|
|
273
|
+
function adoptRemoteComments(remoteComments, lastUpdated, base = commentsBase) {
|
|
274
|
+
const before = JSON.stringify(comments.map(toPersistedComment));
|
|
275
|
+
comments = mergeCommentsForRetry(comments, base, remoteComments);
|
|
276
|
+
commentsBase = snapshotComments(remoteComments);
|
|
277
|
+
commentsLastUpdated = lastUpdated;
|
|
278
|
+
for (const comment of comments) {
|
|
279
|
+
// An answer has landed; the words that were streaming in are now in the thread.
|
|
280
|
+
if (agentState.partials[comment.key] && lastAuthorOf(comment)) delete agentState.partials[comment.key];
|
|
29
281
|
}
|
|
282
|
+
if (JSON.stringify(comments.map(toPersistedComment)) !== before) {
|
|
283
|
+
updateCommentsSidebar();
|
|
284
|
+
commentsPaneStale = true;
|
|
285
|
+
}
|
|
286
|
+
redrawCommentsPane();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Redraw the open file's comments from `comments`, if they have changed and
|
|
291
|
+
* nobody is typing into a box the redraw would throw away. Uses the diff
|
|
292
|
+
* already on screen rather than fetching it again, and keeps the scroll
|
|
293
|
+
* position, so an answer arriving does not move what is being read.
|
|
294
|
+
*/
|
|
295
|
+
function redrawCommentsPane() {
|
|
296
|
+
const typing = document.querySelector('.comment-input-box, .followup-input-box');
|
|
297
|
+
if (!commentsPaneStale || !currentFile || typing) return;
|
|
298
|
+
// Nor while code is selected in the pane: that is the first half of
|
|
299
|
+
// quoting it in a comment, and a redraw would drop the selection.
|
|
300
|
+
const selection = window.getSelection?.();
|
|
301
|
+
if (selection && !selection.isCollapsed && document.getElementById('codeViewer').contains(selection.anchorNode)) return;
|
|
302
|
+
if (isFullContextMode && storedFullFileLines) {
|
|
303
|
+
displayFullContext(currentFile, storedFullFileLines);
|
|
304
|
+
commentsPaneStale = false;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (!currentDiffLines.length) return;
|
|
308
|
+
const viewer = document.getElementById('codeViewer');
|
|
309
|
+
const scrollTop = viewer.scrollTop;
|
|
310
|
+
matchCommentsToFile(currentFile, currentDiffLines);
|
|
311
|
+
displayCode(currentFile, currentDiffLines, false);
|
|
312
|
+
viewer.scrollTop = scrollTop;
|
|
30
313
|
}
|
|
31
314
|
|
|
32
315
|
// Load comments from backend
|
|
@@ -36,6 +319,8 @@ async function loadCommentsFromBackend() {
|
|
|
36
319
|
try {
|
|
37
320
|
const response = await fetch(`${API_BASE}/load-comments/${currentRepoId}`);
|
|
38
321
|
const data = await response.json();
|
|
322
|
+
commentsLastUpdated = data.lastUpdated ?? null;
|
|
323
|
+
commentsBase = snapshotComments(data.comments || []);
|
|
39
324
|
return data.comments || [];
|
|
40
325
|
} catch (error) {
|
|
41
326
|
console.error('Failed to load comments:', error);
|
|
@@ -881,6 +1166,12 @@ async function loadRepo(scope = {}) {
|
|
|
881
1166
|
showStatus(`Picked up ${plural(savedComments.length, 'saved comment')} from last time.`, 'success');
|
|
882
1167
|
}
|
|
883
1168
|
|
|
1169
|
+
// From here on, comments written anywhere else -- another tab, an agent
|
|
1170
|
+
// answering in a thread -- arrive as they are written.
|
|
1171
|
+
openReviewEvents(currentRepoId);
|
|
1172
|
+
document.getElementById('searchButton')?.classList.remove('hidden');
|
|
1173
|
+
if (searchState.open) runSearch();
|
|
1174
|
+
|
|
884
1175
|
// Open something. A loaded repository used to sit behind an empty pane
|
|
885
1176
|
// until a file was clicked, which reads as a failure rather than as a
|
|
886
1177
|
// result (#47). Done here rather than in `displayFiles` because the saved
|
|
@@ -1065,45 +1356,7 @@ async function loadFile(filePath, index) {
|
|
|
1065
1356
|
//
|
|
1066
1357
|
// `comments` is what exists. A comment that is not in it does not exist.
|
|
1067
1358
|
if (comments.length > 0) {
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
// Update existing comments in the array with matched diff indices
|
|
1071
|
-
matchedComments.forEach(mc => {
|
|
1072
|
-
const existing = comments.find(c =>
|
|
1073
|
-
c.file === mc.file &&
|
|
1074
|
-
c.line === mc.line &&
|
|
1075
|
-
c.text === mc.text
|
|
1076
|
-
);
|
|
1077
|
-
|
|
1078
|
-
if (existing) {
|
|
1079
|
-
// Update existing comment with new diff line index and match type
|
|
1080
|
-
existing.diffLineIndex = mc.diffLineIndex;
|
|
1081
|
-
existing.matchType = mc.matchType;
|
|
1082
|
-
existing.newLineContent = mc.newLineContent;
|
|
1083
|
-
// Preserve followUps from saved data
|
|
1084
|
-
if (mc.followUps) {
|
|
1085
|
-
existing.followUps = mc.followUps;
|
|
1086
|
-
}
|
|
1087
|
-
} else {
|
|
1088
|
-
// Unreachable now that the matcher is fed the live list: everything
|
|
1089
|
-
// it returns came from there. Kept as a guard rather than removed,
|
|
1090
|
-
// because silently dropping a comment is the worse failure of the
|
|
1091
|
-
// two, and this is the line that would do it.
|
|
1092
|
-
// Add new comment if it doesn't exist
|
|
1093
|
-
comments.push({
|
|
1094
|
-
file: mc.file,
|
|
1095
|
-
diffLineIndex: mc.diffLineIndex,
|
|
1096
|
-
line: mc.line,
|
|
1097
|
-
lineContent: mc.lineContent,
|
|
1098
|
-
selectedText: mc.selectedText,
|
|
1099
|
-
text: mc.text,
|
|
1100
|
-
matchType: mc.matchType,
|
|
1101
|
-
newLineContent: mc.newLineContent,
|
|
1102
|
-
followUps: mc.followUps || []
|
|
1103
|
-
});
|
|
1104
|
-
}
|
|
1105
|
-
});
|
|
1106
|
-
|
|
1359
|
+
matchCommentsToFile(filePath, data.diffLines);
|
|
1107
1360
|
// Update comments sidebar after matching comments
|
|
1108
1361
|
updateCommentsSidebar();
|
|
1109
1362
|
}
|
|
@@ -1119,6 +1372,35 @@ async function loadFile(filePath, index) {
|
|
|
1119
1372
|
}
|
|
1120
1373
|
}
|
|
1121
1374
|
|
|
1375
|
+
/**
|
|
1376
|
+
* Give this file's comments their position in the diff on screen.
|
|
1377
|
+
*
|
|
1378
|
+
* Comments are found by key, not by file, line and text: two identical
|
|
1379
|
+
* comments on one line are two comments, and an edited one is still itself.
|
|
1380
|
+
*
|
|
1381
|
+
* @param {string} filePath
|
|
1382
|
+
* @param {object[]} diffLines
|
|
1383
|
+
*/
|
|
1384
|
+
function matchCommentsToFile(filePath, diffLines) {
|
|
1385
|
+
const matchedComments = matchCommentsToDiff(comments, filePath, diffLines);
|
|
1386
|
+
|
|
1387
|
+
matchedComments.forEach(mc => {
|
|
1388
|
+
const existing = comments.find(c => commentKey(c) === commentKey(mc));
|
|
1389
|
+
|
|
1390
|
+
if (existing) {
|
|
1391
|
+
existing.diffLineIndex = mc.diffLineIndex;
|
|
1392
|
+
existing.matchType = mc.matchType;
|
|
1393
|
+
existing.newLineContent = mc.newLineContent;
|
|
1394
|
+
} else {
|
|
1395
|
+
// Unreachable now that the matcher is fed the live list: everything
|
|
1396
|
+
// it returns came from there. Kept as a guard rather than removed,
|
|
1397
|
+
// because silently dropping a comment is the worse failure of the
|
|
1398
|
+
// two, and this is the line that would do it.
|
|
1399
|
+
comments.push({ ...mc, followUps: mc.followUps || [] });
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1122
1404
|
// Display code with diff highlighting
|
|
1123
1405
|
function displayCode(filePath, diffLines, binary) {
|
|
1124
1406
|
const codeSection = document.getElementById('codeSection');
|
|
@@ -1127,6 +1409,7 @@ function displayCode(filePath, diffLines, binary) {
|
|
|
1127
1409
|
|
|
1128
1410
|
currentFileEl.textContent = filePath;
|
|
1129
1411
|
currentDiffLines = diffLines; // Store for reference when adding comments
|
|
1412
|
+
commentsPaneStale = false; // drawn from `comments` as they stand now
|
|
1130
1413
|
|
|
1131
1414
|
// A binary file has no lines to show and none to comment on. Saying so is
|
|
1132
1415
|
// the whole feature: it used to be decoded as UTF-8 and rendered as
|
|
@@ -1183,24 +1466,10 @@ function displayCode(filePath, diffLines, binary) {
|
|
|
1183
1466
|
</div>
|
|
1184
1467
|
` : '';
|
|
1185
1468
|
|
|
1186
|
-
|
|
1187
|
-
let followUpsHtml = '';
|
|
1188
|
-
if (hasComment.followUps && hasComment.followUps.length > 0) {
|
|
1189
|
-
followUpsHtml = '<div class="comment-followups">';
|
|
1190
|
-
hasComment.followUps.forEach((followUp, idx) => {
|
|
1191
|
-
const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
|
|
1192
|
-
followUpsHtml += `
|
|
1193
|
-
<div class="comment-followup-item">
|
|
1194
|
-
<span class="followup-text">${escapeHtml(followUp.text)}</span>
|
|
1195
|
-
${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
|
|
1196
|
-
</div>
|
|
1197
|
-
`;
|
|
1198
|
-
});
|
|
1199
|
-
followUpsHtml += '</div>';
|
|
1200
|
-
}
|
|
1469
|
+
const followUpsHtml = threadHtml(hasComment);
|
|
1201
1470
|
|
|
1202
1471
|
html += `
|
|
1203
|
-
<div class="comment-box ${matchTypeClass}">
|
|
1472
|
+
<div class="comment-box ${matchTypeClass}" data-key="${escapeHtml(commentKey(hasComment))}">
|
|
1204
1473
|
${fuzzyWarning}
|
|
1205
1474
|
<span class="comment-text">${escapeHtml(hasComment.text)}</span>
|
|
1206
1475
|
${followUpsHtml}
|
|
@@ -1220,21 +1489,7 @@ function displayCode(filePath, diffLines, binary) {
|
|
|
1220
1489
|
if (unmatchedComments.length > 0) {
|
|
1221
1490
|
html += '<div class="unmatched-comments-section">';
|
|
1222
1491
|
unmatchedComments.forEach((comment, unmatchedIndex) => {
|
|
1223
|
-
|
|
1224
|
-
let unmatchedFollowUpsHtml = '';
|
|
1225
|
-
if (comment.followUps && comment.followUps.length > 0) {
|
|
1226
|
-
unmatchedFollowUpsHtml = '<div class="comment-followups">';
|
|
1227
|
-
comment.followUps.forEach((followUp, idx) => {
|
|
1228
|
-
const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
|
|
1229
|
-
unmatchedFollowUpsHtml += `
|
|
1230
|
-
<div class="comment-followup-item">
|
|
1231
|
-
<span class="followup-text">${escapeHtml(followUp.text)}</span>
|
|
1232
|
-
${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
|
|
1233
|
-
</div>
|
|
1234
|
-
`;
|
|
1235
|
-
});
|
|
1236
|
-
unmatchedFollowUpsHtml += '</div>';
|
|
1237
|
-
}
|
|
1492
|
+
const unmatchedFollowUpsHtml = threadHtml(comment);
|
|
1238
1493
|
|
|
1239
1494
|
// Every control in here used to carry the comment's file name and text,
|
|
1240
1495
|
// quoted into an attribute that is evaluated as JavaScript -- with `'`
|
|
@@ -1244,7 +1499,7 @@ function displayCode(filePath, diffLines, binary) {
|
|
|
1244
1499
|
// rendered from. The row's random id went with it: the position is the
|
|
1245
1500
|
// identifier now.
|
|
1246
1501
|
html += `
|
|
1247
|
-
<div class="unmatched-comment-item collapsed" tabindex="0">
|
|
1502
|
+
<div class="unmatched-comment-item collapsed" tabindex="0" data-key="${escapeHtml(commentKey(comment))}">
|
|
1248
1503
|
<div class="unmatched-comment-header" onclick="toggleUnmatchedComment(${Number(unmatchedIndex)})">
|
|
1249
1504
|
<span class="unmatched-comment-icon">⚠️</span>
|
|
1250
1505
|
<span class="unmatched-comment-title">Comment not found (Line ${comment.line} changed)</span>
|
|
@@ -1271,6 +1526,7 @@ function displayCode(filePath, diffLines, binary) {
|
|
|
1271
1526
|
|
|
1272
1527
|
codeViewer.innerHTML = html;
|
|
1273
1528
|
codeSection.classList.remove('hidden');
|
|
1529
|
+
renderSearchMinimap();
|
|
1274
1530
|
|
|
1275
1531
|
// Add text selection handler
|
|
1276
1532
|
codeViewer.removeEventListener('mousedown', trackShiftKeyDown);
|
|
@@ -1402,15 +1658,7 @@ function saveFollowUp(diffLineIndex) {
|
|
|
1402
1658
|
const comment = comments.find(c => c.file === currentFile && c.diffLineIndex === diffLineIndex);
|
|
1403
1659
|
if (!comment) return;
|
|
1404
1660
|
|
|
1405
|
-
|
|
1406
|
-
if (!comment.followUps) {
|
|
1407
|
-
comment.followUps = [];
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
comment.followUps.push({
|
|
1411
|
-
text: text,
|
|
1412
|
-
timestamp: new Date().toISOString()
|
|
1413
|
-
});
|
|
1661
|
+
appendFollowUp(comment, text);
|
|
1414
1662
|
|
|
1415
1663
|
// Auto-save to backend
|
|
1416
1664
|
saveCommentsToBackend();
|
|
@@ -1504,15 +1752,7 @@ function saveFollowUpToUnmatched(index) {
|
|
|
1504
1752
|
const comment = unmatchedShown[Number(index)];
|
|
1505
1753
|
if (!comment) return;
|
|
1506
1754
|
|
|
1507
|
-
|
|
1508
|
-
if (!comment.followUps) {
|
|
1509
|
-
comment.followUps = [];
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
comment.followUps.push({
|
|
1513
|
-
text: followUpText,
|
|
1514
|
-
timestamp: new Date().toISOString()
|
|
1515
|
-
});
|
|
1755
|
+
appendFollowUp(comment, followUpText);
|
|
1516
1756
|
|
|
1517
1757
|
// Auto-save to backend
|
|
1518
1758
|
saveCommentsToBackend();
|
|
@@ -1692,6 +1932,17 @@ function toggleCommentInputWithSelection(diffLineIndex, selectedText = null) {
|
|
|
1692
1932
|
});
|
|
1693
1933
|
}
|
|
1694
1934
|
|
|
1935
|
+
/**
|
|
1936
|
+
* The comment being edited, while its box is open.
|
|
1937
|
+
*
|
|
1938
|
+
* Editing takes a comment out of the list and saving puts a new one back.
|
|
1939
|
+
* The new one used to start with an empty thread, so editing a comment that
|
|
1940
|
+
* had replies deleted every one of them -- and now that replies include an
|
|
1941
|
+
* agent's answers, that is the answers gone. Holding on to the original is
|
|
1942
|
+
* how the edited comment keeps its key and its thread.
|
|
1943
|
+
*/
|
|
1944
|
+
let commentBeingEdited = null;
|
|
1945
|
+
|
|
1695
1946
|
// Save comment
|
|
1696
1947
|
function saveComment(diffLineIndex, selectedText = null) {
|
|
1697
1948
|
const input = document.getElementById('commentInput');
|
|
@@ -1709,7 +1960,11 @@ function saveComment(diffLineIndex, selectedText = null) {
|
|
|
1709
1960
|
const lineContent = diffLine.content || '';
|
|
1710
1961
|
const lineNum = diffLine.newLine || diffLine.oldLine;
|
|
1711
1962
|
|
|
1963
|
+
const edited = commentBeingEdited?.file === currentFile ? commentBeingEdited : null;
|
|
1964
|
+
commentBeingEdited = null;
|
|
1965
|
+
|
|
1712
1966
|
comments.push({
|
|
1967
|
+
key: edited?.key ?? mintKey(),
|
|
1713
1968
|
file: currentFile,
|
|
1714
1969
|
diffLineIndex: diffLineIndex,
|
|
1715
1970
|
line: lineNum,
|
|
@@ -1717,7 +1972,7 @@ function saveComment(diffLineIndex, selectedText = null) {
|
|
|
1717
1972
|
selectedText: selectedText,
|
|
1718
1973
|
text: text,
|
|
1719
1974
|
matchType: 'exact', // New comments are exact matches
|
|
1720
|
-
followUps: []
|
|
1975
|
+
followUps: edited?.followUps ?? []
|
|
1721
1976
|
});
|
|
1722
1977
|
|
|
1723
1978
|
// Auto-save to backend
|
|
@@ -1745,6 +2000,7 @@ function editComment(diffLineIndex) {
|
|
|
1745
2000
|
|
|
1746
2001
|
// Remove the comment from the list temporarily
|
|
1747
2002
|
comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
|
|
2003
|
+
commentBeingEdited = comment;
|
|
1748
2004
|
|
|
1749
2005
|
// Reload file and show edit input
|
|
1750
2006
|
const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
|
|
@@ -1768,7 +2024,7 @@ function editComment(diffLineIndex) {
|
|
|
1768
2024
|
<textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput">${escapeHtml(comment.text)}</textarea>
|
|
1769
2025
|
<div class="actions">
|
|
1770
2026
|
<button${selectedTextData(selectedText)} onclick="saveComment(${Number(diffLineIndex)}, this.dataset.selectedText ?? null)">Save Comment</button>
|
|
1771
|
-
<button class="cancel-btn" onclick="
|
|
2027
|
+
<button class="cancel-btn" onclick="cancelEdit()">Cancel</button>
|
|
1772
2028
|
</div>
|
|
1773
2029
|
`;
|
|
1774
2030
|
|
|
@@ -1794,16 +2050,29 @@ function editComment(diffLineIndex) {
|
|
|
1794
2050
|
}
|
|
1795
2051
|
if (e.key === 'Escape') {
|
|
1796
2052
|
e.preventDefault();
|
|
1797
|
-
|
|
1798
|
-
// Restore the comment if canceled
|
|
1799
|
-
comments.push(comment);
|
|
1800
|
-
const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
|
|
1801
|
-
loadFile(currentFile, fileIndex);
|
|
2053
|
+
cancelEdit();
|
|
1802
2054
|
}
|
|
1803
2055
|
});
|
|
1804
2056
|
});
|
|
1805
2057
|
}
|
|
1806
2058
|
|
|
2059
|
+
/**
|
|
2060
|
+
* Put back the comment whose edit was abandoned.
|
|
2061
|
+
*
|
|
2062
|
+
* Escape always did this; the Cancel button only closed the box, which left
|
|
2063
|
+
* the comment out of the list -- and the next save, from any edit at all,
|
|
2064
|
+
* deleted it from the review.
|
|
2065
|
+
*/
|
|
2066
|
+
function cancelEdit() {
|
|
2067
|
+
document.querySelector('.comment-input-box')?.remove();
|
|
2068
|
+
if (commentBeingEdited) {
|
|
2069
|
+
comments.push(commentBeingEdited);
|
|
2070
|
+
commentBeingEdited = null;
|
|
2071
|
+
}
|
|
2072
|
+
const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
|
|
2073
|
+
loadFile(currentFile, fileIndex);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
1807
2076
|
// Delete comment
|
|
1808
2077
|
function deleteComment(diffLineIndex) {
|
|
1809
2078
|
comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
|
|
@@ -1952,11 +2221,16 @@ async function cleanupRepo() {
|
|
|
1952
2221
|
|
|
1953
2222
|
// Reset application
|
|
1954
2223
|
function resetApp() {
|
|
2224
|
+
closeReviewEvents();
|
|
2225
|
+
closeSearch();
|
|
2226
|
+
document.getElementById('searchButton')?.classList.add('hidden');
|
|
1955
2227
|
currentRepoId = null;
|
|
1956
2228
|
currentFiles = [];
|
|
1957
2229
|
currentFile = null;
|
|
1958
2230
|
currentFileIndex = -1;
|
|
1959
2231
|
comments = [];
|
|
2232
|
+
commentsLastUpdated = null;
|
|
2233
|
+
commentsBase = [];
|
|
1960
2234
|
|
|
1961
2235
|
document.getElementById('repoPath').value = '';
|
|
1962
2236
|
setRepoButton(null);
|
|
@@ -2311,6 +2585,14 @@ function updateCommentsSidebar() {
|
|
|
2311
2585
|
const elsewhereHtml = elsewhere
|
|
2312
2586
|
? '<div class="comment-item-elsewhere-note">Not in this view</div>'
|
|
2313
2587
|
: '';
|
|
2588
|
+
const replies = (comment.followUps ?? []).length;
|
|
2589
|
+
const answering = threadsBeingAnswered().has(commentKey(comment)) || agentState.partials[commentKey(comment)];
|
|
2590
|
+
const answeredBy = lastAuthorOf(comment);
|
|
2591
|
+
const threadNote = answering
|
|
2592
|
+
? '<div class="comment-item-thread comment-item-answering">Claude is answering…</div>'
|
|
2593
|
+
: replies > 0
|
|
2594
|
+
? `<div class="comment-item-thread">${replies} ${replies === 1 ? 'reply' : 'replies'}${answeredBy ? ` · answered by ${escapeHtml(authorName(answeredBy))}` : ''}</div>`
|
|
2595
|
+
: '';
|
|
2314
2596
|
html += `
|
|
2315
2597
|
<div class="comment-item${elsewhere ? ' comment-item-elsewhere' : ''}">
|
|
2316
2598
|
<button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(${Number(commentIndex)})" title="Delete comment">×</button>
|
|
@@ -2319,6 +2601,7 @@ function updateCommentsSidebar() {
|
|
|
2319
2601
|
<div class="comment-item-line">Line ${comment.line}</div>
|
|
2320
2602
|
<div class="comment-item-text">${escapeHtml(comment.text)}</div>
|
|
2321
2603
|
${selectedHtml}
|
|
2604
|
+
${threadNote}
|
|
2322
2605
|
${elsewhereHtml}
|
|
2323
2606
|
</div>
|
|
2324
2607
|
</div>
|
|
@@ -2484,6 +2767,716 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
|
|
|
2484
2767
|
return map[key] || null;
|
|
2485
2768
|
}
|
|
2486
2769
|
|
|
2770
|
+
/*
|
|
2771
|
+
* Agents answering in the review's threads.
|
|
2772
|
+
*
|
|
2773
|
+
* A Claude Code session can be connected to the review -- in the background,
|
|
2774
|
+
* from the dialog below, or live from a terminal with `reviewer connect` --
|
|
2775
|
+
* and answers every comment and reply the reviewer writes, in the thread.
|
|
2776
|
+
* The answers arrive through the same comments file everything else does;
|
|
2777
|
+
* what the page adds is showing who is connected, which threads are being
|
|
2778
|
+
* answered, and an answer's words as they are written.
|
|
2779
|
+
*/
|
|
2780
|
+
|
|
2781
|
+
/**
|
|
2782
|
+
* @type {{agents: object[], answerer: object|null, partials: Object<string, string>, events: EventSource|null, sessions: object[]|null}}
|
|
2783
|
+
* `agents` are the presences the server reports (live, background or
|
|
2784
|
+
* watching); `answerer` is a background session this server runs;
|
|
2785
|
+
* `partials` holds answers still being written, by comment key.
|
|
2786
|
+
*/
|
|
2787
|
+
const agentState = { agents: [], answerer: null, partials: {}, events: null, sessions: null };
|
|
2788
|
+
|
|
2789
|
+
/** The display name for whoever wrote a follow-up. */
|
|
2790
|
+
function authorName(by) {
|
|
2791
|
+
return by === 'claude' ? 'Claude' : by;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
/** Who wrote the last word in a thread: null for the reviewer. Mirrors lib/threads.js. */
|
|
2795
|
+
function lastAuthorOf(comment) {
|
|
2796
|
+
const followUps = comment.followUps ?? [];
|
|
2797
|
+
return followUps.length === 0 ? null : (followUps[followUps.length - 1].by ?? null);
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
/** Keys of the threads some agent is answering right now. */
|
|
2801
|
+
function threadsBeingAnswered() {
|
|
2802
|
+
const keys = new Set();
|
|
2803
|
+
for (const agent of agentState.agents) (agent.working ?? []).forEach(key => keys.add(key));
|
|
2804
|
+
if (agentState.answerer?.working) keys.add(agentState.answerer.working);
|
|
2805
|
+
return keys;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/** Whether anything is connected to answer at all. */
|
|
2809
|
+
function agentConnected() {
|
|
2810
|
+
return agentState.agents.length > 0 || Boolean(agentState.answerer);
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
/**
|
|
2814
|
+
* A little Markdown, for answers: fenced code, inline code and bold. The text
|
|
2815
|
+
* is escaped first and only these three patterns are turned back into
|
|
2816
|
+
* markup, so nothing in an answer can become anything else on the page.
|
|
2817
|
+
*/
|
|
2818
|
+
function renderMarkdown(text) {
|
|
2819
|
+
const blocks = String(text ?? '').split(/^```[^\n]*\n?/m);
|
|
2820
|
+
return blocks.map((block, index) => {
|
|
2821
|
+
if (index % 2 === 1) return `<pre class="followup-code"><code>${escapeHtml(block.replace(/\n$/, ''))}</code></pre>`;
|
|
2822
|
+
return escapeHtml(block)
|
|
2823
|
+
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
2824
|
+
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
2825
|
+
}).join('');
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
/** One follow-up in a thread. */
|
|
2829
|
+
function followUpHtml(followUp) {
|
|
2830
|
+
const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
|
|
2831
|
+
if (followUp.by) {
|
|
2832
|
+
return `
|
|
2833
|
+
<div class="comment-followup-item followup-agent">
|
|
2834
|
+
<span class="followup-author">${escapeHtml(authorName(followUp.by))}</span>
|
|
2835
|
+
<div class="followup-text followup-markdown">${renderMarkdown(followUp.text)}</div>
|
|
2836
|
+
${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
|
|
2837
|
+
</div>
|
|
2838
|
+
`;
|
|
2839
|
+
}
|
|
2840
|
+
return `
|
|
2841
|
+
<div class="comment-followup-item">
|
|
2842
|
+
<span class="followup-text">${escapeHtml(followUp.text)}</span>
|
|
2843
|
+
${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
|
|
2844
|
+
</div>
|
|
2845
|
+
`;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/** The answer being written in a thread, or a note that one is coming. */
|
|
2849
|
+
function pendingAnswerHtml(comment) {
|
|
2850
|
+
const key = commentKey(comment);
|
|
2851
|
+
const partial = agentState.partials[key];
|
|
2852
|
+
if (threadsBeingAnswered().has(key) || partial) {
|
|
2853
|
+
return `
|
|
2854
|
+
<div class="comment-followup-item followup-agent followup-pending" data-pending-key="${escapeHtml(key)}">
|
|
2855
|
+
<span class="followup-author">Claude</span>
|
|
2856
|
+
<div class="followup-text followup-markdown">${partial ? renderMarkdown(partial) : '<span class="agent-typing">answering…</span>'}</div>
|
|
2857
|
+
</div>
|
|
2858
|
+
`;
|
|
2859
|
+
}
|
|
2860
|
+
if (agentConnected() && lastAuthorOf(comment) === null) {
|
|
2861
|
+
return '<div class="followup-queued">Waiting for Claude to answer</div>';
|
|
2862
|
+
}
|
|
2863
|
+
return '';
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/** A comment's follow-ups, and whatever answer is on its way. */
|
|
2867
|
+
function threadHtml(comment) {
|
|
2868
|
+
const items = (comment.followUps ?? []).map(followUpHtml).join('');
|
|
2869
|
+
const pending = pendingAnswerHtml(comment);
|
|
2870
|
+
if (!items && !pending) return '';
|
|
2871
|
+
return `<div class="comment-followups">${items}${pending}</div>`;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
/**
|
|
2875
|
+
* Put an answer's latest words on screen without redrawing the pane: the
|
|
2876
|
+
* pane may hold a box someone is typing in, and a partial arrives many times
|
|
2877
|
+
* a second.
|
|
2878
|
+
*/
|
|
2879
|
+
function showPartialAnswer(key) {
|
|
2880
|
+
const box = document.querySelector(`.comment-box[data-key="${CSS.escape(key)}"], .unmatched-comment-item[data-key="${CSS.escape(key)}"]`);
|
|
2881
|
+
if (!box) return;
|
|
2882
|
+
const comment = comments.find(c => commentKey(c) === key);
|
|
2883
|
+
if (!comment) return;
|
|
2884
|
+
const pending = box.querySelector('.followup-pending .followup-text');
|
|
2885
|
+
if (pending) {
|
|
2886
|
+
pending.innerHTML = renderMarkdown(agentState.partials[key]);
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
const thread = box.querySelector('.comment-followups');
|
|
2890
|
+
const html = threadHtml(comment);
|
|
2891
|
+
if (thread) thread.outerHTML = html;
|
|
2892
|
+
else box.querySelector('.comment-text')?.insertAdjacentHTML('afterend', html);
|
|
2893
|
+
}
|
|
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
|
+
|
|
2900
|
+
/** Redraw every thread's pending state after who-is-answering-what changed. */
|
|
2901
|
+
function refreshPendingAnswers() {
|
|
2902
|
+
renderAgentButton();
|
|
2903
|
+
const signature = JSON.stringify([agentConnected(), [...threadsBeingAnswered()].sort()]);
|
|
2904
|
+
if (signature === pendingSignature) return;
|
|
2905
|
+
pendingSignature = signature;
|
|
2906
|
+
updateCommentsSidebar();
|
|
2907
|
+
commentsPaneStale = true;
|
|
2908
|
+
redrawCommentsPane();
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
/** The header button: whether anything is answering, and what it is doing. */
|
|
2912
|
+
function renderAgentButton() {
|
|
2913
|
+
const button = document.getElementById('agentButton');
|
|
2914
|
+
const label = document.getElementById('agentButtonLabel');
|
|
2915
|
+
if (!button || !label) return;
|
|
2916
|
+
button.classList.toggle('hidden', !currentRepoId);
|
|
2917
|
+
|
|
2918
|
+
const working = threadsBeingAnswered().size;
|
|
2919
|
+
const live = agentState.agents.find(agent => agent.kind === 'channel');
|
|
2920
|
+
const connected = agentConnected();
|
|
2921
|
+
button.classList.toggle('connected', connected);
|
|
2922
|
+
button.classList.toggle('working', working > 0);
|
|
2923
|
+
if (!connected) {
|
|
2924
|
+
label.textContent = 'Ask Claude Code';
|
|
2925
|
+
button.title = 'Have the Claude Code session that wrote this code answer your comments';
|
|
2926
|
+
} else {
|
|
2927
|
+
label.textContent = working > 0 ? 'Claude is answering…' : 'Claude Code connected';
|
|
2928
|
+
const title = live?.title ?? agentState.answerer?.title ?? agentState.agents[0]?.title;
|
|
2929
|
+
const how = live ? 'Live, from your terminal'
|
|
2930
|
+
: agentState.answerer ? 'In the background'
|
|
2931
|
+
: 'Following the review from an agent session';
|
|
2932
|
+
button.title = `${how}${title ? `: ${title}` : ''}`;
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
/** Follow the open review's live feed. One per tab, replaced with the repository. */
|
|
2937
|
+
function openReviewEvents(repoId) {
|
|
2938
|
+
closeReviewEvents();
|
|
2939
|
+
if (typeof EventSource === 'undefined') return;
|
|
2940
|
+
const events = new EventSource(`${API_BASE}/events/${repoId}`);
|
|
2941
|
+
agentState.events = events;
|
|
2942
|
+
// The feed counts as this tab watching once it is open; the separate
|
|
2943
|
+
// signal is closed only then, so there is no moment with neither.
|
|
2944
|
+
events.addEventListener('open', stopWatchingFromThisTab, { once: true });
|
|
2945
|
+
|
|
2946
|
+
const parse = event => {
|
|
2947
|
+
try {
|
|
2948
|
+
return JSON.parse(event.data);
|
|
2949
|
+
} catch {
|
|
2950
|
+
return null;
|
|
2951
|
+
}
|
|
2952
|
+
};
|
|
2953
|
+
|
|
2954
|
+
events.addEventListener('comments', event => {
|
|
2955
|
+
const data = parse(event);
|
|
2956
|
+
if (!data || currentRepoId !== repoId) return;
|
|
2957
|
+
if ((data.lastUpdated ?? null) === commentsLastUpdated) return;
|
|
2958
|
+
adoptRemoteComments(Array.isArray(data.comments) ? data.comments : [], data.lastUpdated ?? null);
|
|
2959
|
+
});
|
|
2960
|
+
events.addEventListener('agents', event => {
|
|
2961
|
+
const data = parse(event);
|
|
2962
|
+
if (!data || currentRepoId !== repoId) return;
|
|
2963
|
+
agentState.agents = Array.isArray(data.agents) ? data.agents : [];
|
|
2964
|
+
refreshPendingAnswers();
|
|
2965
|
+
});
|
|
2966
|
+
events.addEventListener('answerer', event => {
|
|
2967
|
+
if (currentRepoId !== repoId) return;
|
|
2968
|
+
agentState.answerer = parse(event);
|
|
2969
|
+
refreshPendingAnswers();
|
|
2970
|
+
});
|
|
2971
|
+
events.addEventListener('partial', event => {
|
|
2972
|
+
const data = parse(event);
|
|
2973
|
+
if (!data || currentRepoId !== repoId || !data.key) return;
|
|
2974
|
+
agentState.partials[data.key] = data.text;
|
|
2975
|
+
showPartialAnswer(data.key);
|
|
2976
|
+
});
|
|
2977
|
+
events.addEventListener('agent-error', event => {
|
|
2978
|
+
const data = parse(event);
|
|
2979
|
+
if (!data || currentRepoId !== repoId) return;
|
|
2980
|
+
if (data.key) delete agentState.partials[data.key];
|
|
2981
|
+
showStatus(`Claude Code: ${data.message}`, 'error');
|
|
2982
|
+
refreshPendingAnswers();
|
|
2983
|
+
});
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
function closeReviewEvents() {
|
|
2987
|
+
if (agentState.events) watchFromThisTab();
|
|
2988
|
+
agentState.events?.close();
|
|
2989
|
+
agentState.events = null;
|
|
2990
|
+
agentState.agents = [];
|
|
2991
|
+
agentState.answerer = null;
|
|
2992
|
+
agentState.partials = {};
|
|
2993
|
+
renderAgentButton();
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
/** How long ago, briefly. */
|
|
2997
|
+
function sinceText(iso) {
|
|
2998
|
+
return timeAgo(iso) || new Date(iso).toLocaleString();
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
async function openAgentDialog() {
|
|
3002
|
+
if (!currentRepoId) return;
|
|
3003
|
+
document.getElementById('agentModal').classList.remove('hidden');
|
|
3004
|
+
const list = document.getElementById('agentSessions');
|
|
3005
|
+
list.innerHTML = '<p class="agent-empty">Looking for Claude Code sessions…</p>';
|
|
3006
|
+
renderAgentConnection();
|
|
3007
|
+
|
|
3008
|
+
try {
|
|
3009
|
+
const response = await fetch(`${API_BASE}/agent/sessions/${currentRepoId}`);
|
|
3010
|
+
const data = await response.json();
|
|
3011
|
+
if (!response.ok) throw new Error(data.error || 'Could not list sessions');
|
|
3012
|
+
agentState.sessions = data.sessions;
|
|
3013
|
+
renderAgentSessions();
|
|
3014
|
+
} catch (error) {
|
|
3015
|
+
list.innerHTML = `<p class="agent-empty">${escapeHtml(error.message)}</p>`;
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
function closeAgentDialog() {
|
|
3020
|
+
document.getElementById('agentModal').classList.add('hidden');
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
/** What is connected now, with a way to stop the background session. */
|
|
3024
|
+
function renderAgentConnection() {
|
|
3025
|
+
const box = document.getElementById('agentConnected');
|
|
3026
|
+
const parts = [];
|
|
3027
|
+
for (const agent of agentState.agents.filter(a => a.kind !== 'background')) {
|
|
3028
|
+
parts.push(`<div class="agent-connection"><span class="agent-dot"></span>
|
|
3029
|
+
<div><strong>${agent.kind === 'channel' ? 'Live in your terminal' : 'Watching from an agent'}</strong>
|
|
3030
|
+
<div class="agent-connection-title">${escapeHtml(agent.title ?? (agent.session ? `session ${agent.session.slice(0, 8)}` : 'Claude Code'))}</div></div></div>`);
|
|
3031
|
+
}
|
|
3032
|
+
if (agentState.answerer) {
|
|
3033
|
+
const a = agentState.answerer;
|
|
3034
|
+
parts.push(`<div class="agent-connection"><span class="agent-dot"></span>
|
|
3035
|
+
<div><strong>Answering in the background</strong>
|
|
3036
|
+
<div class="agent-connection-title">${escapeHtml(a.title ?? a.session)}</div>
|
|
3037
|
+
<div class="agent-connection-meta">${a.forked ? `In a copy of ${escapeHtml(a.session.slice(0, 8))}${a.answeringIn && a.answeringIn !== a.session ? ` (${escapeHtml(a.answeringIn.slice(0, 8))})` : ''}; the original is untouched.` : `In session ${escapeHtml(a.session.slice(0, 8))} itself.`}</div></div>
|
|
3038
|
+
<button class="agent-disconnect" onclick="disconnectAgent()">Stop</button></div>`);
|
|
3039
|
+
}
|
|
3040
|
+
box.innerHTML = parts.join('');
|
|
3041
|
+
box.classList.toggle('hidden', parts.length === 0);
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
function renderAgentSessions() {
|
|
3045
|
+
const list = document.getElementById('agentSessions');
|
|
3046
|
+
const sessions = agentState.sessions ?? [];
|
|
3047
|
+
if (sessions.length === 0) {
|
|
3048
|
+
list.innerHTML = '<p class="agent-empty">No Claude Code session has worked in this repository yet. ' +
|
|
3049
|
+
'Sessions are found under <code>~/.claude/projects</code> by the directory they ran in.</p>';
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
list.innerHTML = sessions.map((session, index) => {
|
|
3053
|
+
const label = session.title ?? session.firstPrompt ?? '(no prompt)';
|
|
3054
|
+
const copy = session.title?.startsWith('Review answers:');
|
|
3055
|
+
return `
|
|
3056
|
+
<div class="agent-session${copy ? ' agent-session-copy' : ''}">
|
|
3057
|
+
<div class="agent-session-text">
|
|
3058
|
+
<div class="agent-session-title">${escapeHtml(label)}</div>
|
|
3059
|
+
<div class="agent-session-meta">${escapeHtml(session.id.slice(0, 8))}${session.branch ? ` · ${escapeHtml(session.branch)}` : ''} · ${escapeHtml(sinceText(session.updatedAt))}</div>
|
|
3060
|
+
</div>
|
|
3061
|
+
<div class="agent-session-actions">
|
|
3062
|
+
<button class="agent-connect" onclick="connectAgent(${Number(index)})">Answer my comments</button>
|
|
3063
|
+
<button class="agent-copy" onclick="copyConnectCommand(${Number(index)})" title="Copy a command that opens this session live in a terminal">Copy live command</button>
|
|
3064
|
+
</div>
|
|
3065
|
+
</div>
|
|
3066
|
+
`;
|
|
3067
|
+
}).join('');
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
async function connectAgent(index) {
|
|
3071
|
+
const session = agentState.sessions?.[Number(index)];
|
|
3072
|
+
if (!session || !currentRepoId) return;
|
|
3073
|
+
const fork = document.getElementById('agentFork').checked;
|
|
3074
|
+
try {
|
|
3075
|
+
const response = await fetch(`${API_BASE}/agent/connect`, {
|
|
3076
|
+
method: 'POST',
|
|
3077
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3078
|
+
body: JSON.stringify({ repoId: currentRepoId, sessionId: session.id, fork })
|
|
3079
|
+
});
|
|
3080
|
+
const data = await response.json();
|
|
3081
|
+
if (!response.ok) throw new Error(data.error || 'Could not connect');
|
|
3082
|
+
agentState.answerer = data.answerer;
|
|
3083
|
+
closeAgentDialog();
|
|
3084
|
+
refreshPendingAnswers();
|
|
3085
|
+
showStatus('Claude Code will answer your comments in their threads.', 'success');
|
|
3086
|
+
} catch (error) {
|
|
3087
|
+
showStatus(`Could not connect Claude Code: ${error.message}`, 'error');
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
async function disconnectAgent() {
|
|
3092
|
+
if (!currentRepoId) return;
|
|
3093
|
+
await fetch(`${API_BASE}/agent/disconnect`, {
|
|
3094
|
+
method: 'POST',
|
|
3095
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3096
|
+
body: JSON.stringify({ repoId: currentRepoId })
|
|
3097
|
+
}).catch(() => {});
|
|
3098
|
+
agentState.answerer = null;
|
|
3099
|
+
renderAgentConnection();
|
|
3100
|
+
refreshPendingAnswers();
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
async function copyConnectCommand(index) {
|
|
3104
|
+
const session = agentState.sessions?.[Number(index)];
|
|
3105
|
+
if (!session) return;
|
|
3106
|
+
const command = `reviewer connect --session ${session.id}`;
|
|
3107
|
+
try {
|
|
3108
|
+
await navigator.clipboard.writeText(command);
|
|
3109
|
+
showStatus(`Copied: ${command}`, 'success');
|
|
3110
|
+
} catch {
|
|
3111
|
+
showStatus(`Run this in a terminal: ${command}`, 'success');
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
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
|
+
|
|
2487
3480
|
// Allow Enter key to load repo
|
|
2488
3481
|
/**
|
|
2489
3482
|
* Tell the server this tab exists, by holding a connection open for as long
|
|
@@ -2498,17 +3491,35 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
|
|
|
2498
3491
|
* server's grace period work -- a flaky connection comes back inside it and
|
|
2499
3492
|
* nothing is stopped.
|
|
2500
3493
|
*/
|
|
3494
|
+
let aliveStream = null;
|
|
3495
|
+
|
|
2501
3496
|
function watchFromThisTab() {
|
|
3497
|
+
if (aliveStream) return;
|
|
2502
3498
|
try {
|
|
2503
|
-
new EventSource(`${API_BASE}/alive`);
|
|
3499
|
+
aliveStream = new EventSource(`${API_BASE}/alive`);
|
|
2504
3500
|
} catch {
|
|
2505
3501
|
// Older browsers, or a page opened from a file:// URL. Losing this costs
|
|
2506
3502
|
// an automatic exit, not a review, so there is nothing to report.
|
|
2507
3503
|
}
|
|
2508
3504
|
}
|
|
2509
3505
|
|
|
3506
|
+
/**
|
|
3507
|
+
* Hand the "this tab is open" signal over to the review's live feed, which
|
|
3508
|
+
* the server counts the same way. One long-lived connection per tab rather
|
|
3509
|
+
* than two: browsers allow six to a host, and every save needs one.
|
|
3510
|
+
*/
|
|
3511
|
+
function stopWatchingFromThisTab() {
|
|
3512
|
+
aliveStream?.close();
|
|
3513
|
+
aliveStream = null;
|
|
3514
|
+
}
|
|
3515
|
+
|
|
2510
3516
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2511
3517
|
watchFromThisTab();
|
|
3518
|
+
initSearch();
|
|
3519
|
+
|
|
3520
|
+
// A tab you were only reading catches up on other tabs' comments when it
|
|
3521
|
+
// comes back into view. See refreshCommentsFromElsewhere.
|
|
3522
|
+
document.addEventListener('visibilitychange', refreshCommentsFromElsewhere);
|
|
2512
3523
|
|
|
2513
3524
|
document.getElementById('repoPath').addEventListener('keypress', (e) => {
|
|
2514
3525
|
if (e.key === 'Enter') {
|