git-reviewer 2.11.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -23
- package/lib/agent.js +22 -2
- package/lib/changes.js +73 -4
- package/package.json +1 -1
- package/public/app.js +341 -96
- package/public/index.html +35 -21
- package/public/style.css +156 -48
- package/server.js +149 -25
package/public/app.js
CHANGED
|
@@ -137,122 +137,307 @@ 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
|
+
|
|
140
162
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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".
|
|
163
|
+
* Fetch the branches, tags and commits the pickers offer, and fill both.
|
|
148
164
|
*
|
|
149
|
-
*
|
|
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.
|
|
150
168
|
*/
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
169
|
+
async function loadRefs(repoId) {
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetch(`${API_BASE}/refs/${repoId}`);
|
|
172
|
+
currentRefs = response.ok ? await response.json() : null;
|
|
173
|
+
} catch {
|
|
174
|
+
currentRefs = null;
|
|
175
|
+
}
|
|
176
|
+
fillPicker('base');
|
|
177
|
+
fillPicker('head');
|
|
178
|
+
}
|
|
158
179
|
|
|
159
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Fill one picker, grouped the way people look for things.
|
|
182
|
+
*
|
|
183
|
+
* Branches first, because "my branch against main" is the comparison people
|
|
184
|
+
* reach for. Every label goes in through `new Option`, which sets text, not
|
|
185
|
+
* markup: branch names and commit subjects come from whatever repository is
|
|
186
|
+
* open, and a ref name may legally contain `<` and `>`.
|
|
187
|
+
*/
|
|
188
|
+
function fillPicker(end) {
|
|
189
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
190
|
+
const refs = currentRefs;
|
|
191
|
+
select.innerHTML = '';
|
|
192
|
+
|
|
193
|
+
const placeholder = new Option(end === 'base' ? 'Choose a base…' : 'Choose what to compare…', '');
|
|
194
|
+
placeholder.disabled = true;
|
|
195
|
+
select.appendChild(placeholder);
|
|
196
|
+
|
|
197
|
+
const group = (label, options) => {
|
|
198
|
+
if (options.length === 0) return;
|
|
199
|
+
const optgroup = document.createElement('optgroup');
|
|
200
|
+
optgroup.label = label;
|
|
201
|
+
options.forEach(option => optgroup.appendChild(option));
|
|
202
|
+
select.appendChild(optgroup);
|
|
203
|
+
};
|
|
160
204
|
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
if (refs) {
|
|
206
|
+
group('Branches', refs.branches.map(branch => new Option(
|
|
207
|
+
`${branch.name}${branch.name === refs.current ? ' (current)' : ''} · ${branch.when}`,
|
|
208
|
+
branch.name
|
|
209
|
+
)));
|
|
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)));
|
|
212
|
+
group(
|
|
213
|
+
refs.current ? `Recent commits on ${refs.current}` : 'Recent commits',
|
|
214
|
+
refs.commits.map(commit => new Option(`${commit.short} · ${commit.subject} · ${commit.when}`, commit.sha))
|
|
215
|
+
);
|
|
172
216
|
}
|
|
173
217
|
|
|
174
|
-
|
|
175
|
-
? 'Reviewing the last commit'
|
|
176
|
-
: 'Reviewing uncommitted changes';
|
|
218
|
+
select.appendChild(new Option('Other: a tag, HEAD~5, a sha…', OTHER_REF));
|
|
177
219
|
select.value = '';
|
|
178
|
-
document.getElementById('compareBase').value = '';
|
|
179
220
|
}
|
|
180
221
|
|
|
181
222
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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.
|
|
223
|
+
* Point a picker at a ref, by name if it is offered, by sha if the name is
|
|
224
|
+
* not but the commit is, and otherwise through "Other…" with the text shown.
|
|
188
225
|
*
|
|
189
|
-
* A
|
|
190
|
-
*
|
|
191
|
-
*
|
|
226
|
+
* A range opened by typing `HEAD~2` resolves to a commit that may well be in
|
|
227
|
+
* the list; showing it selected there is more useful than a picker stuck on
|
|
228
|
+
* "Other" around a value that is also sitting three rows down.
|
|
192
229
|
*/
|
|
193
|
-
|
|
194
|
-
const select = document.getElementById(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
230
|
+
function selectRef(end, name, sha) {
|
|
231
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
232
|
+
const other = document.getElementById(PICKERS[end].other);
|
|
233
|
+
const offered = value => [...select.options].some(option => option.value === value);
|
|
234
|
+
|
|
235
|
+
if (name && offered(name)) {
|
|
236
|
+
select.value = name;
|
|
237
|
+
} else if (sha && offered(sha)) {
|
|
238
|
+
select.value = sha;
|
|
239
|
+
} else if (name || sha) {
|
|
240
|
+
select.value = OTHER_REF;
|
|
241
|
+
other.value = name || displayRef(sha);
|
|
242
|
+
other.classList.remove('hidden');
|
|
243
|
+
return;
|
|
244
|
+
} else {
|
|
245
|
+
select.value = '';
|
|
246
|
+
}
|
|
247
|
+
other.classList.add('hidden');
|
|
248
|
+
other.value = '';
|
|
249
|
+
}
|
|
199
250
|
|
|
200
|
-
|
|
201
|
-
|
|
251
|
+
/** The ref one end of the comparison currently names, or '' if none yet. */
|
|
252
|
+
function pickedRef(end) {
|
|
253
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
254
|
+
return select.value === OTHER_REF
|
|
255
|
+
? document.getElementById(PICKERS[end].other).value.trim()
|
|
256
|
+
: select.value;
|
|
257
|
+
}
|
|
202
258
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
259
|
+
/** Either picker changed: compare as soon as both ends are known. */
|
|
260
|
+
function onPickerChange(end) {
|
|
261
|
+
const select = document.getElementById(PICKERS[end].select);
|
|
262
|
+
const other = document.getElementById(PICKERS[end].other);
|
|
209
263
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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 */
|
|
264
|
+
if (select.value === OTHER_REF) {
|
|
265
|
+
other.classList.remove('hidden');
|
|
266
|
+
other.focus();
|
|
267
|
+
return; // Enter in the box compares; see the listeners in DOMContentLoaded.
|
|
220
268
|
}
|
|
269
|
+
|
|
270
|
+
other.classList.add('hidden');
|
|
271
|
+
compareNow();
|
|
221
272
|
}
|
|
222
273
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
274
|
+
function compareNow() {
|
|
275
|
+
const base = pickedRef('base');
|
|
276
|
+
const head = pickedRef('head');
|
|
277
|
+
if (base && head) loadRepo({ base, head });
|
|
278
|
+
}
|
|
227
279
|
|
|
228
|
-
|
|
229
|
-
|
|
280
|
+
/** Reverse the comparison: "what does main have that my branch does not". */
|
|
281
|
+
function swapCompare() {
|
|
282
|
+
const base = pickedRef('base');
|
|
283
|
+
const head = pickedRef('head');
|
|
284
|
+
if (base && head) loadRepo({ base: head, head: base });
|
|
230
285
|
}
|
|
231
286
|
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
287
|
+
/**
|
|
288
|
+
* The base most people mean, if the repository makes it obvious.
|
|
289
|
+
*
|
|
290
|
+
* The remote comes first: a local `main` that has not been pulled in a week
|
|
291
|
+
* is a quietly wrong base, and `origin/main` is what the pull request will be
|
|
292
|
+
* measured against. Never the branch you are on -- comparing a branch with
|
|
293
|
+
* itself is always empty, and offering it as a default would make the first
|
|
294
|
+
* click of the feature look broken.
|
|
295
|
+
*/
|
|
296
|
+
function defaultBase() {
|
|
297
|
+
const refs = currentRefs;
|
|
298
|
+
if (!refs) return null;
|
|
299
|
+
|
|
300
|
+
const known = new Set([...refs.branches, ...refs.remotes].map(ref => ref.name));
|
|
301
|
+
const candidates = ['origin/main', 'main', 'origin/master', 'master', 'origin/develop', 'develop'];
|
|
302
|
+
return candidates.find(name => known.has(name) && name !== refs.current) ?? null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Open the compare row. With an obvious base, compare straight away: one
|
|
307
|
+
* click to the view a pull request would give you. Without one, show the
|
|
308
|
+
* pickers and wait -- a guessed comparison nobody asked for is worse than
|
|
309
|
+
* asking.
|
|
310
|
+
*/
|
|
311
|
+
function openCompare() {
|
|
312
|
+
document.getElementById('compareRow').classList.remove('hidden');
|
|
313
|
+
const head = currentRefs?.current ?? 'HEAD';
|
|
314
|
+
const base = defaultBase();
|
|
235
315
|
|
|
236
|
-
if (
|
|
237
|
-
|
|
316
|
+
if (base) {
|
|
317
|
+
loadRepo({ base, head });
|
|
238
318
|
return;
|
|
239
319
|
}
|
|
240
320
|
|
|
241
|
-
|
|
321
|
+
selectRef('base', null, null);
|
|
322
|
+
selectRef('head', head, null);
|
|
323
|
+
document.getElementById('compareToggle').classList.add('hidden');
|
|
324
|
+
document.getElementById('scopeResetBtn').classList.remove('hidden');
|
|
325
|
+
document.getElementById(PICKERS.base.select).focus();
|
|
242
326
|
}
|
|
243
327
|
|
|
244
328
|
/** Back to the default scope: uncommitted changes, or the last commit. */
|
|
245
329
|
function resetScope() {
|
|
246
|
-
document.getElementById('
|
|
247
|
-
document.getElementById('compareSelect').value = '';
|
|
330
|
+
document.getElementById('compareRow').classList.add('hidden');
|
|
248
331
|
loadRepo();
|
|
249
332
|
}
|
|
250
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Say what the review is of, in the names the reviewer used.
|
|
336
|
+
*
|
|
337
|
+
* Built from DOM nodes rather than markup: branch names are chosen by
|
|
338
|
+
* whoever made the repository, and one may legally contain `<`.
|
|
339
|
+
*
|
|
340
|
+
* @param {{mode: string, range: ?object, files: object[]}} data
|
|
341
|
+
*/
|
|
342
|
+
function renderScope(data) {
|
|
343
|
+
const bar = document.getElementById('scopeBar');
|
|
344
|
+
const label = document.getElementById('scopeLabel');
|
|
345
|
+
const meta = document.getElementById('scopeMeta');
|
|
346
|
+
const note = document.getElementById('scopeNote');
|
|
347
|
+
const row = document.getElementById('compareRow');
|
|
348
|
+
const toggle = document.getElementById('compareToggle');
|
|
349
|
+
const reset = document.getElementById('scopeResetBtn');
|
|
350
|
+
|
|
351
|
+
const strong = text => {
|
|
352
|
+
const el = document.createElement('strong');
|
|
353
|
+
el.textContent = text;
|
|
354
|
+
return el;
|
|
355
|
+
};
|
|
356
|
+
const setNote = text => {
|
|
357
|
+
note.textContent = text ?? '';
|
|
358
|
+
note.classList.toggle('hidden', !text);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
bar.classList.remove('hidden');
|
|
362
|
+
label.replaceChildren();
|
|
363
|
+
const branch = currentRefs?.current;
|
|
364
|
+
|
|
365
|
+
if (data.mode === 'range' && data.range) {
|
|
366
|
+
const range = data.range;
|
|
367
|
+
const base = displayRef(range.baseName ?? range.base);
|
|
368
|
+
const head = displayRef(range.headName ?? range.head);
|
|
369
|
+
const files = data.files.length;
|
|
370
|
+
|
|
371
|
+
label.append(strong(base), ' → ', strong(head));
|
|
372
|
+
meta.textContent = files > 0
|
|
373
|
+
? `${plural(range.commits, 'commit')} · ${plural(files, 'file')} changed`
|
|
374
|
+
: `${plural(range.commits, 'commit')} · no files differ`;
|
|
375
|
+
|
|
376
|
+
// The empty cases first: they are the ones that need explaining, and an
|
|
377
|
+
// empty file list with no explanation reads as the tool having failed.
|
|
378
|
+
if (files === 0 && range.commits === 0) {
|
|
379
|
+
setNote(range.behind > 0
|
|
380
|
+
? `${head} has nothing that ${base} does not already have. Try swapping them to see what ${base} adds.`
|
|
381
|
+
: `${base} and ${head} are the same commit, so there is nothing between them.`);
|
|
382
|
+
} else if (files === 0) {
|
|
383
|
+
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.`);
|
|
384
|
+
} else if (range.behind > 0) {
|
|
385
|
+
// The merge-base diff leaves these out on purpose, the way a pull
|
|
386
|
+
// request does. Say so, or someone goes looking for main's change and
|
|
387
|
+
// concludes the tool lost it.
|
|
388
|
+
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.`);
|
|
389
|
+
} else {
|
|
390
|
+
setNote(null);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
row.classList.remove('hidden');
|
|
394
|
+
selectRef('base', range.baseName, range.base);
|
|
395
|
+
selectRef('head', range.headName, range.head);
|
|
396
|
+
toggle.classList.add('hidden');
|
|
397
|
+
reset.classList.remove('hidden');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (data.mode === 'lastCommit') {
|
|
402
|
+
label.append('Last commit');
|
|
403
|
+
if (branch) label.append(' on ', strong(branch));
|
|
404
|
+
const tip = currentRefs?.commits?.[0];
|
|
405
|
+
meta.textContent = tip ? `${tip.subject} · ${tip.short}` : '';
|
|
406
|
+
} else {
|
|
407
|
+
label.append('Uncommitted changes');
|
|
408
|
+
if (branch) label.append(' on ', strong(branch));
|
|
409
|
+
meta.textContent = plural(data.files.length, 'file') + ' changed';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
setNote(null);
|
|
413
|
+
row.classList.add('hidden');
|
|
414
|
+
toggle.classList.remove('hidden');
|
|
415
|
+
reset.classList.add('hidden');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Empty the diff pane, so a finished comparison cannot leave the last one's file on screen. */
|
|
419
|
+
function clearCodePane() {
|
|
420
|
+
currentFile = null;
|
|
421
|
+
currentDiffLines = [];
|
|
422
|
+
document.getElementById('codeSection').classList.add('hidden');
|
|
423
|
+
document.getElementById('currentFile').textContent = '';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** What the file list says when a comparison has no files in it. */
|
|
427
|
+
function showEmptyFileList(message) {
|
|
428
|
+
const filesList = document.getElementById('filesList');
|
|
429
|
+
const empty = document.createElement('div');
|
|
430
|
+
empty.className = 'files-empty';
|
|
431
|
+
empty.textContent = message;
|
|
432
|
+
filesList.replaceChildren(empty);
|
|
433
|
+
}
|
|
434
|
+
|
|
251
435
|
/**
|
|
252
436
|
* Load a repository, optionally as a range between two commits.
|
|
253
437
|
*
|
|
254
|
-
* @param {{base?: string}} [scope] omit for the default:
|
|
255
|
-
* falling back to the last commit on a clean tree.
|
|
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.
|
|
256
441
|
*/
|
|
257
442
|
async function loadRepo(scope = {}) {
|
|
258
443
|
const repoPath = document.getElementById('repoPath').value.trim();
|
|
@@ -269,7 +454,9 @@ async function loadRepo(scope = {}) {
|
|
|
269
454
|
const response = await fetch(`${API_BASE}/load-repo`, {
|
|
270
455
|
method: 'POST',
|
|
271
456
|
headers: { 'Content-Type': 'application/json' },
|
|
272
|
-
body: JSON.stringify(scope.base
|
|
457
|
+
body: JSON.stringify(scope.base
|
|
458
|
+
? { repoPath, base: scope.base, ...(scope.head ? { head: scope.head } : {}) }
|
|
459
|
+
: { repoPath })
|
|
273
460
|
});
|
|
274
461
|
|
|
275
462
|
const data = await response.json();
|
|
@@ -289,9 +476,9 @@ async function loadRepo(scope = {}) {
|
|
|
289
476
|
setRepoButton(data.repoPath);
|
|
290
477
|
}
|
|
291
478
|
|
|
292
|
-
// Before renderScope, which needs the options present to
|
|
293
|
-
//
|
|
294
|
-
await
|
|
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);
|
|
295
482
|
renderScope(data);
|
|
296
483
|
|
|
297
484
|
if (data.files.length === 0) {
|
|
@@ -301,9 +488,16 @@ async function loadRepo(scope = {}) {
|
|
|
301
488
|
// that covers -- so this is information, not an error, and it must not
|
|
302
489
|
// be dressed as one.
|
|
303
490
|
if (data.mode === 'range') {
|
|
304
|
-
|
|
491
|
+
// The scope bar is already explaining this; a status line repeating
|
|
492
|
+
// it underneath is two bars saying one thing.
|
|
493
|
+
clearStatus();
|
|
305
494
|
hidePicker();
|
|
306
495
|
displayFiles([]);
|
|
496
|
+
showEmptyFileList('No files differ between these two.');
|
|
497
|
+
updateCommentsSidebar();
|
|
498
|
+
// Or the previous comparison's file stays on screen under a bar that
|
|
499
|
+
// says there are no files, and reads as part of this one.
|
|
500
|
+
clearCodePane();
|
|
307
501
|
document.getElementById('loadBtn').disabled = false;
|
|
308
502
|
return;
|
|
309
503
|
}
|
|
@@ -313,9 +507,15 @@ async function loadRepo(scope = {}) {
|
|
|
313
507
|
return;
|
|
314
508
|
}
|
|
315
509
|
|
|
316
|
-
|
|
510
|
+
// The scope bar above says what was loaded, in more useful terms than
|
|
511
|
+
// this line did ("Found 3 changed file(s)"), so there is nothing left
|
|
512
|
+
// for the status line to add on success.
|
|
513
|
+
clearStatus();
|
|
317
514
|
hidePicker();
|
|
318
515
|
displayFiles(data.files);
|
|
516
|
+
// Which comments are in view depends on which files are, so the sidebar
|
|
517
|
+
// is redrawn for every new scope, not only when saved comments arrive.
|
|
518
|
+
updateCommentsSidebar();
|
|
319
519
|
|
|
320
520
|
// Load saved comments
|
|
321
521
|
const savedComments = await loadCommentsFromBackend();
|
|
@@ -335,7 +535,7 @@ async function loadRepo(scope = {}) {
|
|
|
335
535
|
// Show comments sidebar immediately
|
|
336
536
|
updateCommentsSidebar();
|
|
337
537
|
|
|
338
|
-
showStatus(
|
|
538
|
+
showStatus(`Picked up ${plural(savedComments.length, 'saved comment')} from last time.`, 'success');
|
|
339
539
|
}
|
|
340
540
|
|
|
341
541
|
// Open something. A loaded repository used to sit behind an empty pane
|
|
@@ -362,6 +562,15 @@ async function loadRepo(scope = {}) {
|
|
|
362
562
|
}
|
|
363
563
|
|
|
364
564
|
// Display list of changed files
|
|
565
|
+
/** What each status letter means, for the badge's tooltip and screen readers. */
|
|
566
|
+
const FILE_STATUS_NAMES = {
|
|
567
|
+
A: 'Added',
|
|
568
|
+
M: 'Modified',
|
|
569
|
+
D: 'Deleted',
|
|
570
|
+
R: 'Renamed',
|
|
571
|
+
B: 'Binary'
|
|
572
|
+
};
|
|
573
|
+
|
|
365
574
|
function fileStatusClass(status) {
|
|
366
575
|
switch (status) {
|
|
367
576
|
case 'A': return 'file-status-added';
|
|
@@ -396,7 +605,12 @@ function displayFiles(files) {
|
|
|
396
605
|
? `${escapeHtml(path)}/<span class="filename">${escapeHtml(filename)}</span>`
|
|
397
606
|
: `<span class="filename">${escapeHtml(filename)}</span>`;
|
|
398
607
|
const statusClass = fileStatusClass(status);
|
|
399
|
-
|
|
608
|
+
// One letter is git's vocabulary, not everyone's. The name rides along as
|
|
609
|
+
// a tooltip and as the accessible label, so "D" is never the only thing
|
|
610
|
+
// saying that a file is gone. `status` is one of five letters the server
|
|
611
|
+
// produces, and escaped anyway: it is data that ends up in markup.
|
|
612
|
+
const statusName = FILE_STATUS_NAMES[status] ?? 'Changed';
|
|
613
|
+
const statusBadge = `<span class="file-status ${statusClass}" title="${statusName}" aria-label="${statusName}">${escapeHtml(status)}</span>`;
|
|
400
614
|
|
|
401
615
|
// The row used to carry the file name itself, inside a string literal
|
|
402
616
|
// inside the attribute the browser evaluates as JavaScript. A name with a
|
|
@@ -1387,10 +1601,19 @@ function resetApp() {
|
|
|
1387
1601
|
// Show status message
|
|
1388
1602
|
function showStatus(message, type) {
|
|
1389
1603
|
const statusEl = document.getElementById('status');
|
|
1390
|
-
|
|
1604
|
+
// No "Status:" prefix. The element is role="status", which is what tells a
|
|
1605
|
+
// screen reader what it is; sighted readers could already see that.
|
|
1606
|
+
statusEl.textContent = message;
|
|
1391
1607
|
statusEl.className = `status ${type}`;
|
|
1392
1608
|
}
|
|
1393
1609
|
|
|
1610
|
+
/** Hide the status line, when something else on screen is already saying it. */
|
|
1611
|
+
function clearStatus() {
|
|
1612
|
+
const statusEl = document.getElementById('status');
|
|
1613
|
+
statusEl.textContent = '';
|
|
1614
|
+
statusEl.className = 'status hidden';
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1394
1617
|
// Track if we're in full context mode
|
|
1395
1618
|
let isFullContextMode = false;
|
|
1396
1619
|
let storedFullFileLines = null;
|
|
@@ -1701,14 +1924,24 @@ function updateCommentsSidebar() {
|
|
|
1701
1924
|
// anyway. A comment's identity here is its position in `comments`,
|
|
1702
1925
|
// and that is a number. See #46.
|
|
1703
1926
|
const commentIndex = comments.indexOf(comment);
|
|
1927
|
+
// A comment is kept across comparisons -- switch from main -> feature
|
|
1928
|
+
// back to the last commit and a note on a file only the branch
|
|
1929
|
+
// touched is still yours. It just is not on screen, and a sidebar
|
|
1930
|
+
// entry that looks attached but does nothing when clicked reads as
|
|
1931
|
+
// lost. So it says where it stands.
|
|
1932
|
+
const elsewhere = !currentFiles.some(f => f.path === file);
|
|
1933
|
+
const elsewhereHtml = elsewhere
|
|
1934
|
+
? '<div class="comment-item-elsewhere-note">Not in this view</div>'
|
|
1935
|
+
: '';
|
|
1704
1936
|
html += `
|
|
1705
|
-
<div class="comment-item">
|
|
1937
|
+
<div class="comment-item${elsewhere ? ' comment-item-elsewhere' : ''}">
|
|
1706
1938
|
<button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(${Number(commentIndex)})" title="Delete comment">×</button>
|
|
1707
1939
|
<div class="comment-item-content" onclick="jumpToComment(${Number(commentIndex)})">
|
|
1708
1940
|
<div class="comment-item-file">${escapeHtml(file)}</div>
|
|
1709
1941
|
<div class="comment-item-line">Line ${comment.line}</div>
|
|
1710
1942
|
<div class="comment-item-text">${escapeHtml(comment.text)}</div>
|
|
1711
1943
|
${selectedHtml}
|
|
1944
|
+
${elsewhereHtml}
|
|
1712
1945
|
</div>
|
|
1713
1946
|
</div>
|
|
1714
1947
|
`;
|
|
@@ -1748,7 +1981,17 @@ function jumpToComment(commentIndex) {
|
|
|
1748
1981
|
|
|
1749
1982
|
// Find the file index
|
|
1750
1983
|
const fileIndex = currentFiles.findIndex(f => f.path === file);
|
|
1751
|
-
if (fileIndex === -1)
|
|
1984
|
+
if (fileIndex === -1) {
|
|
1985
|
+
// This used to return without a word, so clicking a comment did nothing
|
|
1986
|
+
// and looked broken. The comment is safe; the file is just not part of
|
|
1987
|
+
// what is being compared right now.
|
|
1988
|
+
showStatus(
|
|
1989
|
+
`${file} is not in the files you are looking at now, so this comment cannot be shown in place. ` +
|
|
1990
|
+
'It is kept, and will be back in place in any view that includes that file.',
|
|
1991
|
+
'loading'
|
|
1992
|
+
);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1752
1995
|
|
|
1753
1996
|
// Load the file
|
|
1754
1997
|
loadFile(file, fileIndex).then(() => {
|
|
@@ -1889,11 +2132,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1889
2132
|
}
|
|
1890
2133
|
});
|
|
1891
2134
|
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
2135
|
+
for (const end of ['base', 'head']) {
|
|
2136
|
+
document.getElementById(PICKERS[end].other).addEventListener('keypress', (e) => {
|
|
2137
|
+
if (e.key === 'Enter') {
|
|
2138
|
+
compareNow();
|
|
2139
|
+
}
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
1897
2142
|
|
|
1898
2143
|
// Initialize resize handle
|
|
1899
2144
|
initResizeHandle();
|
package/public/index.html
CHANGED
|
@@ -53,28 +53,42 @@
|
|
|
53
53
|
</button>
|
|
54
54
|
</div>
|
|
55
55
|
</div>
|
|
56
|
-
<div id="status" class="status"></div>
|
|
57
|
-
|
|
58
|
-
<div id="scopeBar" class="scope-bar hidden">
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
<
|
|
67
|
-
</
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
<div id="status" class="status hidden" role="status" aria-live="polite"></div>
|
|
57
|
+
|
|
58
|
+
<div id="scopeBar" class="scope-bar hidden" role="region" aria-label="What is being reviewed">
|
|
59
|
+
<div class="scope-summary">
|
|
60
|
+
<div class="scope-text">
|
|
61
|
+
<span id="scopeLabel" class="scope-label"></span>
|
|
62
|
+
<span id="scopeMeta" class="scope-meta"></span>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="scope-actions">
|
|
65
|
+
<button id="compareToggle" class="scope-action" onclick="openCompare()">Compare branches</button>
|
|
66
|
+
<button id="scopeResetBtn" class="scope-action hidden" onclick="resetScope()">Stop comparing</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- GitHub's vocabulary, deliberately: "base" and "compare" are what
|
|
71
|
+
the people using this already call the two ends of a pull request. -->
|
|
72
|
+
<div id="compareRow" class="compare-row hidden">
|
|
73
|
+
<div class="compare-end">
|
|
74
|
+
<label for="compareBaseSelect">Base</label>
|
|
75
|
+
<select id="compareBaseSelect" onchange="onPickerChange('base')"></select>
|
|
76
|
+
<input id="compareBaseOther" class="compare-other hidden" type="text"
|
|
77
|
+
placeholder="tag, HEAD~5 or sha" spellcheck="false" autocomplete="off"
|
|
78
|
+
aria-label="Base ref">
|
|
79
|
+
</div>
|
|
80
|
+
<button id="compareSwap" class="compare-swap" onclick="swapCompare()"
|
|
81
|
+
title="Swap base and compare" aria-label="Swap base and compare">⇄</button>
|
|
82
|
+
<div class="compare-end">
|
|
83
|
+
<label for="compareHeadSelect">Compare</label>
|
|
84
|
+
<select id="compareHeadSelect" onchange="onPickerChange('head')"></select>
|
|
85
|
+
<input id="compareHeadOther" class="compare-other hidden" type="text"
|
|
86
|
+
placeholder="tag, HEAD~5 or sha" spellcheck="false" autocomplete="off"
|
|
87
|
+
aria-label="Compare ref">
|
|
88
|
+
</div>
|
|
77
89
|
</div>
|
|
90
|
+
|
|
91
|
+
<p id="scopeNote" class="scope-note hidden"></p>
|
|
78
92
|
</div>
|
|
79
93
|
</header>
|
|
80
94
|
|