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/README.md +61 -27
- package/bin/reviewer.js +8 -4
- package/lib/agent.js +11 -0
- package/lib/cli.js +48 -16
- package/man/git-reviewer.1 +80 -0
- package/package.json +7 -3
- package/public/app.js +384 -19
- package/public/index.html +25 -2
- package/public/style.css +144 -0
- package/server.js +183 -12
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
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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]
|
|
439
|
-
* uncommitted changes, falling back to the last
|
|
440
|
-
* `head`
|
|
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(
|
|
458
|
-
? { repoPath,
|
|
459
|
-
|
|
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
|
-
//
|
|
480
|
-
//
|
|
481
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
<
|
|
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">‹</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">›</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>
|