git-reviewer 2.11.0 → 2.13.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 +62 -23
- package/lib/agent.js +33 -2
- package/lib/changes.js +73 -4
- package/package.json +1 -1
- package/public/app.js +698 -88
- package/public/index.html +59 -22
- package/public/style.css +299 -47
- package/server.js +325 -30
package/public/app.js
CHANGED
|
@@ -137,122 +137,631 @@ function shortSha(sha) {
|
|
|
137
137
|
return typeof sha === 'string' ? sha.slice(0, 8) : '';
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** A full sha is shortened for display; a branch or tag name is left alone. */
|
|
141
|
+
function displayRef(ref) {
|
|
142
|
+
return /^[0-9a-f]{40}$/i.test(ref ?? '') ? shortSha(ref) : (ref ?? '');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `n thing` or `n things`, instead of the `thing(s)` that reads like a form. */
|
|
146
|
+
function plural(n, word) {
|
|
147
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// What the compare pickers can offer, from /api/refs. Refetched on every
|
|
151
|
+
// load, because a comparison opens a new session and branches move.
|
|
152
|
+
let currentRefs = null;
|
|
153
|
+
|
|
154
|
+
// The "type something else" choice at the foot of either picker.
|
|
155
|
+
const OTHER_REF = '__other__';
|
|
156
|
+
|
|
157
|
+
const PICKERS = {
|
|
158
|
+
base: { select: 'compareBaseSelect', other: 'compareBaseOther' },
|
|
159
|
+
head: { select: 'compareHeadSelect', other: 'compareHeadOther' }
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Fetch the branches, tags and commits the pickers offer, and fill both.
|
|
164
|
+
*
|
|
165
|
+
* A failure leaves both pickers with only "Other…" in them, which still
|
|
166
|
+
* works; a repository whose refs will not list is not a reason to refuse to
|
|
167
|
+
* compare it.
|
|
168
|
+
*/
|
|
169
|
+
async function loadRefs(repoId, head) {
|
|
170
|
+
try {
|
|
171
|
+
const query = head ? `?${new URLSearchParams({ head })}` : '';
|
|
172
|
+
const response = await fetch(`${API_BASE}/refs/${repoId}${query}`);
|
|
173
|
+
currentRefs = response.ok ? await response.json() : null;
|
|
174
|
+
} catch {
|
|
175
|
+
currentRefs = null;
|
|
176
|
+
}
|
|
177
|
+
fillPicker('base');
|
|
178
|
+
fillPicker('head');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Fill one picker, grouped the way people look for things.
|
|
183
|
+
*
|
|
184
|
+
* Branches first, because "my branch against main" is the comparison people
|
|
185
|
+
* reach for. Every label goes in through `new Option`, which sets text, not
|
|
186
|
+
* markup: branch names and commit subjects come from whatever repository is
|
|
187
|
+
* open, and a ref name may legally contain `<` and `>`.
|
|
188
|
+
*/
|
|
189
|
+
function fillPicker(end) {
|
|
190
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
191
|
+
const refs = currentRefs;
|
|
192
|
+
select.innerHTML = '';
|
|
193
|
+
|
|
194
|
+
const placeholder = new Option(end === 'base' ? 'Choose a base…' : 'Choose what to compare…', '');
|
|
195
|
+
placeholder.disabled = true;
|
|
196
|
+
select.appendChild(placeholder);
|
|
197
|
+
|
|
198
|
+
const group = (label, options) => {
|
|
199
|
+
if (options.length === 0) return;
|
|
200
|
+
const optgroup = document.createElement('optgroup');
|
|
201
|
+
optgroup.label = label;
|
|
202
|
+
options.forEach(option => optgroup.appendChild(option));
|
|
203
|
+
select.appendChild(optgroup);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (refs) {
|
|
207
|
+
group('Branches', refs.branches.map(branch => new Option(
|
|
208
|
+
`${branch.name}${branch.name === refs.current ? ' (current)' : ''} · ${branch.when}`,
|
|
209
|
+
branch.name
|
|
210
|
+
)));
|
|
211
|
+
// Commits second, not last. In 2.12 they came after every remote branch
|
|
212
|
+
// and tag -- option 51 of 81 in this project's own repository -- which is
|
|
213
|
+
// why comparing a commit felt like a feature that had been removed.
|
|
214
|
+
group(
|
|
215
|
+
'Recent commits',
|
|
216
|
+
refs.commits.map(commit => new Option(`${commit.short} · ${commit.subject} · ${commit.when}`, commit.sha))
|
|
217
|
+
);
|
|
218
|
+
group('Remote branches', refs.remotes.map(branch => new Option(`${branch.name} · ${branch.when}`, branch.name)));
|
|
219
|
+
group('Tags', refs.tags.map(tag => new Option(`${tag.name} · ${tag.when}`, tag.name)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
select.appendChild(new Option('Other: a tag, HEAD~5, a sha…', OTHER_REF));
|
|
223
|
+
select.value = '';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Point a picker at a ref, by name if it is offered, by sha if the name is
|
|
228
|
+
* not but the commit is, and otherwise through "Other…" with the text shown.
|
|
229
|
+
*
|
|
230
|
+
* A range opened by typing `HEAD~2` resolves to a commit that may well be in
|
|
231
|
+
* the list; showing it selected there is more useful than a picker stuck on
|
|
232
|
+
* "Other" around a value that is also sitting three rows down.
|
|
233
|
+
*/
|
|
234
|
+
function selectRef(end, name, sha) {
|
|
235
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
236
|
+
const other = document.getElementById(PICKERS[end].other);
|
|
237
|
+
const offered = value => [...select.options].some(option => option.value === value);
|
|
238
|
+
|
|
239
|
+
if (name && offered(name)) {
|
|
240
|
+
select.value = name;
|
|
241
|
+
} else if (sha && offered(sha)) {
|
|
242
|
+
select.value = sha;
|
|
243
|
+
} else if (name || sha) {
|
|
244
|
+
select.value = OTHER_REF;
|
|
245
|
+
other.value = name || displayRef(sha);
|
|
246
|
+
other.classList.remove('hidden');
|
|
247
|
+
return;
|
|
248
|
+
} else {
|
|
249
|
+
select.value = '';
|
|
250
|
+
}
|
|
251
|
+
other.classList.add('hidden');
|
|
252
|
+
other.value = '';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The ref one end of the comparison currently names, or '' if none yet. */
|
|
256
|
+
function pickedRef(end) {
|
|
257
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
258
|
+
return select.value === OTHER_REF
|
|
259
|
+
? document.getElementById(PICKERS[end].other).value.trim()
|
|
260
|
+
: select.value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Either picker changed: compare as soon as both ends are known. */
|
|
264
|
+
function onPickerChange(end) {
|
|
265
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
266
|
+
const other = document.getElementById(PICKERS[end].other);
|
|
267
|
+
|
|
268
|
+
if (select.value === OTHER_REF) {
|
|
269
|
+
other.classList.remove('hidden');
|
|
270
|
+
other.focus();
|
|
271
|
+
return; // Enter in the box compares; see the listeners in DOMContentLoaded.
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
other.classList.add('hidden');
|
|
275
|
+
compareNow();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function compareNow() {
|
|
279
|
+
const base = pickedRef('base');
|
|
280
|
+
const head = pickedRef('head');
|
|
281
|
+
if (base && head) loadRepo({ base, head });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Reverse the comparison: "what does main have that my branch does not". */
|
|
285
|
+
function swapCompare() {
|
|
286
|
+
const base = pickedRef('base');
|
|
287
|
+
const head = pickedRef('head');
|
|
288
|
+
if (base && head) loadRepo({ base: head, head: base });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The base most people mean, if the repository makes it obvious.
|
|
293
|
+
*
|
|
294
|
+
* The remote comes first: a local `main` that has not been pulled in a week
|
|
295
|
+
* is a quietly wrong base, and `origin/main` is what the pull request will be
|
|
296
|
+
* measured against. Never the branch you are on -- comparing a branch with
|
|
297
|
+
* itself is always empty, and offering it as a default would make the first
|
|
298
|
+
* click of the feature look broken.
|
|
299
|
+
*/
|
|
300
|
+
function defaultBase() {
|
|
301
|
+
const refs = currentRefs;
|
|
302
|
+
if (!refs) return null;
|
|
303
|
+
|
|
304
|
+
const known = new Set([...refs.branches, ...refs.remotes].map(ref => ref.name));
|
|
305
|
+
const candidates = ['origin/main', 'main', 'origin/master', 'master', 'origin/develop', 'develop'];
|
|
306
|
+
return candidates.find(name => known.has(name) && name !== refs.current) ?? null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Open the compare row. With an obvious base, compare straight away: one
|
|
311
|
+
* click to the view a pull request would give you. Without one, show the
|
|
312
|
+
* pickers and wait -- a guessed comparison nobody asked for is worse than
|
|
313
|
+
* asking.
|
|
314
|
+
*/
|
|
315
|
+
function openCompare() {
|
|
316
|
+
document.getElementById('compareRow').classList.remove('hidden');
|
|
317
|
+
const head = currentRefs?.current ?? 'HEAD';
|
|
318
|
+
const base = defaultBase();
|
|
319
|
+
|
|
320
|
+
if (base) {
|
|
321
|
+
loadRepo({ base, head });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
selectRef('base', null, null);
|
|
326
|
+
selectRef('head', head, null);
|
|
327
|
+
document.getElementById('compareToggle').classList.add('hidden');
|
|
328
|
+
document.getElementById('scopeResetBtn').classList.remove('hidden');
|
|
329
|
+
document.getElementById(PICKERS.base.select).focus();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Back to the default scope: uncommitted changes, or the last commit. */
|
|
333
|
+
function resetScope() {
|
|
334
|
+
document.getElementById('compareRow').classList.add('hidden');
|
|
335
|
+
loadRepo();
|
|
336
|
+
}
|
|
337
|
+
|
|
140
338
|
/**
|
|
141
|
-
* Say what the review is of,
|
|
339
|
+
* Say what the review is of, in the names the reviewer used.
|
|
142
340
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* nothing is indistinguishable from a repository with nothing to review. The
|
|
146
|
-
* commit count stays on screen so that an empty list reads as "9 commits, no
|
|
147
|
-
* net change" rather than as "nothing happened".
|
|
341
|
+
* Built from DOM nodes rather than markup: branch names are chosen by
|
|
342
|
+
* whoever made the repository, and one may legally contain `<`.
|
|
148
343
|
*
|
|
149
|
-
* @param {{mode: string, range: ?
|
|
344
|
+
* @param {{mode: string, range: ?object, files: object[]}} data
|
|
150
345
|
*/
|
|
151
346
|
function renderScope(data) {
|
|
152
347
|
const bar = document.getElementById('scopeBar');
|
|
153
348
|
const label = document.getElementById('scopeLabel');
|
|
349
|
+
const meta = document.getElementById('scopeMeta');
|
|
350
|
+
const note = document.getElementById('scopeNote');
|
|
351
|
+
const row = document.getElementById('compareRow');
|
|
352
|
+
const toggle = document.getElementById('compareToggle');
|
|
154
353
|
const reset = document.getElementById('scopeResetBtn');
|
|
155
354
|
|
|
355
|
+
const strong = text => {
|
|
356
|
+
const el = document.createElement('strong');
|
|
357
|
+
el.textContent = text;
|
|
358
|
+
return el;
|
|
359
|
+
};
|
|
360
|
+
const setNote = (text, { message = false } = {}) => {
|
|
361
|
+
note.textContent = text ?? '';
|
|
362
|
+
note.classList.toggle('hidden', !text);
|
|
363
|
+
// A commit message keeps its line breaks; an explanation reflows.
|
|
364
|
+
note.classList.toggle('scope-note-message', Boolean(text) && message);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const nav = document.getElementById('commitNav');
|
|
368
|
+
const position = document.getElementById('commitPosition');
|
|
369
|
+
const allCommits = document.getElementById('allCommitsBtn');
|
|
370
|
+
nav.classList.add('hidden');
|
|
371
|
+
allCommits.classList.add('hidden');
|
|
372
|
+
reset.textContent = 'Stop comparing';
|
|
373
|
+
|
|
156
374
|
bar.classList.remove('hidden');
|
|
157
|
-
|
|
375
|
+
label.replaceChildren();
|
|
376
|
+
const branch = currentRefs?.current;
|
|
377
|
+
|
|
378
|
+
if (data.mode === 'range' && data.range?.kind === 'commits') {
|
|
379
|
+
// One commit, or a run of them, picked from the commits list.
|
|
380
|
+
const run = data.range;
|
|
381
|
+
const single = run.first === run.last;
|
|
382
|
+
const chrono = chronologicalCommits(commitContext);
|
|
383
|
+
const index = chrono.findIndex(commit => commit.sha === run.first);
|
|
384
|
+
const commit = single && index !== -1 ? chrono[index] : null;
|
|
385
|
+
const files = plural(data.files.length, 'file') + ' changed';
|
|
386
|
+
|
|
387
|
+
if (single) {
|
|
388
|
+
label.append('Commit ', strong(run.headName));
|
|
389
|
+
} else {
|
|
390
|
+
label.append(strong(run.baseName), ' \u2026 ', strong(run.headName));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const inComparison = commitContext?.kind === 'comparison';
|
|
394
|
+
if (inComparison) {
|
|
395
|
+
const outer = commitContext.range;
|
|
396
|
+
label.append(' in ', strong(displayRef(outer.baseName ?? outer.base)), ' \u2192 ',
|
|
397
|
+
strong(displayRef(outer.headName ?? outer.head)));
|
|
398
|
+
} else if (branch) {
|
|
399
|
+
label.append(' on ', strong(branch));
|
|
400
|
+
}
|
|
158
401
|
|
|
159
|
-
|
|
402
|
+
meta.textContent = single
|
|
403
|
+
? `${commit?.subject ?? run.subject ?? ''} \u00b7 ${files}`
|
|
404
|
+
: `${plural(run.commits, 'commit')} \u00b7 ${files}`;
|
|
405
|
+
|
|
406
|
+
// The message is review material -- often the only place the author says
|
|
407
|
+
// why -- so a single commit shows its body, not just its subject line.
|
|
408
|
+
if (single && commit?.body) {
|
|
409
|
+
setNote(commit.body, { message: true });
|
|
410
|
+
} else if (!single) {
|
|
411
|
+
setNote(`Reviewing ${plural(run.commits, 'commit')} as one combined diff, from ${run.baseName} through ${run.headName}.`);
|
|
412
|
+
} else {
|
|
413
|
+
setNote(null);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (single && index !== -1) {
|
|
417
|
+
nav.classList.remove('hidden');
|
|
418
|
+
// "2 of 3" means something inside a comparison; inside a branch's
|
|
419
|
+
// history it would count from wherever the list happened to stop.
|
|
420
|
+
position.textContent = inComparison ? `${index + 1} of ${chrono.length}` : '';
|
|
421
|
+
document.getElementById('prevCommitBtn').disabled = index === 0;
|
|
422
|
+
document.getElementById('nextCommitBtn').disabled = index === chrono.length - 1;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (inComparison) {
|
|
426
|
+
row.classList.remove('hidden');
|
|
427
|
+
const outer = commitContext.range;
|
|
428
|
+
selectRef('base', outer.baseName, outer.base);
|
|
429
|
+
selectRef('head', outer.headName, outer.head);
|
|
430
|
+
allCommits.textContent = `All ${plural(chrono.length, 'commit')}`;
|
|
431
|
+
allCommits.classList.remove('hidden');
|
|
432
|
+
toggle.classList.add('hidden');
|
|
433
|
+
reset.classList.remove('hidden');
|
|
434
|
+
} else {
|
|
435
|
+
row.classList.add('hidden');
|
|
436
|
+
toggle.classList.remove('hidden');
|
|
437
|
+
reset.textContent = 'Back to latest';
|
|
438
|
+
reset.classList.remove('hidden');
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
160
442
|
|
|
161
443
|
if (data.mode === 'range' && data.range) {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
444
|
+
const range = data.range;
|
|
445
|
+
const base = displayRef(range.baseName ?? range.base);
|
|
446
|
+
const head = displayRef(range.headName ?? range.head);
|
|
447
|
+
const files = data.files.length;
|
|
448
|
+
|
|
449
|
+
label.append(strong(base), ' → ', strong(head));
|
|
450
|
+
meta.textContent = files > 0
|
|
451
|
+
? `${plural(range.commits, 'commit')} · ${plural(files, 'file')} changed`
|
|
452
|
+
: `${plural(range.commits, 'commit')} · no files differ`;
|
|
453
|
+
|
|
454
|
+
// The empty cases first: they are the ones that need explaining, and an
|
|
455
|
+
// empty file list with no explanation reads as the tool having failed.
|
|
456
|
+
if (files === 0 && range.commits === 0) {
|
|
457
|
+
setNote(range.behind > 0
|
|
458
|
+
? `${head} has nothing that ${base} does not already have. Try swapping them to see what ${base} adds.`
|
|
459
|
+
: `${base} and ${head} are the same commit, so there is nothing between them.`);
|
|
460
|
+
} else if (files === 0) {
|
|
461
|
+
setNote(`Those ${plural(range.commits, 'commit')} undo each other: every change in them was reverted again before ${head}, so there is no difference left to review.`);
|
|
462
|
+
} else if (range.behind > 0) {
|
|
463
|
+
// The merge-base diff leaves these out on purpose, the way a pull
|
|
464
|
+
// request does. Say so, or someone goes looking for main's change and
|
|
465
|
+
// concludes the tool lost it.
|
|
466
|
+
setNote(`${base} has ${plural(range.behind, 'newer commit')} that ${range.behind === 1 ? 'is' : 'are'} not on ${head}. ${range.behind === 1 ? 'It is' : 'They are'} left out: this shows only what ${head} changes since the two diverged, as a pull request would.`);
|
|
467
|
+
} else {
|
|
468
|
+
setNote(null);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
row.classList.remove('hidden');
|
|
472
|
+
selectRef('base', range.baseName, range.base);
|
|
473
|
+
selectRef('head', range.headName, range.head);
|
|
474
|
+
toggle.classList.add('hidden');
|
|
475
|
+
reset.classList.remove('hidden');
|
|
171
476
|
return;
|
|
172
477
|
}
|
|
173
478
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
479
|
+
if (data.mode === 'lastCommit') {
|
|
480
|
+
label.append('Last commit');
|
|
481
|
+
if (branch) label.append(' on ', strong(branch));
|
|
482
|
+
const tip = commitContext?.commits?.[0] ?? currentRefs?.commits?.[0];
|
|
483
|
+
meta.textContent = tip ? `${tip.subject} · ${tip.short}` : '';
|
|
484
|
+
setNote(tip?.body || null, { message: true });
|
|
485
|
+
|
|
486
|
+
// One click back through history from here -- the first thing someone
|
|
487
|
+
// looking at the last commit is likely to want next.
|
|
488
|
+
if ((commitContext?.commits?.length ?? 0) > 1) {
|
|
489
|
+
nav.classList.remove('hidden');
|
|
490
|
+
position.textContent = '';
|
|
491
|
+
document.getElementById('prevCommitBtn').disabled = false;
|
|
492
|
+
document.getElementById('nextCommitBtn').disabled = true;
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
label.append('Uncommitted changes');
|
|
496
|
+
if (branch) label.append(' on ', strong(branch));
|
|
497
|
+
meta.textContent = plural(data.files.length, 'file') + ' changed';
|
|
498
|
+
setNote(null);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
row.classList.add('hidden');
|
|
502
|
+
toggle.classList.remove('hidden');
|
|
503
|
+
reset.classList.add('hidden');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// --- commits: reviewing them one at a time, or a run of them -----------------
|
|
507
|
+
|
|
508
|
+
// What the commits list is showing. `comparison` is the commits a Compare is
|
|
509
|
+
// made of, oldest first; `history` is the recent history of the branch when
|
|
510
|
+
// the view is its last commit, newest first. Null for the working tree, which
|
|
511
|
+
// has no commits to list. `range` keeps the outer comparison, so narrowing to
|
|
512
|
+
// one commit does not lose what it was a commit *of*.
|
|
513
|
+
let commitContext = null;
|
|
514
|
+
|
|
515
|
+
// Which of those commits the review currently shows, as `{from, to}` shas,
|
|
516
|
+
// oldest first; null for all of them.
|
|
517
|
+
let commitSelection = null;
|
|
518
|
+
|
|
519
|
+
// The commit a shift-click extends from.
|
|
520
|
+
let commitAnchor = null;
|
|
521
|
+
|
|
522
|
+
/** The context's commits, oldest first, whichever order the list shows them in. */
|
|
523
|
+
function chronologicalCommits(context) {
|
|
524
|
+
if (!context) return [];
|
|
525
|
+
return context.order === 'newest-first' ? [...context.commits].reverse() : context.commits;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The run between two commits of a list, oldest first -- whichever was
|
|
530
|
+
* clicked first. A shift-click upwards and one downwards select the same run.
|
|
531
|
+
*
|
|
532
|
+
* @param {{sha: string}[]} chronological commits, oldest first
|
|
533
|
+
* @returns {[string, string]|null} [from, to]
|
|
534
|
+
*/
|
|
535
|
+
function orderedRun(chronological, a, b) {
|
|
536
|
+
const i = chronological.findIndex(commit => commit.sha === a);
|
|
537
|
+
const j = chronological.findIndex(commit => commit.sha === b);
|
|
538
|
+
if (i === -1 || j === -1) return null;
|
|
539
|
+
return i <= j
|
|
540
|
+
? [chronological[i].sha, chronological[j].sha]
|
|
541
|
+
: [chronological[j].sha, chronological[i].sha];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Where a step lands, or -1 if there is nowhere to go.
|
|
546
|
+
*
|
|
547
|
+
* From "all commits" a step forward starts at the oldest -- the beginning of
|
|
548
|
+
* the story -- and a step back starts at the newest. Stepping never wraps:
|
|
549
|
+
* reaching the end of a branch and silently landing on its first commit
|
|
550
|
+
* again reads as the list having started over.
|
|
551
|
+
*
|
|
552
|
+
* @param {number} length
|
|
553
|
+
* @param {number} current index of the one commit shown, or -1 if not one
|
|
554
|
+
* @param {1|-1} direction +1 newer, -1 older
|
|
555
|
+
*/
|
|
556
|
+
function stepIndex(length, current, direction) {
|
|
557
|
+
if (length === 0) return -1;
|
|
558
|
+
if (current === -1) return direction > 0 ? 0 : length - 1;
|
|
559
|
+
const next = current + direction;
|
|
560
|
+
return next >= 0 && next < length ? next : -1;
|
|
179
561
|
}
|
|
180
562
|
|
|
181
563
|
/**
|
|
182
|
-
*
|
|
564
|
+
* "3 hours ago" as "3h", for a list where the subject needs the width.
|
|
183
565
|
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
566
|
+
* Git's relative dates are written for a sentence, and in a 320px sidebar
|
|
567
|
+
* "2 seconds ago" took more room than the commit subject beside it. The full
|
|
568
|
+
* phrase stays in the row's tooltip. Anything not recognised is returned as
|
|
569
|
+
* it came, rather than guessed at.
|
|
188
570
|
*
|
|
189
|
-
*
|
|
190
|
-
* and a repository whose history will not list is not a reason to refuse to
|
|
191
|
-
* review it.
|
|
571
|
+
* @param {string} when git's %cr, e.g. "3 weeks ago" or "1 year, 2 months ago"
|
|
192
572
|
*/
|
|
193
|
-
|
|
194
|
-
const
|
|
573
|
+
function compactWhen(when) {
|
|
574
|
+
const match = /^(\d+) (second|minute|hour|day|week|month|year)s?(?:, .*)? ago$/.exec(when ?? '');
|
|
575
|
+
if (!match) return when ?? '';
|
|
576
|
+
const unit = { second: 's', minute: 'm', hour: 'h', day: 'd', week: 'w', month: 'mo', year: 'y' }[match[2]];
|
|
577
|
+
return `${match[1]}${unit}`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Fetch a commits list for the context the review is in. */
|
|
581
|
+
async function fetchCommitContext(repoId, kind, { base, head, range }) {
|
|
582
|
+
const query = new URLSearchParams({ head });
|
|
583
|
+
if (base) query.set('base', base);
|
|
195
584
|
|
|
196
585
|
try {
|
|
197
|
-
const response = await fetch(`${API_BASE}/
|
|
198
|
-
if (!response.ok) return;
|
|
586
|
+
const response = await fetch(`${API_BASE}/log/${repoId}?${query}`);
|
|
587
|
+
if (!response.ok) return null;
|
|
588
|
+
const log = await response.json();
|
|
589
|
+
return { kind, base, head, range, ...log };
|
|
590
|
+
} catch {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
199
594
|
|
|
200
|
-
|
|
201
|
-
|
|
595
|
+
/**
|
|
596
|
+
* Decide what the commits list shows after a load.
|
|
597
|
+
*
|
|
598
|
+
* A load that narrows to commits keeps the list it narrowed from; anything
|
|
599
|
+
* else rebuilds it. The branch history offered with "last commit" is the same
|
|
600
|
+
* gesture as a comparison's commits: this is where "review the changes since
|
|
601
|
+
* that commit", which 2.11 had as a dropdown and 2.12 buried under forty
|
|
602
|
+
* branches, lives now.
|
|
603
|
+
*/
|
|
604
|
+
async function refreshCommitContext(data, scope) {
|
|
605
|
+
if (scope.commits && commitContext) {
|
|
606
|
+
commitSelection = { from: data.range.first, to: data.range.last };
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
202
609
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
select.appendChild(new Option('No earlier commits', ''));
|
|
206
|
-
select.disabled = true;
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
610
|
+
commitAnchor = null;
|
|
611
|
+
commitSelection = null;
|
|
209
612
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
613
|
+
if (data.mode === 'range' && data.range) {
|
|
614
|
+
commitContext = await fetchCommitContext(data.repoId, 'comparison', {
|
|
615
|
+
base: data.range.base, head: data.range.head, range: data.range
|
|
616
|
+
});
|
|
617
|
+
} else if (data.mode === 'lastCommit' && data.range) {
|
|
618
|
+
commitContext = await fetchCommitContext(data.repoId, 'history', { head: data.range.head });
|
|
619
|
+
// The last commit is itself one of the listed commits, so it is shown as
|
|
620
|
+
// the selected one, and shift-clicking further down selects "since then".
|
|
621
|
+
commitSelection = { from: data.range.head, to: data.range.head };
|
|
622
|
+
commitAnchor = data.range.head;
|
|
623
|
+
} else {
|
|
624
|
+
commitContext = null;
|
|
220
625
|
}
|
|
221
626
|
}
|
|
222
627
|
|
|
223
|
-
/**
|
|
224
|
-
function
|
|
225
|
-
const
|
|
226
|
-
|
|
628
|
+
/** Draw the commits list, marking the ones the review is of. */
|
|
629
|
+
function renderCommitList() {
|
|
630
|
+
const section = document.getElementById('commitsSection');
|
|
631
|
+
const list = document.getElementById('commitsList');
|
|
632
|
+
const title = document.getElementById('commitsTitle');
|
|
633
|
+
const count = document.getElementById('commitCount');
|
|
634
|
+
const truncated = document.getElementById('commitsTruncated');
|
|
635
|
+
|
|
636
|
+
if (!commitContext || commitContext.commits.length === 0) {
|
|
637
|
+
section.classList.add('hidden');
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
section.classList.remove('hidden');
|
|
642
|
+
title.textContent = commitContext.kind === 'comparison'
|
|
643
|
+
? 'Commits in this comparison'
|
|
644
|
+
: `Recent commits${currentRefs?.current ? ` on ${currentRefs.current}` : ''}`;
|
|
645
|
+
count.textContent = String(commitContext.total);
|
|
646
|
+
truncated.classList.toggle('hidden', !commitContext.truncated);
|
|
647
|
+
truncated.textContent = commitContext.truncated
|
|
648
|
+
? `Showing ${commitContext.commits.length} of ${commitContext.total}. Anything further back is still reachable by typing it into Compare.`
|
|
649
|
+
: '';
|
|
227
650
|
|
|
228
|
-
|
|
229
|
-
|
|
651
|
+
// Which rows are in the review: everything from `from` to `to` in time,
|
|
652
|
+
// or every row when there is no selection.
|
|
653
|
+
const chrono = chronologicalCommits(commitContext);
|
|
654
|
+
const inRun = new Set();
|
|
655
|
+
if (commitSelection) {
|
|
656
|
+
const i = chrono.findIndex(c => c.sha === commitSelection.from);
|
|
657
|
+
const j = chrono.findIndex(c => c.sha === commitSelection.to);
|
|
658
|
+
if (i !== -1 && j !== -1) chrono.slice(Math.min(i, j), Math.max(i, j) + 1).forEach(c => inRun.add(c.sha));
|
|
659
|
+
} else {
|
|
660
|
+
chrono.forEach(c => inRun.add(c.sha));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Built from nodes: subjects and author names are whatever the repository
|
|
664
|
+
// says, and they are shown, never parsed as markup.
|
|
665
|
+
const rows = commitContext.commits.map(commit => {
|
|
666
|
+
const item = document.createElement('li');
|
|
667
|
+
item.className = 'commit-item';
|
|
668
|
+
item.dataset.sha = commit.sha;
|
|
669
|
+
item.tabIndex = 0;
|
|
670
|
+
item.setAttribute('role', 'option');
|
|
671
|
+
const selected = inRun.has(commit.sha) && commitSelection !== null;
|
|
672
|
+
item.setAttribute('aria-selected', String(selected));
|
|
673
|
+
if (selected) item.classList.add('commit-item-selected');
|
|
674
|
+
item.title = `${commit.subject}\n${commit.short} · ${commit.author} · ${commit.when}${commit.merge ? ' · merge' : ''}`;
|
|
675
|
+
|
|
676
|
+
const sha = document.createElement('span');
|
|
677
|
+
sha.className = 'commit-sha';
|
|
678
|
+
sha.textContent = commit.short;
|
|
679
|
+
const subject = document.createElement('span');
|
|
680
|
+
subject.className = 'commit-subject';
|
|
681
|
+
subject.textContent = commit.subject;
|
|
682
|
+
const when = document.createElement('span');
|
|
683
|
+
when.className = 'commit-when';
|
|
684
|
+
when.textContent = compactWhen(commit.when);
|
|
685
|
+
|
|
686
|
+
item.append(sha, subject);
|
|
687
|
+
if (commit.merge) {
|
|
688
|
+
const merge = document.createElement('span');
|
|
689
|
+
merge.className = 'commit-merge';
|
|
690
|
+
merge.textContent = 'merge';
|
|
691
|
+
item.append(merge);
|
|
692
|
+
}
|
|
693
|
+
item.append(when);
|
|
694
|
+
return item;
|
|
695
|
+
});
|
|
696
|
+
list.replaceChildren(...rows);
|
|
697
|
+
|
|
698
|
+
const current = list.querySelector('.commit-item-selected');
|
|
699
|
+
if (current) current.scrollIntoView({ block: 'nearest' });
|
|
230
700
|
}
|
|
231
701
|
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
702
|
+
/**
|
|
703
|
+
* Review one commit, or -- with shift -- the run from the anchor to it.
|
|
704
|
+
*
|
|
705
|
+
* @param {string} sha
|
|
706
|
+
* @param {boolean} extend
|
|
707
|
+
*/
|
|
708
|
+
function selectCommit(sha, extend) {
|
|
709
|
+
if (!commitContext) return;
|
|
235
710
|
|
|
236
|
-
if (
|
|
237
|
-
|
|
711
|
+
if (extend && commitAnchor) {
|
|
712
|
+
const run = orderedRun(chronologicalCommits(commitContext), commitAnchor, sha);
|
|
713
|
+
if (run) loadRepo({ commits: { from: run[0], to: run[1] } });
|
|
238
714
|
return;
|
|
239
715
|
}
|
|
240
716
|
|
|
241
|
-
|
|
717
|
+
commitAnchor = sha;
|
|
718
|
+
loadRepo({ commits: { from: sha, to: sha } });
|
|
242
719
|
}
|
|
243
720
|
|
|
244
|
-
/**
|
|
245
|
-
function
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
721
|
+
/** Step one commit older (-1) or newer (+1): commit-by-commit review. */
|
|
722
|
+
function stepCommit(direction) {
|
|
723
|
+
if (!commitContext) return;
|
|
724
|
+
const chrono = chronologicalCommits(commitContext);
|
|
725
|
+
const single = commitSelection && commitSelection.from === commitSelection.to
|
|
726
|
+
? chrono.findIndex(c => c.sha === commitSelection.from)
|
|
727
|
+
: -1;
|
|
728
|
+
const next = stepIndex(chrono.length, single, direction);
|
|
729
|
+
if (next !== -1) selectCommit(chrono[next].sha, false);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Back from one commit to the whole comparison it was part of. */
|
|
733
|
+
function showAllCommits() {
|
|
734
|
+
if (commitContext?.kind !== 'comparison') return;
|
|
735
|
+
// By the names that were picked, so the bar still says main -> feature/auth
|
|
736
|
+
// rather than two SHAs.
|
|
737
|
+
const { range } = commitContext;
|
|
738
|
+
loadRepo({ base: range.baseName ?? range.base, head: range.headName ?? range.head });
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Empty the diff pane, so a finished comparison cannot leave the last one's file on screen. */
|
|
742
|
+
function clearCodePane() {
|
|
743
|
+
currentFile = null;
|
|
744
|
+
currentDiffLines = [];
|
|
745
|
+
document.getElementById('codeSection').classList.add('hidden');
|
|
746
|
+
document.getElementById('currentFile').textContent = '';
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** What the file list says when a comparison has no files in it. */
|
|
750
|
+
function showEmptyFileList(message) {
|
|
751
|
+
const filesList = document.getElementById('filesList');
|
|
752
|
+
const empty = document.createElement('div');
|
|
753
|
+
empty.className = 'files-empty';
|
|
754
|
+
empty.textContent = message;
|
|
755
|
+
filesList.replaceChildren(empty);
|
|
249
756
|
}
|
|
250
757
|
|
|
251
758
|
/**
|
|
252
759
|
* Load a repository, optionally as a range between two commits.
|
|
253
760
|
*
|
|
254
|
-
* @param {{base?: string
|
|
255
|
-
* falling back to the last
|
|
761
|
+
* @param {{base?: string, head?: string, commits?: {from: string, to: string}}} [scope]
|
|
762
|
+
* omit for the default: uncommitted changes, falling back to the last
|
|
763
|
+
* commit on a clean tree. `base`/`head` compare two refs from their merge
|
|
764
|
+
* base; `commits` reviews one commit or an unbroken run of them, inclusive.
|
|
256
765
|
*/
|
|
257
766
|
async function loadRepo(scope = {}) {
|
|
258
767
|
const repoPath = document.getElementById('repoPath').value.trim();
|
|
@@ -269,7 +778,11 @@ async function loadRepo(scope = {}) {
|
|
|
269
778
|
const response = await fetch(`${API_BASE}/load-repo`, {
|
|
270
779
|
method: 'POST',
|
|
271
780
|
headers: { 'Content-Type': 'application/json' },
|
|
272
|
-
body: JSON.stringify(
|
|
781
|
+
body: JSON.stringify(
|
|
782
|
+
scope.commits ? { repoPath, commits: scope.commits }
|
|
783
|
+
: scope.base ? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
|
|
784
|
+
: { repoPath }
|
|
785
|
+
)
|
|
273
786
|
});
|
|
274
787
|
|
|
275
788
|
const data = await response.json();
|
|
@@ -289,10 +802,14 @@ async function loadRepo(scope = {}) {
|
|
|
289
802
|
setRepoButton(data.repoPath);
|
|
290
803
|
}
|
|
291
804
|
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
805
|
+
// In this order: the commits list first, because it decides which head
|
|
806
|
+
// the pickers' commits should follow; the refs next, because renderScope
|
|
807
|
+
// needs the options present to select the current ends among them and
|
|
808
|
+
// the current branch to name it; then the bar and the list.
|
|
809
|
+
await refreshCommitContext(data, scope);
|
|
810
|
+
await loadRefs(data.repoId, commitContext?.head ?? data.range?.head);
|
|
295
811
|
renderScope(data);
|
|
812
|
+
renderCommitList();
|
|
296
813
|
|
|
297
814
|
if (data.files.length === 0) {
|
|
298
815
|
// A range with no files is a real answer, not a failure: every change
|
|
@@ -301,9 +818,20 @@ async function loadRepo(scope = {}) {
|
|
|
301
818
|
// that covers -- so this is information, not an error, and it must not
|
|
302
819
|
// be dressed as one.
|
|
303
820
|
if (data.mode === 'range') {
|
|
304
|
-
|
|
821
|
+
// The scope bar is already explaining this; a status line repeating
|
|
822
|
+
// it underneath is two bars saying one thing.
|
|
823
|
+
clearStatus();
|
|
305
824
|
hidePicker();
|
|
306
825
|
displayFiles([]);
|
|
826
|
+
showEmptyFileList(data.range?.kind !== 'commits'
|
|
827
|
+
? 'No files differ between these two.'
|
|
828
|
+
: data.range.first === data.range.last
|
|
829
|
+
? 'This commit changes no files.'
|
|
830
|
+
: 'These commits change no files between them.');
|
|
831
|
+
updateCommentsSidebar();
|
|
832
|
+
// Or the previous comparison's file stays on screen under a bar that
|
|
833
|
+
// says there are no files, and reads as part of this one.
|
|
834
|
+
clearCodePane();
|
|
307
835
|
document.getElementById('loadBtn').disabled = false;
|
|
308
836
|
return;
|
|
309
837
|
}
|
|
@@ -313,9 +841,15 @@ async function loadRepo(scope = {}) {
|
|
|
313
841
|
return;
|
|
314
842
|
}
|
|
315
843
|
|
|
316
|
-
|
|
844
|
+
// The scope bar above says what was loaded, in more useful terms than
|
|
845
|
+
// this line did ("Found 3 changed file(s)"), so there is nothing left
|
|
846
|
+
// for the status line to add on success.
|
|
847
|
+
clearStatus();
|
|
317
848
|
hidePicker();
|
|
318
849
|
displayFiles(data.files);
|
|
850
|
+
// Which comments are in view depends on which files are, so the sidebar
|
|
851
|
+
// is redrawn for every new scope, not only when saved comments arrive.
|
|
852
|
+
updateCommentsSidebar();
|
|
319
853
|
|
|
320
854
|
// Load saved comments
|
|
321
855
|
const savedComments = await loadCommentsFromBackend();
|
|
@@ -335,7 +869,7 @@ async function loadRepo(scope = {}) {
|
|
|
335
869
|
// Show comments sidebar immediately
|
|
336
870
|
updateCommentsSidebar();
|
|
337
871
|
|
|
338
|
-
showStatus(
|
|
872
|
+
showStatus(`Picked up ${plural(savedComments.length, 'saved comment')} from last time.`, 'success');
|
|
339
873
|
}
|
|
340
874
|
|
|
341
875
|
// Open something. A loaded repository used to sit behind an empty pane
|
|
@@ -362,6 +896,15 @@ async function loadRepo(scope = {}) {
|
|
|
362
896
|
}
|
|
363
897
|
|
|
364
898
|
// Display list of changed files
|
|
899
|
+
/** What each status letter means, for the badge's tooltip and screen readers. */
|
|
900
|
+
const FILE_STATUS_NAMES = {
|
|
901
|
+
A: 'Added',
|
|
902
|
+
M: 'Modified',
|
|
903
|
+
D: 'Deleted',
|
|
904
|
+
R: 'Renamed',
|
|
905
|
+
B: 'Binary'
|
|
906
|
+
};
|
|
907
|
+
|
|
365
908
|
function fileStatusClass(status) {
|
|
366
909
|
switch (status) {
|
|
367
910
|
case 'A': return 'file-status-added';
|
|
@@ -396,7 +939,12 @@ function displayFiles(files) {
|
|
|
396
939
|
? `${escapeHtml(path)}/<span class="filename">${escapeHtml(filename)}</span>`
|
|
397
940
|
: `<span class="filename">${escapeHtml(filename)}</span>`;
|
|
398
941
|
const statusClass = fileStatusClass(status);
|
|
399
|
-
|
|
942
|
+
// One letter is git's vocabulary, not everyone's. The name rides along as
|
|
943
|
+
// a tooltip and as the accessible label, so "D" is never the only thing
|
|
944
|
+
// saying that a file is gone. `status` is one of five letters the server
|
|
945
|
+
// produces, and escaped anyway: it is data that ends up in markup.
|
|
946
|
+
const statusName = FILE_STATUS_NAMES[status] ?? 'Changed';
|
|
947
|
+
const statusBadge = `<span class="file-status ${statusClass}" title="${statusName}" aria-label="${statusName}">${escapeHtml(status)}</span>`;
|
|
400
948
|
|
|
401
949
|
// The row used to carry the file name itself, inside a string literal
|
|
402
950
|
// inside the attribute the browser evaluates as JavaScript. A name with a
|
|
@@ -1387,10 +1935,19 @@ function resetApp() {
|
|
|
1387
1935
|
// Show status message
|
|
1388
1936
|
function showStatus(message, type) {
|
|
1389
1937
|
const statusEl = document.getElementById('status');
|
|
1390
|
-
|
|
1938
|
+
// No "Status:" prefix. The element is role="status", which is what tells a
|
|
1939
|
+
// screen reader what it is; sighted readers could already see that.
|
|
1940
|
+
statusEl.textContent = message;
|
|
1391
1941
|
statusEl.className = `status ${type}`;
|
|
1392
1942
|
}
|
|
1393
1943
|
|
|
1944
|
+
/** Hide the status line, when something else on screen is already saying it. */
|
|
1945
|
+
function clearStatus() {
|
|
1946
|
+
const statusEl = document.getElementById('status');
|
|
1947
|
+
statusEl.textContent = '';
|
|
1948
|
+
statusEl.className = 'status hidden';
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1394
1951
|
// Track if we're in full context mode
|
|
1395
1952
|
let isFullContextMode = false;
|
|
1396
1953
|
let storedFullFileLines = null;
|
|
@@ -1701,14 +2258,24 @@ function updateCommentsSidebar() {
|
|
|
1701
2258
|
// anyway. A comment's identity here is its position in `comments`,
|
|
1702
2259
|
// and that is a number. See #46.
|
|
1703
2260
|
const commentIndex = comments.indexOf(comment);
|
|
2261
|
+
// A comment is kept across comparisons -- switch from main -> feature
|
|
2262
|
+
// back to the last commit and a note on a file only the branch
|
|
2263
|
+
// touched is still yours. It just is not on screen, and a sidebar
|
|
2264
|
+
// entry that looks attached but does nothing when clicked reads as
|
|
2265
|
+
// lost. So it says where it stands.
|
|
2266
|
+
const elsewhere = !currentFiles.some(f => f.path === file);
|
|
2267
|
+
const elsewhereHtml = elsewhere
|
|
2268
|
+
? '<div class="comment-item-elsewhere-note">Not in this view</div>'
|
|
2269
|
+
: '';
|
|
1704
2270
|
html += `
|
|
1705
|
-
<div class="comment-item">
|
|
2271
|
+
<div class="comment-item${elsewhere ? ' comment-item-elsewhere' : ''}">
|
|
1706
2272
|
<button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(${Number(commentIndex)})" title="Delete comment">×</button>
|
|
1707
2273
|
<div class="comment-item-content" onclick="jumpToComment(${Number(commentIndex)})">
|
|
1708
2274
|
<div class="comment-item-file">${escapeHtml(file)}</div>
|
|
1709
2275
|
<div class="comment-item-line">Line ${comment.line}</div>
|
|
1710
2276
|
<div class="comment-item-text">${escapeHtml(comment.text)}</div>
|
|
1711
2277
|
${selectedHtml}
|
|
2278
|
+
${elsewhereHtml}
|
|
1712
2279
|
</div>
|
|
1713
2280
|
</div>
|
|
1714
2281
|
`;
|
|
@@ -1748,7 +2315,17 @@ function jumpToComment(commentIndex) {
|
|
|
1748
2315
|
|
|
1749
2316
|
// Find the file index
|
|
1750
2317
|
const fileIndex = currentFiles.findIndex(f => f.path === file);
|
|
1751
|
-
if (fileIndex === -1)
|
|
2318
|
+
if (fileIndex === -1) {
|
|
2319
|
+
// This used to return without a word, so clicking a comment did nothing
|
|
2320
|
+
// and looked broken. The comment is safe; the file is just not part of
|
|
2321
|
+
// what is being compared right now.
|
|
2322
|
+
showStatus(
|
|
2323
|
+
`${file} is not in the files you are looking at now, so this comment cannot be shown in place. ` +
|
|
2324
|
+
'It is kept, and will be back in place in any view that includes that file.',
|
|
2325
|
+
'loading'
|
|
2326
|
+
);
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
1752
2329
|
|
|
1753
2330
|
// Load the file
|
|
1754
2331
|
loadFile(file, fileIndex).then(() => {
|
|
@@ -1839,7 +2416,11 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
|
|
|
1839
2416
|
// must be checked before the typing guard.
|
|
1840
2417
|
if (key === 'Escape') return 'escape';
|
|
1841
2418
|
|
|
1842
|
-
|
|
2419
|
+
// SELECT as well as the text fields. With focus in one of the compare
|
|
2420
|
+
// pickers, a letter is the browser's type-ahead -- "c" jumps to the first
|
|
2421
|
+
// option starting with c -- and it also opened a comment box, because only
|
|
2422
|
+
// INPUT and TEXTAREA were excluded here when the pickers arrived in 2.12.
|
|
2423
|
+
if (targetTagName === 'INPUT' || targetTagName === 'TEXTAREA' || targetTagName === 'SELECT') {
|
|
1843
2424
|
return null;
|
|
1844
2425
|
}
|
|
1845
2426
|
|
|
@@ -1851,6 +2432,8 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
|
|
|
1851
2432
|
'n': 'nextComment',
|
|
1852
2433
|
'p': 'prevComment',
|
|
1853
2434
|
'c': 'commentFocus',
|
|
2435
|
+
'[': 'prevCommit',
|
|
2436
|
+
']': 'nextCommit',
|
|
1854
2437
|
'?': 'help'
|
|
1855
2438
|
};
|
|
1856
2439
|
|
|
@@ -1889,11 +2472,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1889
2472
|
}
|
|
1890
2473
|
});
|
|
1891
2474
|
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
2475
|
+
// The commits list: click reviews one, shift-click the run to it. Enter
|
|
2476
|
+
// and shift+Enter do the same from the keyboard, since every row is
|
|
2477
|
+
// focusable.
|
|
2478
|
+
const commitsList = document.getElementById('commitsList');
|
|
2479
|
+
commitsList.addEventListener('click', (e) => {
|
|
2480
|
+
const item = e.target.closest('.commit-item');
|
|
2481
|
+
if (!item) return;
|
|
2482
|
+
// Shift-click also extends the browser's text selection across the rows
|
|
2483
|
+
// it passes; that is noise here, not a gesture anyone meant.
|
|
2484
|
+
if (e.shiftKey) window.getSelection()?.removeAllRanges();
|
|
2485
|
+
selectCommit(item.dataset.sha, e.shiftKey);
|
|
1896
2486
|
});
|
|
2487
|
+
commitsList.addEventListener('keydown', (e) => {
|
|
2488
|
+
const item = e.target.closest('.commit-item');
|
|
2489
|
+
if (!item || e.key !== 'Enter') return;
|
|
2490
|
+
e.preventDefault();
|
|
2491
|
+
selectCommit(item.dataset.sha, e.shiftKey);
|
|
2492
|
+
});
|
|
2493
|
+
|
|
2494
|
+
for (const end of ['base', 'head']) {
|
|
2495
|
+
document.getElementById(PICKERS[end].other).addEventListener('keypress', (e) => {
|
|
2496
|
+
if (e.key === 'Enter') {
|
|
2497
|
+
compareNow();
|
|
2498
|
+
}
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
1897
2501
|
|
|
1898
2502
|
// Initialize resize handle
|
|
1899
2503
|
initResizeHandle();
|
|
@@ -1918,6 +2522,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1918
2522
|
} else if (action === 'commentFocus') {
|
|
1919
2523
|
e.preventDefault();
|
|
1920
2524
|
handleCommentShortcut();
|
|
2525
|
+
} else if (action === 'prevCommit') {
|
|
2526
|
+
e.preventDefault();
|
|
2527
|
+
stepCommit(-1);
|
|
2528
|
+
} else if (action === 'nextCommit') {
|
|
2529
|
+
e.preventDefault();
|
|
2530
|
+
stepCommit(1);
|
|
1921
2531
|
} else if (action === 'help') {
|
|
1922
2532
|
e.preventDefault();
|
|
1923
2533
|
document.getElementById('helpButton').click();
|