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/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, above the files.
339
+ * Say what the review is of, in the names the reviewer used.
142
340
  *
143
- * The empty-range case is the reason this exists. `git diff x3 x5` over work
144
- * that was added and then removed is correctly empty, and a file list showing
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: ?{base: string, head: string, commits: number}}} data
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
- reset.classList.toggle('hidden', data.mode !== 'range');
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
- const select = document.getElementById('compareSelect');
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 { base, head, commits } = data.range;
163
- label.textContent =
164
- `Comparing ${shortSha(base)} \u2192 ${shortSha(head)} \u2014 ` +
165
- `${commits} commit${commits === 1 ? '' : 's'}, shown as one combined diff`;
166
- // Only if the base is one of the offered commits. A range opened by
167
- // typing `HEAD~5` or a branch name has no matching option, and leaving
168
- // the picker on its placeholder is honest -- the scope bar above already
169
- // says exactly what is being compared.
170
- select.value = [...select.options].some(option => option.value === base) ? base : '';
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
- label.textContent = data.mode === 'lastCommit'
175
- ? 'Reviewing the last commit'
176
- : 'Reviewing uncommitted changes';
177
- select.value = '';
178
- document.getElementById('compareBase').value = '';
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
- * Fill the commit picker for the open repository.
564
+ * "3 hours ago" as "3h", for a list where the subject needs the width.
183
565
  *
184
- * The newest commit is deliberately not offered. "Changes since HEAD" is
185
- * either nothing or the working tree depending on how you squint, and both
186
- * of those are already what "Back to latest" gives you -- so listing it
187
- * would be offering a choice that cannot do anything useful.
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
- * A failure here is silent on purpose: the text box next to it still works,
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
- async function loadCommitOptions(repoId) {
194
- const select = document.getElementById('compareSelect');
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}/commits/${repoId}`);
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
- const { commits } = await response.json();
201
- const options = commits.slice(1);
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
- select.innerHTML = '';
204
- if (options.length === 0) {
205
- select.appendChild(new Option('No earlier commits', ''));
206
- select.disabled = true;
207
- return;
208
- }
610
+ commitAnchor = null;
611
+ commitSelection = null;
209
612
 
210
- select.disabled = false;
211
- select.appendChild(new Option('Pick a commit…', ''));
212
- for (const commit of options) {
213
- // The subject is what a person recognises; the sha is there to
214
- // disambiguate two commits with the same message, which is common
215
- // enough in a branch that has been rebased.
216
- select.appendChild(new Option(`${commit.short} · ${commit.subject} · ${commit.when}`, commit.sha));
217
- }
218
- } catch {
219
- /* the text box beside it is the fallback */
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
- /** Compare from whatever was chosen in the picker. */
224
- function compareFromSelection() {
225
- const base = document.getElementById('compareSelect').value;
226
- if (!base) return;
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
- document.getElementById('compareBase').value = '';
229
- loadRepo({ base });
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
- /** Reload the repository as a range starting at whatever was typed. */
233
- function compareFrom() {
234
- const base = document.getElementById('compareBase').value.trim();
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 (!base) {
237
- showStatus('Enter a commit, tag or branch to compare from', 'error');
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
- loadRepo({ base });
717
+ commitAnchor = sha;
718
+ loadRepo({ commits: { from: sha, to: sha } });
242
719
  }
243
720
 
244
- /** Back to the default scope: uncommitted changes, or the last commit. */
245
- function resetScope() {
246
- document.getElementById('compareBase').value = '';
247
- document.getElementById('compareSelect').value = '';
248
- loadRepo();
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}} [scope] omit for the default: uncommitted changes,
255
- * falling back to the last commit on a clean tree.
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(scope.base ? { repoPath, base: scope.base } : { repoPath })
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
- // Before renderScope, which needs the options present to be able to
293
- // select the current base among them.
294
- await loadCommitOptions(data.repoId);
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
- showStatus(data.message, 'success');
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
- showStatus(data.message, 'success');
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(`${data.message} - Loaded ${savedComments.length} saved comment(s)`, 'success');
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
- const statusBadge = `<span class="file-status ${statusClass}">${status}</span>`;
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
- statusEl.textContent = 'Status: ' + message;
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) return;
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
- if (targetTagName === 'INPUT' || targetTagName === 'TEXTAREA') {
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
- document.getElementById('compareBase').addEventListener('keypress', (e) => {
1893
- if (e.key === 'Enter') {
1894
- compareFrom();
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();