git-reviewer 2.14.1 → 2.16.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 +122 -4
- package/bin/reviewer.js +286 -7
- package/lib/agent.js +25 -5
- package/lib/answerer.js +191 -0
- package/lib/changes.js +83 -4
- 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 +197 -20
- package/lib/comments.js +175 -10
- package/lib/export.js +2 -2
- package/lib/presence.js +223 -0
- package/lib/review.js +3 -1
- package/lib/sessions.js +8 -4
- package/lib/store.js +290 -7
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +58 -0
- package/package.json +1 -1
- package/public/app.js +811 -119
- package/public/index.html +33 -0
- package/public/style.css +277 -0
- package/server.js +338 -43
package/public/app.js
CHANGED
|
@@ -3,31 +3,315 @@
|
|
|
3
3
|
// In Electron, this will be the random port Electron assigned
|
|
4
4
|
const API_BASE = `${window.location.origin}/api`;
|
|
5
5
|
const stagedByDefault = new URLSearchParams(window.location.search).get('staged') === '1';
|
|
6
|
+
const untrackedByDefault = new URLSearchParams(window.location.search).get('untracked') !== '0';
|
|
6
7
|
|
|
7
8
|
let currentRepoId = null;
|
|
8
9
|
let currentFiles = [];
|
|
9
10
|
let currentFile = null;
|
|
10
11
|
let currentDiffLines = [];
|
|
11
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
|
+
}
|
|
12
170
|
|
|
13
171
|
// Auto-save comments to backend
|
|
14
172
|
async function saveCommentsToBackend() {
|
|
15
173
|
if (!currentRepoId) return;
|
|
16
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;
|
|
17
244
|
try {
|
|
18
|
-
await fetch(`${API_BASE}/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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();
|
|
28
258
|
}
|
|
29
259
|
}
|
|
30
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];
|
|
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;
|
|
313
|
+
}
|
|
314
|
+
|
|
31
315
|
// Load comments from backend
|
|
32
316
|
async function loadCommentsFromBackend() {
|
|
33
317
|
if (!currentRepoId) return [];
|
|
@@ -35,6 +319,8 @@ async function loadCommentsFromBackend() {
|
|
|
35
319
|
try {
|
|
36
320
|
const response = await fetch(`${API_BASE}/load-comments/${currentRepoId}`);
|
|
37
321
|
const data = await response.json();
|
|
322
|
+
commentsLastUpdated = data.lastUpdated ?? null;
|
|
323
|
+
commentsBase = snapshotComments(data.comments || []);
|
|
38
324
|
return data.comments || [];
|
|
39
325
|
} catch (error) {
|
|
40
326
|
console.error('Failed to load comments:', error);
|
|
@@ -495,8 +781,15 @@ function renderScope(data) {
|
|
|
495
781
|
} else {
|
|
496
782
|
label.append(data.mode === 'staged' ? 'Staged changes' : 'Uncommitted changes');
|
|
497
783
|
if (branch) label.append(' on ', strong(branch));
|
|
498
|
-
|
|
499
|
-
|
|
784
|
+
const folders = data.files.filter(file => file.directory);
|
|
785
|
+
const foldedFiles = folders.reduce((sum, file) => sum + file.fileCount, 0);
|
|
786
|
+
meta.textContent = plural(data.files.length - folders.length, 'file') + ' changed' + (folders.length
|
|
787
|
+
? `; ${plural(folders.length, 'folder')} folded (${foldedFiles.toLocaleString('en-US')} untracked files)`
|
|
788
|
+
: '');
|
|
789
|
+
setNote(data.mode === 'staged'
|
|
790
|
+
? 'The diff excludes unstaged changes. Saved comments are shared between views and may refer to other code; check their anchors.'
|
|
791
|
+
: data.files.some(file => file.directory) ? data.message
|
|
792
|
+
: data.untracked === false ? 'Untracked files are excluded.' : null);
|
|
500
793
|
}
|
|
501
794
|
|
|
502
795
|
row.classList.add('hidden');
|
|
@@ -782,7 +1075,7 @@ async function loadRepo(scope = {}) {
|
|
|
782
1075
|
body: JSON.stringify(
|
|
783
1076
|
scope.commits ? { repoPath, commits: scope.commits }
|
|
784
1077
|
: scope.base ? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
|
|
785
|
-
: { repoPath, staged: stagedByDefault }
|
|
1078
|
+
: { repoPath, staged: stagedByDefault, ...(untrackedByDefault ? {} : { untracked: false }) }
|
|
786
1079
|
)
|
|
787
1080
|
});
|
|
788
1081
|
|
|
@@ -818,13 +1111,13 @@ async function loadRepo(scope = {}) {
|
|
|
818
1111
|
// `message`, and the scope bar above is still showing how many commits
|
|
819
1112
|
// that covers -- so this is information, not an error, and it must not
|
|
820
1113
|
// be dressed as one.
|
|
821
|
-
if (data.mode === 'range' || data.mode === 'staged') {
|
|
1114
|
+
if (data.mode === 'range' || data.mode === 'staged' || data.untracked === false) {
|
|
822
1115
|
// The scope bar is already explaining this; a status line repeating
|
|
823
1116
|
// it underneath is two bars saying one thing.
|
|
824
1117
|
clearStatus();
|
|
825
1118
|
hidePicker();
|
|
826
1119
|
displayFiles([]);
|
|
827
|
-
showEmptyFileList(data.mode === 'staged' ? 'No staged changes.' : data.range?.kind !== 'commits'
|
|
1120
|
+
showEmptyFileList(data.mode === 'staged' ? 'No staged changes.' : data.mode === 'working' ? 'No tracked changes.' : data.range?.kind !== 'commits'
|
|
828
1121
|
? 'No files differ between these two.'
|
|
829
1122
|
: data.range.first === data.range.last
|
|
830
1123
|
? 'This commit changes no files.'
|
|
@@ -873,6 +1166,10 @@ async function loadRepo(scope = {}) {
|
|
|
873
1166
|
showStatus(`Picked up ${plural(savedComments.length, 'saved comment')} from last time.`, 'success');
|
|
874
1167
|
}
|
|
875
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
|
+
|
|
876
1173
|
// Open something. A loaded repository used to sit behind an empty pane
|
|
877
1174
|
// until a file was clicked, which reads as a failure rather than as a
|
|
878
1175
|
// result (#47). Done here rather than in `displayFiles` because the saved
|
|
@@ -882,7 +1179,8 @@ async function loadRepo(scope = {}) {
|
|
|
882
1179
|
// repository does not move you off what you were reading.
|
|
883
1180
|
if (currentFiles.length > 0) {
|
|
884
1181
|
const previous = currentFiles.findIndex(file => file.path === currentFile);
|
|
885
|
-
const
|
|
1182
|
+
const firstFile = currentFiles.findIndex(file => !file.directory);
|
|
1183
|
+
const opening = previous === -1 ? Math.max(0, firstFile) : previous;
|
|
886
1184
|
|
|
887
1185
|
loadFile(currentFiles[opening].path, opening);
|
|
888
1186
|
offerHelpOnce();
|
|
@@ -927,7 +1225,7 @@ function displayFiles(files) {
|
|
|
927
1225
|
filesList.innerHTML = files.map((fileObj, index) => {
|
|
928
1226
|
const file = fileObj.path;
|
|
929
1227
|
const status = fileObj.status;
|
|
930
|
-
const parts = file.split('/');
|
|
1228
|
+
const parts = (fileObj.directory ? file.slice(0, -1) : file).split('/');
|
|
931
1229
|
const filename = parts[parts.length - 1];
|
|
932
1230
|
const path = parts.slice(0, -1).join('/');
|
|
933
1231
|
|
|
@@ -936,9 +1234,10 @@ function displayFiles(files) {
|
|
|
936
1234
|
// both halves are escaped going in. They were not, and a name is chosen by
|
|
937
1235
|
// whatever repository is open: markup in one was drawn as markup, without
|
|
938
1236
|
// anyone clicking anything.
|
|
939
|
-
|
|
1237
|
+
let displayText = path
|
|
940
1238
|
? `${escapeHtml(path)}/<span class="filename">${escapeHtml(filename)}</span>`
|
|
941
1239
|
: `<span class="filename">${escapeHtml(filename)}</span>`;
|
|
1240
|
+
if (fileObj.directory) displayText = `<bdi dir="ltr">${displayText}/</bdi>`;
|
|
942
1241
|
const statusClass = fileStatusClass(status);
|
|
943
1242
|
// One letter is git's vocabulary, not everyone's. The name rides along as
|
|
944
1243
|
// a tooltip and as the accessible label, so "D" is never the only thing
|
|
@@ -959,11 +1258,16 @@ function displayFiles(files) {
|
|
|
959
1258
|
// true, and re-rendering the list is what loses the selected row.
|
|
960
1259
|
return `<div class="file-item" onclick="openFileAt(${Number(index)})" title="${escapeHtml(file)}">
|
|
961
1260
|
${statusBadge}<span class="file-path-text">${displayText}</span>
|
|
1261
|
+
${fileObj.directory ? `<span class="file-fold-count">${Number(fileObj.fileCount).toLocaleString('en-US')} files</span>` : ''}
|
|
962
1262
|
<span class="file-comment-count" data-file-index="${Number(index)}" hidden></span>
|
|
963
1263
|
</div>`;
|
|
964
1264
|
}).join('');
|
|
965
1265
|
|
|
966
|
-
|
|
1266
|
+
const folders = files.filter(file => file.directory).length;
|
|
1267
|
+
const individualFiles = files.length - folders;
|
|
1268
|
+
fileCount.textContent = `${individualFiles} file${individualFiles !== 1 ? 's' : ''}` + (folders
|
|
1269
|
+
? ` · ${folders} folder${folders !== 1 ? 's' : ''}`
|
|
1270
|
+
: '');
|
|
967
1271
|
sidebar.classList.remove('hidden');
|
|
968
1272
|
resizeHandle.classList.remove('hidden');
|
|
969
1273
|
submitBtn.classList.remove('hidden');
|
|
@@ -991,6 +1295,7 @@ function openFileAt(index) {
|
|
|
991
1295
|
// Load file content
|
|
992
1296
|
async function loadFile(filePath, index) {
|
|
993
1297
|
if (!currentRepoId) return Promise.resolve();
|
|
1298
|
+
const repoId = currentRepoId;
|
|
994
1299
|
|
|
995
1300
|
currentFile = filePath;
|
|
996
1301
|
currentFileIndex = index; // Track for keyboard navigation
|
|
@@ -1007,9 +1312,30 @@ async function loadFile(filePath, index) {
|
|
|
1007
1312
|
}
|
|
1008
1313
|
});
|
|
1009
1314
|
|
|
1315
|
+
const file = currentFiles[index];
|
|
1316
|
+
const fullContextButton = document.querySelector('.full-context-btn');
|
|
1317
|
+
if (fullContextButton) fullContextButton.disabled = !!file?.directory;
|
|
1318
|
+
if (file?.directory) {
|
|
1319
|
+
// A folded row describes a directory; there is no file or diff to fetch.
|
|
1320
|
+
currentDiffLines = [];
|
|
1321
|
+
document.getElementById('currentFile').textContent = filePath;
|
|
1322
|
+
const section = document.getElementById('codeSection');
|
|
1323
|
+
section.classList.remove('hidden');
|
|
1324
|
+
section.style.display = 'block';
|
|
1325
|
+
document.getElementById('codeViewer').innerHTML =
|
|
1326
|
+
`<div class="binary-notice">${Number(file.fileCount).toLocaleString('en-US')} untracked files` +
|
|
1327
|
+
'<span>This directory is folded because it contains more than 200 files. ' +
|
|
1328
|
+
'Individual files and diffs are not shown. Add generated output to .gitignore to exclude it from future reviews.</span></div>';
|
|
1329
|
+
updateFullContextButton();
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1010
1333
|
try {
|
|
1011
|
-
const response = await fetch(`${API_BASE}/file/${
|
|
1334
|
+
const response = await fetch(`${API_BASE}/file/${repoId}/${filePath}`);
|
|
1012
1335
|
const data = await response.json();
|
|
1336
|
+
// A directory can be selected while a previous file request is in flight.
|
|
1337
|
+
// Its no-diff pane must not be replaced with that file's editable lines.
|
|
1338
|
+
if (currentRepoId !== repoId || currentFile !== filePath) return;
|
|
1013
1339
|
|
|
1014
1340
|
if (!response.ok) {
|
|
1015
1341
|
throw new Error(data.error || 'Failed to load file');
|
|
@@ -1028,45 +1354,7 @@ async function loadFile(filePath, index) {
|
|
|
1028
1354
|
//
|
|
1029
1355
|
// `comments` is what exists. A comment that is not in it does not exist.
|
|
1030
1356
|
if (comments.length > 0) {
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
// Update existing comments in the array with matched diff indices
|
|
1034
|
-
matchedComments.forEach(mc => {
|
|
1035
|
-
const existing = comments.find(c =>
|
|
1036
|
-
c.file === mc.file &&
|
|
1037
|
-
c.line === mc.line &&
|
|
1038
|
-
c.text === mc.text
|
|
1039
|
-
);
|
|
1040
|
-
|
|
1041
|
-
if (existing) {
|
|
1042
|
-
// Update existing comment with new diff line index and match type
|
|
1043
|
-
existing.diffLineIndex = mc.diffLineIndex;
|
|
1044
|
-
existing.matchType = mc.matchType;
|
|
1045
|
-
existing.newLineContent = mc.newLineContent;
|
|
1046
|
-
// Preserve followUps from saved data
|
|
1047
|
-
if (mc.followUps) {
|
|
1048
|
-
existing.followUps = mc.followUps;
|
|
1049
|
-
}
|
|
1050
|
-
} else {
|
|
1051
|
-
// Unreachable now that the matcher is fed the live list: everything
|
|
1052
|
-
// it returns came from there. Kept as a guard rather than removed,
|
|
1053
|
-
// because silently dropping a comment is the worse failure of the
|
|
1054
|
-
// two, and this is the line that would do it.
|
|
1055
|
-
// Add new comment if it doesn't exist
|
|
1056
|
-
comments.push({
|
|
1057
|
-
file: mc.file,
|
|
1058
|
-
diffLineIndex: mc.diffLineIndex,
|
|
1059
|
-
line: mc.line,
|
|
1060
|
-
lineContent: mc.lineContent,
|
|
1061
|
-
selectedText: mc.selectedText,
|
|
1062
|
-
text: mc.text,
|
|
1063
|
-
matchType: mc.matchType,
|
|
1064
|
-
newLineContent: mc.newLineContent,
|
|
1065
|
-
followUps: mc.followUps || []
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1357
|
+
matchCommentsToFile(filePath, data.diffLines);
|
|
1070
1358
|
// Update comments sidebar after matching comments
|
|
1071
1359
|
updateCommentsSidebar();
|
|
1072
1360
|
}
|
|
@@ -1076,11 +1364,41 @@ async function loadFile(filePath, index) {
|
|
|
1076
1364
|
return Promise.resolve();
|
|
1077
1365
|
|
|
1078
1366
|
} catch (error) {
|
|
1367
|
+
if (currentRepoId !== repoId || currentFile !== filePath) return;
|
|
1079
1368
|
showStatus(`Error loading file: ${error.message}`, 'error');
|
|
1080
1369
|
return Promise.reject(error);
|
|
1081
1370
|
}
|
|
1082
1371
|
}
|
|
1083
1372
|
|
|
1373
|
+
/**
|
|
1374
|
+
* Give this file's comments their position in the diff on screen.
|
|
1375
|
+
*
|
|
1376
|
+
* Comments are found by key, not by file, line and text: two identical
|
|
1377
|
+
* comments on one line are two comments, and an edited one is still itself.
|
|
1378
|
+
*
|
|
1379
|
+
* @param {string} filePath
|
|
1380
|
+
* @param {object[]} diffLines
|
|
1381
|
+
*/
|
|
1382
|
+
function matchCommentsToFile(filePath, diffLines) {
|
|
1383
|
+
const matchedComments = matchCommentsToDiff(comments, filePath, diffLines);
|
|
1384
|
+
|
|
1385
|
+
matchedComments.forEach(mc => {
|
|
1386
|
+
const existing = comments.find(c => commentKey(c) === commentKey(mc));
|
|
1387
|
+
|
|
1388
|
+
if (existing) {
|
|
1389
|
+
existing.diffLineIndex = mc.diffLineIndex;
|
|
1390
|
+
existing.matchType = mc.matchType;
|
|
1391
|
+
existing.newLineContent = mc.newLineContent;
|
|
1392
|
+
} else {
|
|
1393
|
+
// Unreachable now that the matcher is fed the live list: everything
|
|
1394
|
+
// it returns came from there. Kept as a guard rather than removed,
|
|
1395
|
+
// because silently dropping a comment is the worse failure of the
|
|
1396
|
+
// two, and this is the line that would do it.
|
|
1397
|
+
comments.push({ ...mc, followUps: mc.followUps || [] });
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1084
1402
|
// Display code with diff highlighting
|
|
1085
1403
|
function displayCode(filePath, diffLines, binary) {
|
|
1086
1404
|
const codeSection = document.getElementById('codeSection');
|
|
@@ -1089,6 +1407,7 @@ function displayCode(filePath, diffLines, binary) {
|
|
|
1089
1407
|
|
|
1090
1408
|
currentFileEl.textContent = filePath;
|
|
1091
1409
|
currentDiffLines = diffLines; // Store for reference when adding comments
|
|
1410
|
+
commentsPaneStale = false; // drawn from `comments` as they stand now
|
|
1092
1411
|
|
|
1093
1412
|
// A binary file has no lines to show and none to comment on. Saying so is
|
|
1094
1413
|
// the whole feature: it used to be decoded as UTF-8 and rendered as
|
|
@@ -1145,24 +1464,10 @@ function displayCode(filePath, diffLines, binary) {
|
|
|
1145
1464
|
</div>
|
|
1146
1465
|
` : '';
|
|
1147
1466
|
|
|
1148
|
-
|
|
1149
|
-
let followUpsHtml = '';
|
|
1150
|
-
if (hasComment.followUps && hasComment.followUps.length > 0) {
|
|
1151
|
-
followUpsHtml = '<div class="comment-followups">';
|
|
1152
|
-
hasComment.followUps.forEach((followUp, idx) => {
|
|
1153
|
-
const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
|
|
1154
|
-
followUpsHtml += `
|
|
1155
|
-
<div class="comment-followup-item">
|
|
1156
|
-
<span class="followup-text">${escapeHtml(followUp.text)}</span>
|
|
1157
|
-
${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
|
|
1158
|
-
</div>
|
|
1159
|
-
`;
|
|
1160
|
-
});
|
|
1161
|
-
followUpsHtml += '</div>';
|
|
1162
|
-
}
|
|
1467
|
+
const followUpsHtml = threadHtml(hasComment);
|
|
1163
1468
|
|
|
1164
1469
|
html += `
|
|
1165
|
-
<div class="comment-box ${matchTypeClass}">
|
|
1470
|
+
<div class="comment-box ${matchTypeClass}" data-key="${escapeHtml(commentKey(hasComment))}">
|
|
1166
1471
|
${fuzzyWarning}
|
|
1167
1472
|
<span class="comment-text">${escapeHtml(hasComment.text)}</span>
|
|
1168
1473
|
${followUpsHtml}
|
|
@@ -1182,21 +1487,7 @@ function displayCode(filePath, diffLines, binary) {
|
|
|
1182
1487
|
if (unmatchedComments.length > 0) {
|
|
1183
1488
|
html += '<div class="unmatched-comments-section">';
|
|
1184
1489
|
unmatchedComments.forEach((comment, unmatchedIndex) => {
|
|
1185
|
-
|
|
1186
|
-
let unmatchedFollowUpsHtml = '';
|
|
1187
|
-
if (comment.followUps && comment.followUps.length > 0) {
|
|
1188
|
-
unmatchedFollowUpsHtml = '<div class="comment-followups">';
|
|
1189
|
-
comment.followUps.forEach((followUp, idx) => {
|
|
1190
|
-
const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
|
|
1191
|
-
unmatchedFollowUpsHtml += `
|
|
1192
|
-
<div class="comment-followup-item">
|
|
1193
|
-
<span class="followup-text">${escapeHtml(followUp.text)}</span>
|
|
1194
|
-
${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
|
|
1195
|
-
</div>
|
|
1196
|
-
`;
|
|
1197
|
-
});
|
|
1198
|
-
unmatchedFollowUpsHtml += '</div>';
|
|
1199
|
-
}
|
|
1490
|
+
const unmatchedFollowUpsHtml = threadHtml(comment);
|
|
1200
1491
|
|
|
1201
1492
|
// Every control in here used to carry the comment's file name and text,
|
|
1202
1493
|
// quoted into an attribute that is evaluated as JavaScript -- with `'`
|
|
@@ -1206,7 +1497,7 @@ function displayCode(filePath, diffLines, binary) {
|
|
|
1206
1497
|
// rendered from. The row's random id went with it: the position is the
|
|
1207
1498
|
// identifier now.
|
|
1208
1499
|
html += `
|
|
1209
|
-
<div class="unmatched-comment-item collapsed" tabindex="0">
|
|
1500
|
+
<div class="unmatched-comment-item collapsed" tabindex="0" data-key="${escapeHtml(commentKey(comment))}">
|
|
1210
1501
|
<div class="unmatched-comment-header" onclick="toggleUnmatchedComment(${Number(unmatchedIndex)})">
|
|
1211
1502
|
<span class="unmatched-comment-icon">⚠️</span>
|
|
1212
1503
|
<span class="unmatched-comment-title">Comment not found (Line ${comment.line} changed)</span>
|
|
@@ -1364,15 +1655,7 @@ function saveFollowUp(diffLineIndex) {
|
|
|
1364
1655
|
const comment = comments.find(c => c.file === currentFile && c.diffLineIndex === diffLineIndex);
|
|
1365
1656
|
if (!comment) return;
|
|
1366
1657
|
|
|
1367
|
-
|
|
1368
|
-
if (!comment.followUps) {
|
|
1369
|
-
comment.followUps = [];
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
comment.followUps.push({
|
|
1373
|
-
text: text,
|
|
1374
|
-
timestamp: new Date().toISOString()
|
|
1375
|
-
});
|
|
1658
|
+
appendFollowUp(comment, text);
|
|
1376
1659
|
|
|
1377
1660
|
// Auto-save to backend
|
|
1378
1661
|
saveCommentsToBackend();
|
|
@@ -1466,15 +1749,7 @@ function saveFollowUpToUnmatched(index) {
|
|
|
1466
1749
|
const comment = unmatchedShown[Number(index)];
|
|
1467
1750
|
if (!comment) return;
|
|
1468
1751
|
|
|
1469
|
-
|
|
1470
|
-
if (!comment.followUps) {
|
|
1471
|
-
comment.followUps = [];
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
comment.followUps.push({
|
|
1475
|
-
text: followUpText,
|
|
1476
|
-
timestamp: new Date().toISOString()
|
|
1477
|
-
});
|
|
1752
|
+
appendFollowUp(comment, followUpText);
|
|
1478
1753
|
|
|
1479
1754
|
// Auto-save to backend
|
|
1480
1755
|
saveCommentsToBackend();
|
|
@@ -1654,6 +1929,17 @@ function toggleCommentInputWithSelection(diffLineIndex, selectedText = null) {
|
|
|
1654
1929
|
});
|
|
1655
1930
|
}
|
|
1656
1931
|
|
|
1932
|
+
/**
|
|
1933
|
+
* The comment being edited, while its box is open.
|
|
1934
|
+
*
|
|
1935
|
+
* Editing takes a comment out of the list and saving puts a new one back.
|
|
1936
|
+
* The new one used to start with an empty thread, so editing a comment that
|
|
1937
|
+
* had replies deleted every one of them -- and now that replies include an
|
|
1938
|
+
* agent's answers, that is the answers gone. Holding on to the original is
|
|
1939
|
+
* how the edited comment keeps its key and its thread.
|
|
1940
|
+
*/
|
|
1941
|
+
let commentBeingEdited = null;
|
|
1942
|
+
|
|
1657
1943
|
// Save comment
|
|
1658
1944
|
function saveComment(diffLineIndex, selectedText = null) {
|
|
1659
1945
|
const input = document.getElementById('commentInput');
|
|
@@ -1671,7 +1957,11 @@ function saveComment(diffLineIndex, selectedText = null) {
|
|
|
1671
1957
|
const lineContent = diffLine.content || '';
|
|
1672
1958
|
const lineNum = diffLine.newLine || diffLine.oldLine;
|
|
1673
1959
|
|
|
1960
|
+
const edited = commentBeingEdited?.file === currentFile ? commentBeingEdited : null;
|
|
1961
|
+
commentBeingEdited = null;
|
|
1962
|
+
|
|
1674
1963
|
comments.push({
|
|
1964
|
+
key: edited?.key ?? mintKey(),
|
|
1675
1965
|
file: currentFile,
|
|
1676
1966
|
diffLineIndex: diffLineIndex,
|
|
1677
1967
|
line: lineNum,
|
|
@@ -1679,7 +1969,7 @@ function saveComment(diffLineIndex, selectedText = null) {
|
|
|
1679
1969
|
selectedText: selectedText,
|
|
1680
1970
|
text: text,
|
|
1681
1971
|
matchType: 'exact', // New comments are exact matches
|
|
1682
|
-
followUps: []
|
|
1972
|
+
followUps: edited?.followUps ?? []
|
|
1683
1973
|
});
|
|
1684
1974
|
|
|
1685
1975
|
// Auto-save to backend
|
|
@@ -1707,6 +1997,7 @@ function editComment(diffLineIndex) {
|
|
|
1707
1997
|
|
|
1708
1998
|
// Remove the comment from the list temporarily
|
|
1709
1999
|
comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
|
|
2000
|
+
commentBeingEdited = comment;
|
|
1710
2001
|
|
|
1711
2002
|
// Reload file and show edit input
|
|
1712
2003
|
const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
|
|
@@ -1730,7 +2021,7 @@ function editComment(diffLineIndex) {
|
|
|
1730
2021
|
<textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput">${escapeHtml(comment.text)}</textarea>
|
|
1731
2022
|
<div class="actions">
|
|
1732
2023
|
<button${selectedTextData(selectedText)} onclick="saveComment(${Number(diffLineIndex)}, this.dataset.selectedText ?? null)">Save Comment</button>
|
|
1733
|
-
<button class="cancel-btn" onclick="
|
|
2024
|
+
<button class="cancel-btn" onclick="cancelEdit()">Cancel</button>
|
|
1734
2025
|
</div>
|
|
1735
2026
|
`;
|
|
1736
2027
|
|
|
@@ -1756,16 +2047,29 @@ function editComment(diffLineIndex) {
|
|
|
1756
2047
|
}
|
|
1757
2048
|
if (e.key === 'Escape') {
|
|
1758
2049
|
e.preventDefault();
|
|
1759
|
-
|
|
1760
|
-
// Restore the comment if canceled
|
|
1761
|
-
comments.push(comment);
|
|
1762
|
-
const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
|
|
1763
|
-
loadFile(currentFile, fileIndex);
|
|
2050
|
+
cancelEdit();
|
|
1764
2051
|
}
|
|
1765
2052
|
});
|
|
1766
2053
|
});
|
|
1767
2054
|
}
|
|
1768
2055
|
|
|
2056
|
+
/**
|
|
2057
|
+
* Put back the comment whose edit was abandoned.
|
|
2058
|
+
*
|
|
2059
|
+
* Escape always did this; the Cancel button only closed the box, which left
|
|
2060
|
+
* the comment out of the list -- and the next save, from any edit at all,
|
|
2061
|
+
* deleted it from the review.
|
|
2062
|
+
*/
|
|
2063
|
+
function cancelEdit() {
|
|
2064
|
+
document.querySelector('.comment-input-box')?.remove();
|
|
2065
|
+
if (commentBeingEdited) {
|
|
2066
|
+
comments.push(commentBeingEdited);
|
|
2067
|
+
commentBeingEdited = null;
|
|
2068
|
+
}
|
|
2069
|
+
const fileIndex = currentFiles.findIndex(f => f.path === currentFile);
|
|
2070
|
+
loadFile(currentFile, fileIndex);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1769
2073
|
// Delete comment
|
|
1770
2074
|
function deleteComment(diffLineIndex) {
|
|
1771
2075
|
comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
|
|
@@ -1914,11 +2218,14 @@ async function cleanupRepo() {
|
|
|
1914
2218
|
|
|
1915
2219
|
// Reset application
|
|
1916
2220
|
function resetApp() {
|
|
2221
|
+
closeReviewEvents();
|
|
1917
2222
|
currentRepoId = null;
|
|
1918
2223
|
currentFiles = [];
|
|
1919
2224
|
currentFile = null;
|
|
1920
2225
|
currentFileIndex = -1;
|
|
1921
2226
|
comments = [];
|
|
2227
|
+
commentsLastUpdated = null;
|
|
2228
|
+
commentsBase = [];
|
|
1922
2229
|
|
|
1923
2230
|
document.getElementById('repoPath').value = '';
|
|
1924
2231
|
setRepoButton(null);
|
|
@@ -1956,6 +2263,9 @@ let storedFullFileLines = null;
|
|
|
1956
2263
|
// Toggle between full context and diff-only view
|
|
1957
2264
|
async function showFullContext() {
|
|
1958
2265
|
if (!currentRepoId || !currentFile) return;
|
|
2266
|
+
if (currentFiles.some(file => file.path === currentFile && file.directory)) return;
|
|
2267
|
+
const repoId = currentRepoId;
|
|
2268
|
+
const filePath = currentFile;
|
|
1959
2269
|
|
|
1960
2270
|
if (isFullContextMode) {
|
|
1961
2271
|
// Switch back to diff view
|
|
@@ -1965,8 +2275,9 @@ async function showFullContext() {
|
|
|
1965
2275
|
} else {
|
|
1966
2276
|
// Switch to full context
|
|
1967
2277
|
try {
|
|
1968
|
-
const response = await fetch(`${API_BASE}/file-full/${
|
|
2278
|
+
const response = await fetch(`${API_BASE}/file-full/${repoId}/${filePath}`);
|
|
1969
2279
|
const data = await response.json();
|
|
2280
|
+
if (currentRepoId !== repoId || currentFile !== filePath) return;
|
|
1970
2281
|
|
|
1971
2282
|
if (!response.ok) {
|
|
1972
2283
|
throw new Error(data.error || 'Failed to load full file content');
|
|
@@ -1978,6 +2289,7 @@ async function showFullContext() {
|
|
|
1978
2289
|
updateFullContextButton();
|
|
1979
2290
|
|
|
1980
2291
|
} catch (error) {
|
|
2292
|
+
if (currentRepoId !== repoId || currentFile !== filePath) return;
|
|
1981
2293
|
showStatus(`Error loading full context: ${error.message}`, 'error');
|
|
1982
2294
|
}
|
|
1983
2295
|
}
|
|
@@ -2268,6 +2580,14 @@ function updateCommentsSidebar() {
|
|
|
2268
2580
|
const elsewhereHtml = elsewhere
|
|
2269
2581
|
? '<div class="comment-item-elsewhere-note">Not in this view</div>'
|
|
2270
2582
|
: '';
|
|
2583
|
+
const replies = (comment.followUps ?? []).length;
|
|
2584
|
+
const answering = threadsBeingAnswered().has(commentKey(comment)) || agentState.partials[commentKey(comment)];
|
|
2585
|
+
const answeredBy = lastAuthorOf(comment);
|
|
2586
|
+
const threadNote = answering
|
|
2587
|
+
? '<div class="comment-item-thread comment-item-answering">Claude is answering…</div>'
|
|
2588
|
+
: replies > 0
|
|
2589
|
+
? `<div class="comment-item-thread">${replies} ${replies === 1 ? 'reply' : 'replies'}${answeredBy ? ` · answered by ${escapeHtml(authorName(answeredBy))}` : ''}</div>`
|
|
2590
|
+
: '';
|
|
2271
2591
|
html += `
|
|
2272
2592
|
<div class="comment-item${elsewhere ? ' comment-item-elsewhere' : ''}">
|
|
2273
2593
|
<button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(${Number(commentIndex)})" title="Delete comment">×</button>
|
|
@@ -2276,6 +2596,7 @@ function updateCommentsSidebar() {
|
|
|
2276
2596
|
<div class="comment-item-line">Line ${comment.line}</div>
|
|
2277
2597
|
<div class="comment-item-text">${escapeHtml(comment.text)}</div>
|
|
2278
2598
|
${selectedHtml}
|
|
2599
|
+
${threadNote}
|
|
2279
2600
|
${elsewhereHtml}
|
|
2280
2601
|
</div>
|
|
2281
2602
|
</div>
|
|
@@ -2441,6 +2762,343 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
|
|
|
2441
2762
|
return map[key] || null;
|
|
2442
2763
|
}
|
|
2443
2764
|
|
|
2765
|
+
/*
|
|
2766
|
+
* Agents answering in the review's threads.
|
|
2767
|
+
*
|
|
2768
|
+
* A Claude Code session can be connected to the review -- in the background,
|
|
2769
|
+
* from the dialog below, or live from a terminal with `reviewer connect` --
|
|
2770
|
+
* and answers every comment and reply the reviewer writes, in the thread.
|
|
2771
|
+
* The answers arrive through the same comments file everything else does;
|
|
2772
|
+
* what the page adds is showing who is connected, which threads are being
|
|
2773
|
+
* answered, and an answer's words as they are written.
|
|
2774
|
+
*/
|
|
2775
|
+
|
|
2776
|
+
/**
|
|
2777
|
+
* @type {{agents: object[], answerer: object|null, partials: Object<string, string>, events: EventSource|null, sessions: object[]|null}}
|
|
2778
|
+
* `agents` are the presences the server reports (live, background or
|
|
2779
|
+
* watching); `answerer` is a background session this server runs;
|
|
2780
|
+
* `partials` holds answers still being written, by comment key.
|
|
2781
|
+
*/
|
|
2782
|
+
const agentState = { agents: [], answerer: null, partials: {}, events: null, sessions: null };
|
|
2783
|
+
|
|
2784
|
+
/** The display name for whoever wrote a follow-up. */
|
|
2785
|
+
function authorName(by) {
|
|
2786
|
+
return by === 'claude' ? 'Claude' : by;
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
/** Who wrote the last word in a thread: null for the reviewer. Mirrors lib/threads.js. */
|
|
2790
|
+
function lastAuthorOf(comment) {
|
|
2791
|
+
const followUps = comment.followUps ?? [];
|
|
2792
|
+
return followUps.length === 0 ? null : (followUps[followUps.length - 1].by ?? null);
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
/** Keys of the threads some agent is answering right now. */
|
|
2796
|
+
function threadsBeingAnswered() {
|
|
2797
|
+
const keys = new Set();
|
|
2798
|
+
for (const agent of agentState.agents) (agent.working ?? []).forEach(key => keys.add(key));
|
|
2799
|
+
if (agentState.answerer?.working) keys.add(agentState.answerer.working);
|
|
2800
|
+
return keys;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
/** Whether anything is connected to answer at all. */
|
|
2804
|
+
function agentConnected() {
|
|
2805
|
+
return agentState.agents.length > 0 || Boolean(agentState.answerer);
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/**
|
|
2809
|
+
* A little Markdown, for answers: fenced code, inline code and bold. The text
|
|
2810
|
+
* is escaped first and only these three patterns are turned back into
|
|
2811
|
+
* markup, so nothing in an answer can become anything else on the page.
|
|
2812
|
+
*/
|
|
2813
|
+
function renderMarkdown(text) {
|
|
2814
|
+
const blocks = String(text ?? '').split(/^```[^\n]*\n?/m);
|
|
2815
|
+
return blocks.map((block, index) => {
|
|
2816
|
+
if (index % 2 === 1) return `<pre class="followup-code"><code>${escapeHtml(block.replace(/\n$/, ''))}</code></pre>`;
|
|
2817
|
+
return escapeHtml(block)
|
|
2818
|
+
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
2819
|
+
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
2820
|
+
}).join('');
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
/** One follow-up in a thread. */
|
|
2824
|
+
function followUpHtml(followUp) {
|
|
2825
|
+
const timestamp = followUp.timestamp ? new Date(followUp.timestamp).toLocaleString() : '';
|
|
2826
|
+
if (followUp.by) {
|
|
2827
|
+
return `
|
|
2828
|
+
<div class="comment-followup-item followup-agent">
|
|
2829
|
+
<span class="followup-author">${escapeHtml(authorName(followUp.by))}</span>
|
|
2830
|
+
<div class="followup-text followup-markdown">${renderMarkdown(followUp.text)}</div>
|
|
2831
|
+
${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
|
|
2832
|
+
</div>
|
|
2833
|
+
`;
|
|
2834
|
+
}
|
|
2835
|
+
return `
|
|
2836
|
+
<div class="comment-followup-item">
|
|
2837
|
+
<span class="followup-text">${escapeHtml(followUp.text)}</span>
|
|
2838
|
+
${timestamp ? `<span class="followup-timestamp">${timestamp}</span>` : ''}
|
|
2839
|
+
</div>
|
|
2840
|
+
`;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
/** The answer being written in a thread, or a note that one is coming. */
|
|
2844
|
+
function pendingAnswerHtml(comment) {
|
|
2845
|
+
const key = commentKey(comment);
|
|
2846
|
+
const partial = agentState.partials[key];
|
|
2847
|
+
if (threadsBeingAnswered().has(key) || partial) {
|
|
2848
|
+
return `
|
|
2849
|
+
<div class="comment-followup-item followup-agent followup-pending" data-pending-key="${escapeHtml(key)}">
|
|
2850
|
+
<span class="followup-author">Claude</span>
|
|
2851
|
+
<div class="followup-text followup-markdown">${partial ? renderMarkdown(partial) : '<span class="agent-typing">answering…</span>'}</div>
|
|
2852
|
+
</div>
|
|
2853
|
+
`;
|
|
2854
|
+
}
|
|
2855
|
+
if (agentConnected() && lastAuthorOf(comment) === null) {
|
|
2856
|
+
return '<div class="followup-queued">Waiting for Claude to answer</div>';
|
|
2857
|
+
}
|
|
2858
|
+
return '';
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
/** A comment's follow-ups, and whatever answer is on its way. */
|
|
2862
|
+
function threadHtml(comment) {
|
|
2863
|
+
const items = (comment.followUps ?? []).map(followUpHtml).join('');
|
|
2864
|
+
const pending = pendingAnswerHtml(comment);
|
|
2865
|
+
if (!items && !pending) return '';
|
|
2866
|
+
return `<div class="comment-followups">${items}${pending}</div>`;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
/**
|
|
2870
|
+
* Put an answer's latest words on screen without redrawing the pane: the
|
|
2871
|
+
* pane may hold a box someone is typing in, and a partial arrives many times
|
|
2872
|
+
* a second.
|
|
2873
|
+
*/
|
|
2874
|
+
function showPartialAnswer(key) {
|
|
2875
|
+
const box = document.querySelector(`.comment-box[data-key="${CSS.escape(key)}"], .unmatched-comment-item[data-key="${CSS.escape(key)}"]`);
|
|
2876
|
+
if (!box) return;
|
|
2877
|
+
const comment = comments.find(c => commentKey(c) === key);
|
|
2878
|
+
if (!comment) return;
|
|
2879
|
+
const pending = box.querySelector('.followup-pending .followup-text');
|
|
2880
|
+
if (pending) {
|
|
2881
|
+
pending.innerHTML = renderMarkdown(agentState.partials[key]);
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
const thread = box.querySelector('.comment-followups');
|
|
2885
|
+
const html = threadHtml(comment);
|
|
2886
|
+
if (thread) thread.outerHTML = html;
|
|
2887
|
+
else box.querySelector('.comment-text')?.insertAdjacentHTML('afterend', html);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
/** Redraw every thread's pending state after who-is-answering-what changed. */
|
|
2891
|
+
function refreshPendingAnswers() {
|
|
2892
|
+
renderAgentButton();
|
|
2893
|
+
updateCommentsSidebar();
|
|
2894
|
+
commentsPaneStale = true;
|
|
2895
|
+
redrawCommentsPane();
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
/** The header button: whether anything is answering, and what it is doing. */
|
|
2899
|
+
function renderAgentButton() {
|
|
2900
|
+
const button = document.getElementById('agentButton');
|
|
2901
|
+
const label = document.getElementById('agentButtonLabel');
|
|
2902
|
+
if (!button || !label) return;
|
|
2903
|
+
button.classList.toggle('hidden', !currentRepoId);
|
|
2904
|
+
|
|
2905
|
+
const working = threadsBeingAnswered().size;
|
|
2906
|
+
const live = agentState.agents.find(agent => agent.kind === 'channel');
|
|
2907
|
+
const connected = agentConnected();
|
|
2908
|
+
button.classList.toggle('connected', connected);
|
|
2909
|
+
button.classList.toggle('working', working > 0);
|
|
2910
|
+
if (!connected) {
|
|
2911
|
+
label.textContent = 'Ask Claude Code';
|
|
2912
|
+
button.title = 'Have the Claude Code session that wrote this code answer your comments';
|
|
2913
|
+
} else {
|
|
2914
|
+
label.textContent = working > 0 ? 'Claude is answering…' : 'Claude Code connected';
|
|
2915
|
+
const title = live?.title ?? agentState.answerer?.title ?? agentState.agents[0]?.title;
|
|
2916
|
+
const how = live ? 'Live, from your terminal'
|
|
2917
|
+
: agentState.answerer ? 'In the background'
|
|
2918
|
+
: 'Following the review from an agent session';
|
|
2919
|
+
button.title = `${how}${title ? `: ${title}` : ''}`;
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/** Follow the open review's live feed. One per tab, replaced with the repository. */
|
|
2924
|
+
function openReviewEvents(repoId) {
|
|
2925
|
+
closeReviewEvents();
|
|
2926
|
+
if (typeof EventSource === 'undefined') return;
|
|
2927
|
+
const events = new EventSource(`${API_BASE}/events/${repoId}`);
|
|
2928
|
+
agentState.events = events;
|
|
2929
|
+
// The feed counts as this tab watching once it is open; the separate
|
|
2930
|
+
// signal is closed only then, so there is no moment with neither.
|
|
2931
|
+
events.addEventListener('open', stopWatchingFromThisTab, { once: true });
|
|
2932
|
+
|
|
2933
|
+
const parse = event => {
|
|
2934
|
+
try {
|
|
2935
|
+
return JSON.parse(event.data);
|
|
2936
|
+
} catch {
|
|
2937
|
+
return null;
|
|
2938
|
+
}
|
|
2939
|
+
};
|
|
2940
|
+
|
|
2941
|
+
events.addEventListener('comments', event => {
|
|
2942
|
+
const data = parse(event);
|
|
2943
|
+
if (!data || currentRepoId !== repoId) return;
|
|
2944
|
+
if ((data.lastUpdated ?? null) === commentsLastUpdated) return;
|
|
2945
|
+
adoptRemoteComments(Array.isArray(data.comments) ? data.comments : [], data.lastUpdated ?? null);
|
|
2946
|
+
});
|
|
2947
|
+
events.addEventListener('agents', event => {
|
|
2948
|
+
const data = parse(event);
|
|
2949
|
+
if (!data || currentRepoId !== repoId) return;
|
|
2950
|
+
agentState.agents = Array.isArray(data.agents) ? data.agents : [];
|
|
2951
|
+
refreshPendingAnswers();
|
|
2952
|
+
});
|
|
2953
|
+
events.addEventListener('answerer', event => {
|
|
2954
|
+
if (currentRepoId !== repoId) return;
|
|
2955
|
+
agentState.answerer = parse(event);
|
|
2956
|
+
refreshPendingAnswers();
|
|
2957
|
+
});
|
|
2958
|
+
events.addEventListener('partial', event => {
|
|
2959
|
+
const data = parse(event);
|
|
2960
|
+
if (!data || currentRepoId !== repoId || !data.key) return;
|
|
2961
|
+
agentState.partials[data.key] = data.text;
|
|
2962
|
+
showPartialAnswer(data.key);
|
|
2963
|
+
});
|
|
2964
|
+
events.addEventListener('agent-error', event => {
|
|
2965
|
+
const data = parse(event);
|
|
2966
|
+
if (!data || currentRepoId !== repoId) return;
|
|
2967
|
+
if (data.key) delete agentState.partials[data.key];
|
|
2968
|
+
showStatus(`Claude Code: ${data.message}`, 'error');
|
|
2969
|
+
refreshPendingAnswers();
|
|
2970
|
+
});
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
function closeReviewEvents() {
|
|
2974
|
+
if (agentState.events) watchFromThisTab();
|
|
2975
|
+
agentState.events?.close();
|
|
2976
|
+
agentState.events = null;
|
|
2977
|
+
agentState.agents = [];
|
|
2978
|
+
agentState.answerer = null;
|
|
2979
|
+
agentState.partials = {};
|
|
2980
|
+
renderAgentButton();
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
/** How long ago, briefly. */
|
|
2984
|
+
function sinceText(iso) {
|
|
2985
|
+
return timeAgo(iso) || new Date(iso).toLocaleString();
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
async function openAgentDialog() {
|
|
2989
|
+
if (!currentRepoId) return;
|
|
2990
|
+
document.getElementById('agentModal').classList.remove('hidden');
|
|
2991
|
+
const list = document.getElementById('agentSessions');
|
|
2992
|
+
list.innerHTML = '<p class="agent-empty">Looking for Claude Code sessions…</p>';
|
|
2993
|
+
renderAgentConnection();
|
|
2994
|
+
|
|
2995
|
+
try {
|
|
2996
|
+
const response = await fetch(`${API_BASE}/agent/sessions/${currentRepoId}`);
|
|
2997
|
+
const data = await response.json();
|
|
2998
|
+
if (!response.ok) throw new Error(data.error || 'Could not list sessions');
|
|
2999
|
+
agentState.sessions = data.sessions;
|
|
3000
|
+
renderAgentSessions();
|
|
3001
|
+
} catch (error) {
|
|
3002
|
+
list.innerHTML = `<p class="agent-empty">${escapeHtml(error.message)}</p>`;
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
function closeAgentDialog() {
|
|
3007
|
+
document.getElementById('agentModal').classList.add('hidden');
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
/** What is connected now, with a way to stop the background session. */
|
|
3011
|
+
function renderAgentConnection() {
|
|
3012
|
+
const box = document.getElementById('agentConnected');
|
|
3013
|
+
const parts = [];
|
|
3014
|
+
for (const agent of agentState.agents.filter(a => a.kind !== 'background')) {
|
|
3015
|
+
parts.push(`<div class="agent-connection"><span class="agent-dot"></span>
|
|
3016
|
+
<div><strong>${agent.kind === 'channel' ? 'Live in your terminal' : 'Watching from an agent'}</strong>
|
|
3017
|
+
<div class="agent-connection-title">${escapeHtml(agent.title ?? (agent.session ? `session ${agent.session.slice(0, 8)}` : 'Claude Code'))}</div></div></div>`);
|
|
3018
|
+
}
|
|
3019
|
+
if (agentState.answerer) {
|
|
3020
|
+
const a = agentState.answerer;
|
|
3021
|
+
parts.push(`<div class="agent-connection"><span class="agent-dot"></span>
|
|
3022
|
+
<div><strong>Answering in the background</strong>
|
|
3023
|
+
<div class="agent-connection-title">${escapeHtml(a.title ?? a.session)}</div>
|
|
3024
|
+
<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>
|
|
3025
|
+
<button class="agent-disconnect" onclick="disconnectAgent()">Stop</button></div>`);
|
|
3026
|
+
}
|
|
3027
|
+
box.innerHTML = parts.join('');
|
|
3028
|
+
box.classList.toggle('hidden', parts.length === 0);
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
function renderAgentSessions() {
|
|
3032
|
+
const list = document.getElementById('agentSessions');
|
|
3033
|
+
const sessions = agentState.sessions ?? [];
|
|
3034
|
+
if (sessions.length === 0) {
|
|
3035
|
+
list.innerHTML = '<p class="agent-empty">No Claude Code session has worked in this repository yet. ' +
|
|
3036
|
+
'Sessions are found under <code>~/.claude/projects</code> by the directory they ran in.</p>';
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
3039
|
+
list.innerHTML = sessions.map((session, index) => {
|
|
3040
|
+
const label = session.title ?? session.firstPrompt ?? '(no prompt)';
|
|
3041
|
+
const copy = session.title?.startsWith('Review answers:');
|
|
3042
|
+
return `
|
|
3043
|
+
<div class="agent-session${copy ? ' agent-session-copy' : ''}">
|
|
3044
|
+
<div class="agent-session-text">
|
|
3045
|
+
<div class="agent-session-title">${escapeHtml(label)}</div>
|
|
3046
|
+
<div class="agent-session-meta">${escapeHtml(session.id.slice(0, 8))}${session.branch ? ` · ${escapeHtml(session.branch)}` : ''} · ${escapeHtml(sinceText(session.updatedAt))}</div>
|
|
3047
|
+
</div>
|
|
3048
|
+
<div class="agent-session-actions">
|
|
3049
|
+
<button class="agent-connect" onclick="connectAgent(${Number(index)})">Answer my comments</button>
|
|
3050
|
+
<button class="agent-copy" onclick="copyConnectCommand(${Number(index)})" title="Copy a command that opens this session live in a terminal">Copy live command</button>
|
|
3051
|
+
</div>
|
|
3052
|
+
</div>
|
|
3053
|
+
`;
|
|
3054
|
+
}).join('');
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
async function connectAgent(index) {
|
|
3058
|
+
const session = agentState.sessions?.[Number(index)];
|
|
3059
|
+
if (!session || !currentRepoId) return;
|
|
3060
|
+
const fork = document.getElementById('agentFork').checked;
|
|
3061
|
+
try {
|
|
3062
|
+
const response = await fetch(`${API_BASE}/agent/connect`, {
|
|
3063
|
+
method: 'POST',
|
|
3064
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3065
|
+
body: JSON.stringify({ repoId: currentRepoId, sessionId: session.id, fork })
|
|
3066
|
+
});
|
|
3067
|
+
const data = await response.json();
|
|
3068
|
+
if (!response.ok) throw new Error(data.error || 'Could not connect');
|
|
3069
|
+
agentState.answerer = data.answerer;
|
|
3070
|
+
closeAgentDialog();
|
|
3071
|
+
refreshPendingAnswers();
|
|
3072
|
+
showStatus('Claude Code will answer your comments in their threads.', 'success');
|
|
3073
|
+
} catch (error) {
|
|
3074
|
+
showStatus(`Could not connect Claude Code: ${error.message}`, 'error');
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
async function disconnectAgent() {
|
|
3079
|
+
if (!currentRepoId) return;
|
|
3080
|
+
await fetch(`${API_BASE}/agent/disconnect`, {
|
|
3081
|
+
method: 'POST',
|
|
3082
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3083
|
+
body: JSON.stringify({ repoId: currentRepoId })
|
|
3084
|
+
}).catch(() => {});
|
|
3085
|
+
agentState.answerer = null;
|
|
3086
|
+
renderAgentConnection();
|
|
3087
|
+
refreshPendingAnswers();
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
async function copyConnectCommand(index) {
|
|
3091
|
+
const session = agentState.sessions?.[Number(index)];
|
|
3092
|
+
if (!session) return;
|
|
3093
|
+
const command = `reviewer connect --session ${session.id}`;
|
|
3094
|
+
try {
|
|
3095
|
+
await navigator.clipboard.writeText(command);
|
|
3096
|
+
showStatus(`Copied: ${command}`, 'success');
|
|
3097
|
+
} catch {
|
|
3098
|
+
showStatus(`Run this in a terminal: ${command}`, 'success');
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
|
|
2444
3102
|
// Allow Enter key to load repo
|
|
2445
3103
|
/**
|
|
2446
3104
|
* Tell the server this tab exists, by holding a connection open for as long
|
|
@@ -2455,18 +3113,35 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
|
|
|
2455
3113
|
* server's grace period work -- a flaky connection comes back inside it and
|
|
2456
3114
|
* nothing is stopped.
|
|
2457
3115
|
*/
|
|
3116
|
+
let aliveStream = null;
|
|
3117
|
+
|
|
2458
3118
|
function watchFromThisTab() {
|
|
3119
|
+
if (aliveStream) return;
|
|
2459
3120
|
try {
|
|
2460
|
-
new EventSource(`${API_BASE}/alive`);
|
|
3121
|
+
aliveStream = new EventSource(`${API_BASE}/alive`);
|
|
2461
3122
|
} catch {
|
|
2462
3123
|
// Older browsers, or a page opened from a file:// URL. Losing this costs
|
|
2463
3124
|
// an automatic exit, not a review, so there is nothing to report.
|
|
2464
3125
|
}
|
|
2465
3126
|
}
|
|
2466
3127
|
|
|
3128
|
+
/**
|
|
3129
|
+
* Hand the "this tab is open" signal over to the review's live feed, which
|
|
3130
|
+
* the server counts the same way. One long-lived connection per tab rather
|
|
3131
|
+
* than two: browsers allow six to a host, and every save needs one.
|
|
3132
|
+
*/
|
|
3133
|
+
function stopWatchingFromThisTab() {
|
|
3134
|
+
aliveStream?.close();
|
|
3135
|
+
aliveStream = null;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
2467
3138
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2468
3139
|
watchFromThisTab();
|
|
2469
3140
|
|
|
3141
|
+
// A tab you were only reading catches up on other tabs' comments when it
|
|
3142
|
+
// comes back into view. See refreshCommentsFromElsewhere.
|
|
3143
|
+
document.addEventListener('visibilitychange', refreshCommentsFromElsewhere);
|
|
3144
|
+
|
|
2470
3145
|
document.getElementById('repoPath').addEventListener('keypress', (e) => {
|
|
2471
3146
|
if (e.key === 'Enter') {
|
|
2472
3147
|
loadRepo();
|
|
@@ -2842,6 +3517,23 @@ function closeHelp() {
|
|
|
2842
3517
|
document.getElementById('helpModal').classList.add('hidden');
|
|
2843
3518
|
}
|
|
2844
3519
|
|
|
3520
|
+
/**
|
|
3521
|
+
* Whether the first-run help should open on its own.
|
|
3522
|
+
*
|
|
3523
|
+
* `intro=0` is an explicit no, for an automation that starts from a fresh
|
|
3524
|
+
* browser every time. Otherwise the dialog opens once per browser, until it
|
|
3525
|
+
* has been seen. Asking for it with the ? button does not come through here.
|
|
3526
|
+
*
|
|
3527
|
+
* @param {string} search location.search, with or without the leading ?
|
|
3528
|
+
* @param {boolean} seen
|
|
3529
|
+
* @returns {boolean}
|
|
3530
|
+
*/
|
|
3531
|
+
function shouldOfferHelp(search, seen) {
|
|
3532
|
+
// The same reader the page uses for `repo` and `staged`.
|
|
3533
|
+
if (new URLSearchParams(search || '').get('intro') === '0') return false;
|
|
3534
|
+
return !seen;
|
|
3535
|
+
}
|
|
3536
|
+
|
|
2845
3537
|
/**
|
|
2846
3538
|
* Offer it unprompted the first time, once a repository is actually on screen.
|
|
2847
3539
|
*
|
|
@@ -2849,7 +3541,7 @@ function closeHelp() {
|
|
|
2849
3541
|
* would be the first thing between somebody and the tool they just started.
|
|
2850
3542
|
*/
|
|
2851
3543
|
function offerHelpOnce() {
|
|
2852
|
-
if (helpWasSeen()) return;
|
|
3544
|
+
if (!shouldOfferHelp(window.location.search, helpWasSeen())) return;
|
|
2853
3545
|
openHelp();
|
|
2854
3546
|
}
|
|
2855
3547
|
|