git-reviewer 2.12.0 → 2.13.1

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
@@ -166,9 +166,10 @@ const PICKERS = {
166
166
  * works; a repository whose refs will not list is not a reason to refuse to
167
167
  * compare it.
168
168
  */
169
- async function loadRefs(repoId) {
169
+ async function loadRefs(repoId, head) {
170
170
  try {
171
- const response = await fetch(`${API_BASE}/refs/${repoId}`);
171
+ const query = head ? `?${new URLSearchParams({ head })}` : '';
172
+ const response = await fetch(`${API_BASE}/refs/${repoId}${query}`);
172
173
  currentRefs = response.ok ? await response.json() : null;
173
174
  } catch {
174
175
  currentRefs = null;
@@ -207,12 +208,15 @@ function fillPicker(end) {
207
208
  `${branch.name}${branch.name === refs.current ? ' (current)' : ''} · ${branch.when}`,
208
209
  branch.name
209
210
  )));
210
- group('Remote branches', refs.remotes.map(branch => new Option(`${branch.name} · ${branch.when}`, branch.name)));
211
- group('Tags', refs.tags.map(tag => new Option(`${tag.name} · ${tag.when}`, tag.name)));
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.
212
214
  group(
213
- refs.current ? `Recent commits on ${refs.current}` : 'Recent commits',
215
+ 'Recent commits',
214
216
  refs.commits.map(commit => new Option(`${commit.short} · ${commit.subject} · ${commit.when}`, commit.sha))
215
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)));
216
220
  }
217
221
 
218
222
  select.appendChild(new Option('Other: a tag, HEAD~5, a sha…', OTHER_REF));
@@ -353,15 +357,89 @@ function renderScope(data) {
353
357
  el.textContent = text;
354
358
  return el;
355
359
  };
356
- const setNote = text => {
360
+ const setNote = (text, { message = false } = {}) => {
357
361
  note.textContent = text ?? '';
358
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);
359
365
  };
360
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
+
361
374
  bar.classList.remove('hidden');
362
375
  label.replaceChildren();
363
376
  const branch = currentRefs?.current;
364
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
+ }
401
+
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
+ }
442
+
365
443
  if (data.mode === 'range' && data.range) {
366
444
  const range = data.range;
367
445
  const base = displayRef(range.baseName ?? range.base);
@@ -401,20 +479,265 @@ function renderScope(data) {
401
479
  if (data.mode === 'lastCommit') {
402
480
  label.append('Last commit');
403
481
  if (branch) label.append(' on ', strong(branch));
404
- const tip = currentRefs?.commits?.[0];
482
+ const tip = commitContext?.commits?.[0] ?? currentRefs?.commits?.[0];
405
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
+ }
406
494
  } else {
407
495
  label.append('Uncommitted changes');
408
496
  if (branch) label.append(' on ', strong(branch));
409
497
  meta.textContent = plural(data.files.length, 'file') + ' changed';
498
+ setNote(null);
410
499
  }
411
500
 
412
- setNote(null);
413
501
  row.classList.add('hidden');
414
502
  toggle.classList.remove('hidden');
415
503
  reset.classList.add('hidden');
416
504
  }
417
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;
561
+ }
562
+
563
+ /**
564
+ * "3 hours ago" as "3h", for a list where the subject needs the width.
565
+ *
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.
570
+ *
571
+ * @param {string} when git's %cr, e.g. "3 weeks ago" or "1 year, 2 months ago"
572
+ */
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);
584
+
585
+ try {
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
+ }
594
+
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
+ }
609
+
610
+ commitAnchor = null;
611
+ commitSelection = null;
612
+
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;
625
+ }
626
+ }
627
+
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
+ : '';
650
+
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' });
700
+ }
701
+
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;
710
+
711
+ if (extend && commitAnchor) {
712
+ const run = orderedRun(chronologicalCommits(commitContext), commitAnchor, sha);
713
+ if (run) loadRepo({ commits: { from: run[0], to: run[1] } });
714
+ return;
715
+ }
716
+
717
+ commitAnchor = sha;
718
+ loadRepo({ commits: { from: sha, to: sha } });
719
+ }
720
+
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
+
418
741
  /** Empty the diff pane, so a finished comparison cannot leave the last one's file on screen. */
419
742
  function clearCodePane() {
420
743
  currentFile = null;
@@ -435,9 +758,10 @@ function showEmptyFileList(message) {
435
758
  /**
436
759
  * Load a repository, optionally as a range between two commits.
437
760
  *
438
- * @param {{base?: string, head?: string}} [scope] omit for the default:
439
- * uncommitted changes, falling back to the last commit on a clean tree.
440
- * `head` defaults to HEAD on the server.
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.
441
765
  */
442
766
  async function loadRepo(scope = {}) {
443
767
  const repoPath = document.getElementById('repoPath').value.trim();
@@ -454,9 +778,11 @@ async function loadRepo(scope = {}) {
454
778
  const response = await fetch(`${API_BASE}/load-repo`, {
455
779
  method: 'POST',
456
780
  headers: { 'Content-Type': 'application/json' },
457
- body: JSON.stringify(scope.base
458
- ? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
459
- : { 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
+ )
460
786
  });
461
787
 
462
788
  const data = await response.json();
@@ -476,10 +802,14 @@ async function loadRepo(scope = {}) {
476
802
  setRepoButton(data.repoPath);
477
803
  }
478
804
 
479
- // Before renderScope, which needs the options present to select the
480
- // current ends among them, and the current branch to name it.
481
- await loadRefs(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);
482
811
  renderScope(data);
812
+ renderCommitList();
483
813
 
484
814
  if (data.files.length === 0) {
485
815
  // A range with no files is a real answer, not a failure: every change
@@ -493,7 +823,11 @@ async function loadRepo(scope = {}) {
493
823
  clearStatus();
494
824
  hidePicker();
495
825
  displayFiles([]);
496
- showEmptyFileList('No files differ between these two.');
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.');
497
831
  updateCommentsSidebar();
498
832
  // Or the previous comparison's file stays on screen under a bar that
499
833
  // says there are no files, and reads as part of this one.
@@ -2082,7 +2416,11 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
2082
2416
  // must be checked before the typing guard.
2083
2417
  if (key === 'Escape') return 'escape';
2084
2418
 
2085
- 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') {
2086
2424
  return null;
2087
2425
  }
2088
2426
 
@@ -2094,6 +2432,8 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
2094
2432
  'n': 'nextComment',
2095
2433
  'p': 'prevComment',
2096
2434
  'c': 'commentFocus',
2435
+ '[': 'prevCommit',
2436
+ ']': 'nextCommit',
2097
2437
  '?': 'help'
2098
2438
  };
2099
2439
 
@@ -2132,6 +2472,25 @@ document.addEventListener('DOMContentLoaded', () => {
2132
2472
  }
2133
2473
  });
2134
2474
 
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);
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
+
2135
2494
  for (const end of ['base', 'head']) {
2136
2495
  document.getElementById(PICKERS[end].other).addEventListener('keypress', (e) => {
2137
2496
  if (e.key === 'Enter') {
@@ -2163,6 +2522,12 @@ document.addEventListener('DOMContentLoaded', () => {
2163
2522
  } else if (action === 'commentFocus') {
2164
2523
  e.preventDefault();
2165
2524
  handleCommentShortcut();
2525
+ } else if (action === 'prevCommit') {
2526
+ e.preventDefault();
2527
+ stepCommit(-1);
2528
+ } else if (action === 'nextCommit') {
2529
+ e.preventDefault();
2530
+ stepCommit(1);
2166
2531
  } else if (action === 'help') {
2167
2532
  e.preventDefault();
2168
2533
  document.getElementById('helpButton').click();
package/public/index.html CHANGED
@@ -62,7 +62,16 @@
62
62
  <span id="scopeMeta" class="scope-meta"></span>
63
63
  </div>
64
64
  <div class="scope-actions">
65
- <button id="compareToggle" class="scope-action" onclick="openCompare()">Compare branches</button>
65
+ <div id="commitNav" class="commit-nav hidden" role="group" aria-label="Step through commits">
66
+ <button id="prevCommitBtn" class="scope-action" onclick="stepCommit(-1)"
67
+ title="Older commit ( [ )" aria-label="Older commit">&#8249;</button>
68
+ <span id="commitPosition" class="commit-position"></span>
69
+ <button id="nextCommitBtn" class="scope-action" onclick="stepCommit(1)"
70
+ title="Newer commit ( ] )" aria-label="Newer commit">&#8250;</button>
71
+ </div>
72
+ <button id="allCommitsBtn" class="scope-action hidden" onclick="showAllCommits()">All commits</button>
73
+ <button id="compareToggle" class="scope-action" onclick="openCompare()"
74
+ title="Compare two branches, tags or commits">Compare</button>
66
75
  <button id="scopeResetBtn" class="scope-action hidden" onclick="resetScope()">Stop comparing</button>
67
76
  </div>
68
77
  </div>
@@ -186,7 +195,7 @@
186
195
  <span class="help-keys">
187
196
  <kbd>⌘</kbd><kbd>Enter</kbd> save · <kbd>Esc</kbd> cancel ·
188
197
  <kbd>↑</kbd><kbd>↓</kbd><kbd>j</kbd><kbd>k</kbd> files ·
189
- <kbd>n</kbd><kbd>p</kbd> comments · <kbd>c</kbd> new comment · <kbd>?</kbd> help
198
+ <kbd>n</kbd><kbd>p</kbd> comments · <kbd>[</kbd><kbd>]</kbd> commits · <kbd>c</kbd> new comment · <kbd>?</kbd> help
190
199
  </span>
191
200
  <button class="help-done" onclick="closeHelp()">Got it</button>
192
201
  </div>
@@ -194,6 +203,20 @@
194
203
  </div>
195
204
 
196
205
  <aside id="sidebar" class="sidebar hidden">
206
+ <!-- The commits a review is made of, when it is made of commits. Click
207
+ one to review it on its own; shift-click a second to review the run
208
+ between them -- the same gesture a pull request's commit picker
209
+ uses, so it does not have to be learned twice. -->
210
+ <section id="commitsSection" class="commits-section hidden" aria-labelledby="commitsTitle">
211
+ <div class="sidebar-header">
212
+ <h2 id="commitsTitle">Commits</h2>
213
+ <span id="commitCount" class="file-count"></span>
214
+ </div>
215
+ <p class="commits-hint">Click one · <kbd>Shift</kbd>-click a run · <kbd>[</kbd><kbd>]</kbd> step</p>
216
+ <ol id="commitsList" class="commits-list" role="listbox" aria-label="Commits" aria-multiselectable="true"></ol>
217
+ <p id="commitsTruncated" class="commits-truncated hidden"></p>
218
+ </section>
219
+
197
220
  <div class="sidebar-header">
198
221
  <h2>Changed Files</h2>
199
222
  <span id="fileCount" class="file-count"></span>