git-reviewer 2.1.0 → 2.3.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 +44 -12
- package/bin/reviewer.js +33 -3
- package/lib/agent.js +62 -6
- package/lib/browse.js +129 -0
- package/lib/cli.js +17 -3
- package/lib/paths.js +25 -0
- package/lib/recents.js +123 -0
- package/lib/repo.js +53 -0
- package/package.json +4 -3
- package/public/app.js +458 -62
- package/public/index.html +60 -6
- package/public/style.css +333 -2
- package/server.js +118 -7
package/public/app.js
CHANGED
|
@@ -159,6 +159,14 @@ async function loadRepo() {
|
|
|
159
159
|
currentRepoId = data.repoId;
|
|
160
160
|
currentFiles = data.files;
|
|
161
161
|
|
|
162
|
+
// The server answers with the root of the working tree, which is not
|
|
163
|
+
// always what was asked for: open a subdirectory and the repository above
|
|
164
|
+
// it is what gets reviewed. Show the path that is actually loaded.
|
|
165
|
+
if (data.repoPath) {
|
|
166
|
+
document.getElementById('repoPath').value = data.repoPath;
|
|
167
|
+
setRepoButton(data.repoPath);
|
|
168
|
+
}
|
|
169
|
+
|
|
162
170
|
if (data.files.length === 0) {
|
|
163
171
|
showStatus('No uncommitted changes found in the repository', 'error');
|
|
164
172
|
document.getElementById('loadBtn').disabled = false;
|
|
@@ -166,6 +174,7 @@ async function loadRepo() {
|
|
|
166
174
|
}
|
|
167
175
|
|
|
168
176
|
showStatus(data.message, 'success');
|
|
177
|
+
hidePicker();
|
|
169
178
|
displayFiles(data.files);
|
|
170
179
|
|
|
171
180
|
// Load saved comments
|
|
@@ -187,8 +196,23 @@ async function loadRepo() {
|
|
|
187
196
|
showStatus(`${data.message} - Loaded ${savedComments.length} saved comment(s)`, 'success');
|
|
188
197
|
}
|
|
189
198
|
|
|
199
|
+
// Open something. A loaded repository used to sit behind an empty pane
|
|
200
|
+
// until a file was clicked, which reads as a failure rather than as a
|
|
201
|
+
// result (#47). Done here rather than in `displayFiles` because the saved
|
|
202
|
+
// comments have to be in hand first, or the diff renders without them.
|
|
203
|
+
//
|
|
204
|
+
// The file already open wins if it is still in the list, so reloading a
|
|
205
|
+
// repository does not move you off what you were reading.
|
|
206
|
+
if (currentFiles.length > 0) {
|
|
207
|
+
const previous = currentFiles.findIndex(file => file.path === currentFile);
|
|
208
|
+
const opening = previous === -1 ? 0 : previous;
|
|
209
|
+
|
|
210
|
+
loadFile(currentFiles[opening].path, opening);
|
|
211
|
+
}
|
|
212
|
+
|
|
190
213
|
} catch (error) {
|
|
191
214
|
showStatus(`Error: ${error.message}`, 'error');
|
|
215
|
+
showPicker();
|
|
192
216
|
} finally {
|
|
193
217
|
document.getElementById('loadBtn').disabled = false;
|
|
194
218
|
}
|
|
@@ -209,12 +233,25 @@ function displayFiles(files) {
|
|
|
209
233
|
const filename = parts[parts.length - 1];
|
|
210
234
|
const path = parts.slice(0, -1).join('/');
|
|
211
235
|
|
|
212
|
-
// Because of RTL, we need to reverse the order in HTML
|
|
213
|
-
|
|
236
|
+
// Because of RTL, we need to reverse the order in HTML. The label is
|
|
237
|
+
// markup -- the name is split so the last segment can be emphasised -- so
|
|
238
|
+
// both halves are escaped going in. They were not, and a name is chosen by
|
|
239
|
+
// whatever repository is open: markup in one was drawn as markup, without
|
|
240
|
+
// anyone clicking anything.
|
|
241
|
+
const displayText = path
|
|
242
|
+
? `${escapeHtml(path)}/<span class="filename">${escapeHtml(filename)}</span>`
|
|
243
|
+
: `<span class="filename">${escapeHtml(filename)}</span>`;
|
|
214
244
|
const statusClass = status === 'A' ? 'file-status-added' : 'file-status-modified';
|
|
215
245
|
const statusBadge = `<span class="file-status ${statusClass}">${status}</span>`;
|
|
216
246
|
|
|
217
|
-
|
|
247
|
+
// The row used to carry the file name itself, inside a string literal
|
|
248
|
+
// inside the attribute the browser evaluates as JavaScript. A name with a
|
|
249
|
+
// quote in it ended that literal and the rest of the name ran on click
|
|
250
|
+
// (#46). Escaping cannot close it: entities in an attribute are decoded
|
|
251
|
+
// before the handler is parsed, so an escaped quote is a quote again by
|
|
252
|
+
// the time it matters. The row carries its position instead, and the name
|
|
253
|
+
// is read back out of `currentFiles`, where it stays data.
|
|
254
|
+
return `<div class="file-item" onclick="openFileAt(${Number(index)})" title="${escapeHtml(file)}">
|
|
218
255
|
${statusBadge}<span class="file-path-text">${displayText}</span>
|
|
219
256
|
</div>`;
|
|
220
257
|
}).join('');
|
|
@@ -228,6 +265,22 @@ function displayFiles(files) {
|
|
|
228
265
|
updateCommentsSidebar();
|
|
229
266
|
}
|
|
230
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Open the file at a position in the list. The handler the file rows carry.
|
|
270
|
+
*
|
|
271
|
+
* `loadFile` still takes a path, because every other caller already has one in
|
|
272
|
+
* hand and none of them travels through an attribute. This one does, so it
|
|
273
|
+
* takes the index and looks the path up here. See the note in `displayFiles`.
|
|
274
|
+
*
|
|
275
|
+
* @param {number} index position in `currentFiles`
|
|
276
|
+
*/
|
|
277
|
+
function openFileAt(index) {
|
|
278
|
+
const file = currentFiles[Number(index)];
|
|
279
|
+
if (!file) return;
|
|
280
|
+
|
|
281
|
+
loadFile(file.path, Number(index));
|
|
282
|
+
}
|
|
283
|
+
|
|
231
284
|
// Load file content
|
|
232
285
|
async function loadFile(filePath, index) {
|
|
233
286
|
if (!currentRepoId) return Promise.resolve();
|
|
@@ -296,7 +349,7 @@ async function loadFile(filePath, index) {
|
|
|
296
349
|
updateCommentsSidebar();
|
|
297
350
|
}
|
|
298
351
|
|
|
299
|
-
displayCode(data.filePath, data.diffLines);
|
|
352
|
+
displayCode(data.filePath, data.diffLines, data.binary);
|
|
300
353
|
updateFullContextButton();
|
|
301
354
|
return Promise.resolve();
|
|
302
355
|
|
|
@@ -307,7 +360,7 @@ async function loadFile(filePath, index) {
|
|
|
307
360
|
}
|
|
308
361
|
|
|
309
362
|
// Display code with diff highlighting
|
|
310
|
-
function displayCode(filePath, diffLines) {
|
|
363
|
+
function displayCode(filePath, diffLines, binary) {
|
|
311
364
|
const codeSection = document.getElementById('codeSection');
|
|
312
365
|
const currentFileEl = document.getElementById('currentFile');
|
|
313
366
|
const codeViewer = document.getElementById('codeViewer');
|
|
@@ -315,6 +368,19 @@ function displayCode(filePath, diffLines) {
|
|
|
315
368
|
currentFileEl.textContent = filePath;
|
|
316
369
|
currentDiffLines = diffLines; // Store for reference when adding comments
|
|
317
370
|
|
|
371
|
+
// A binary file has no lines to show and none to comment on. Saying so is
|
|
372
|
+
// the whole feature: it used to be decoded as UTF-8 and rendered as
|
|
373
|
+
// mojibake, and a comment left on one of those lines exported an anchor
|
|
374
|
+
// that could never match the file again.
|
|
375
|
+
if (binary) {
|
|
376
|
+
codeSection.style.display = 'block';
|
|
377
|
+
codeViewer.innerHTML =
|
|
378
|
+
'<div class="binary-notice">Binary file — not shown.' +
|
|
379
|
+
'<span>git reports this file as binary, so there are no lines to ' +
|
|
380
|
+
'review. Comments need a line to anchor to.</span></div>';
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
318
384
|
const fileComments = comments.filter(c => c.file === filePath);
|
|
319
385
|
|
|
320
386
|
let html = '';
|
|
@@ -329,8 +395,13 @@ function displayCode(filePath, diffLines) {
|
|
|
329
395
|
const clickableLineNum = diffLine.newLine || diffLine.oldLine;
|
|
330
396
|
|
|
331
397
|
// Make both old and new line numbers clickable
|
|
332
|
-
|
|
333
|
-
|
|
398
|
+
// `Number(...)` at every interpolation into a handler, here and below. The
|
|
399
|
+
// rule the page now keeps is that nothing but a number goes into an
|
|
400
|
+
// attribute the browser evaluates as code, and coercing it where it is
|
|
401
|
+
// written makes that true of the markup rather than true of whatever the
|
|
402
|
+
// caller happened to pass. See `displayFiles` and #46.
|
|
403
|
+
const oldLineClick = diffLine.type === 'delete' ? `onclick="toggleCommentInput(${Number(diffLineIndex)})"` : '';
|
|
404
|
+
const newLineClick = diffLine.type !== 'delete' ? `onclick="toggleCommentInput(${Number(diffLineIndex)})"` : '';
|
|
334
405
|
|
|
335
406
|
html += `
|
|
336
407
|
<div class="${lineClass}" data-diff-index="${diffLineIndex}">
|
|
@@ -344,7 +415,6 @@ function displayCode(filePath, diffLines) {
|
|
|
344
415
|
`;
|
|
345
416
|
|
|
346
417
|
if (hasComment) {
|
|
347
|
-
const selectedTextAttr = hasComment.selectedText ? `'${escapeHtml(hasComment.selectedText).replace(/'/g, "\\'")}'` : 'null';
|
|
348
418
|
const matchTypeClass = hasComment.matchType === 'fuzzy' ? 'comment-box-fuzzy' : '';
|
|
349
419
|
const fuzzyWarning = hasComment.matchType === 'fuzzy' ? `
|
|
350
420
|
<div class="comment-fuzzy-warning">
|
|
@@ -375,9 +445,9 @@ function displayCode(filePath, diffLines) {
|
|
|
375
445
|
<span class="comment-text">${escapeHtml(hasComment.text)}</span>
|
|
376
446
|
${followUpsHtml}
|
|
377
447
|
<div class="comment-actions">
|
|
378
|
-
<button class="comment-reply" onclick="addFollowUp(${diffLineIndex})">Reply</button>
|
|
379
|
-
<button class="comment-edit" onclick="editComment(${diffLineIndex}
|
|
380
|
-
<button class="comment-delete" onclick="deleteComment(${diffLineIndex})">Delete</button>
|
|
448
|
+
<button class="comment-reply" onclick="addFollowUp(${Number(diffLineIndex)})">Reply</button>
|
|
449
|
+
<button class="comment-edit" onclick="editComment(${Number(diffLineIndex)})">Edit</button>
|
|
450
|
+
<button class="comment-delete" onclick="deleteComment(${Number(diffLineIndex)})">Delete</button>
|
|
381
451
|
</div>
|
|
382
452
|
</div>
|
|
383
453
|
`;
|
|
@@ -386,11 +456,10 @@ function displayCode(filePath, diffLines) {
|
|
|
386
456
|
|
|
387
457
|
// Show unmatched comments at the end
|
|
388
458
|
const unmatchedComments = fileComments.filter(c => c.matchType === 'unmatched');
|
|
459
|
+
unmatchedShown = unmatchedComments;
|
|
389
460
|
if (unmatchedComments.length > 0) {
|
|
390
461
|
html += '<div class="unmatched-comments-section">';
|
|
391
|
-
unmatchedComments.forEach(comment => {
|
|
392
|
-
const commentId = `unmatched-${Math.random().toString(36).substr(2, 9)}`;
|
|
393
|
-
|
|
462
|
+
unmatchedComments.forEach((comment, unmatchedIndex) => {
|
|
394
463
|
// Generate follow-ups for unmatched comments
|
|
395
464
|
let unmatchedFollowUpsHtml = '';
|
|
396
465
|
if (comment.followUps && comment.followUps.length > 0) {
|
|
@@ -407,9 +476,16 @@ function displayCode(filePath, diffLines) {
|
|
|
407
476
|
unmatchedFollowUpsHtml += '</div>';
|
|
408
477
|
}
|
|
409
478
|
|
|
479
|
+
// Every control in here used to carry the comment's file name and text,
|
|
480
|
+
// quoted into an attribute that is evaluated as JavaScript -- with `'`
|
|
481
|
+
// replaced by `\'` and nothing else, which a name ending in a backslash
|
|
482
|
+
// walks straight out of. They carry the row's position instead, and the
|
|
483
|
+
// three fields are read back from `unmatchedShown`, the array this was
|
|
484
|
+
// rendered from. The row's random id went with it: the position is the
|
|
485
|
+
// identifier now.
|
|
410
486
|
html += `
|
|
411
|
-
<div class="unmatched-comment-item collapsed"
|
|
412
|
-
<div class="unmatched-comment-header" onclick="toggleUnmatchedComment(
|
|
487
|
+
<div class="unmatched-comment-item collapsed">
|
|
488
|
+
<div class="unmatched-comment-header" onclick="toggleUnmatchedComment(${Number(unmatchedIndex)})">
|
|
413
489
|
<span class="unmatched-comment-icon">⚠️</span>
|
|
414
490
|
<span class="unmatched-comment-title">Comment not found (Line ${comment.line} changed)</span>
|
|
415
491
|
<span class="unmatched-comment-toggle">▶</span>
|
|
@@ -423,8 +499,8 @@ function displayCode(filePath, diffLines) {
|
|
|
423
499
|
${comment.selectedText ? `<div class="comment-item-selected">${escapeHtml(comment.selectedText)}</div>` : ''}
|
|
424
500
|
${unmatchedFollowUpsHtml}
|
|
425
501
|
<div class="comment-actions">
|
|
426
|
-
<button class="comment-reply" onclick="addFollowUpToUnmatched(
|
|
427
|
-
<button class="comment-delete" onclick="deleteUnmatchedComment(
|
|
502
|
+
<button class="comment-reply" onclick="addFollowUpToUnmatched(${Number(unmatchedIndex)})">Reply</button>
|
|
503
|
+
<button class="comment-delete" onclick="deleteUnmatchedComment(${Number(unmatchedIndex)})">Delete</button>
|
|
428
504
|
</div>
|
|
429
505
|
</div>
|
|
430
506
|
</div>
|
|
@@ -443,9 +519,29 @@ function displayCode(filePath, diffLines) {
|
|
|
443
519
|
codeViewer.addEventListener('mouseup', handleTextSelection);
|
|
444
520
|
}
|
|
445
521
|
|
|
522
|
+
/**
|
|
523
|
+
* The unmatched comments the code pane is showing, in the order they were
|
|
524
|
+
* drawn. The rows below are identified by their position in here, because the
|
|
525
|
+
* fields that used to identify them -- a file name and the comment's own text
|
|
526
|
+
* -- cannot be written into a handler attribute safely. See #46.
|
|
527
|
+
*
|
|
528
|
+
* @type {object[]}
|
|
529
|
+
*/
|
|
530
|
+
let unmatchedShown = [];
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The row an unmatched comment was drawn into.
|
|
534
|
+
*
|
|
535
|
+
* @param {number} index position in `unmatchedShown`
|
|
536
|
+
* @returns {Element|null}
|
|
537
|
+
*/
|
|
538
|
+
function unmatchedRow(index) {
|
|
539
|
+
return document.querySelectorAll('.unmatched-comment-item')[Number(index)] ?? null;
|
|
540
|
+
}
|
|
541
|
+
|
|
446
542
|
// Toggle unmatched comment expansion
|
|
447
|
-
function toggleUnmatchedComment(
|
|
448
|
-
const element =
|
|
543
|
+
function toggleUnmatchedComment(index) {
|
|
544
|
+
const element = unmatchedRow(index);
|
|
449
545
|
if (!element) return;
|
|
450
546
|
|
|
451
547
|
element.classList.toggle('collapsed');
|
|
@@ -456,7 +552,11 @@ function toggleUnmatchedComment(commentId) {
|
|
|
456
552
|
}
|
|
457
553
|
|
|
458
554
|
// Delete unmatched comment
|
|
459
|
-
function deleteUnmatchedComment(
|
|
555
|
+
function deleteUnmatchedComment(index) {
|
|
556
|
+
const target = unmatchedShown[Number(index)];
|
|
557
|
+
if (!target) return;
|
|
558
|
+
|
|
559
|
+
const { file, line, text } = target;
|
|
460
560
|
comments = comments.filter(c => !(c.file === file && c.line === line && c.text === text));
|
|
461
561
|
|
|
462
562
|
// Auto-save to backend
|
|
@@ -493,7 +593,7 @@ function addFollowUp(diffLineIndex) {
|
|
|
493
593
|
inputBox.innerHTML = `
|
|
494
594
|
<textarea placeholder="Enter your follow-up (Cmd/Ctrl+Enter to save)..." id="followupInput"></textarea>
|
|
495
595
|
<div class="actions">
|
|
496
|
-
<button onclick="saveFollowUp(${diffLineIndex})">Add Follow-up</button>
|
|
596
|
+
<button onclick="saveFollowUp(${Number(diffLineIndex)})">Add Follow-up</button>
|
|
497
597
|
<button class="cancel-btn" onclick="this.closest('.followup-input-box').remove()">Cancel</button>
|
|
498
598
|
</div>
|
|
499
599
|
`;
|
|
@@ -564,25 +664,17 @@ function saveFollowUp(diffLineIndex) {
|
|
|
564
664
|
}
|
|
565
665
|
|
|
566
666
|
// Add follow-up to an unmatched comment
|
|
567
|
-
function addFollowUpToUnmatched(
|
|
667
|
+
function addFollowUpToUnmatched(index) {
|
|
568
668
|
// Remove any existing follow-up input
|
|
569
669
|
const existingInput = document.querySelector('.followup-input-box');
|
|
570
670
|
if (existingInput) {
|
|
571
671
|
existingInput.remove();
|
|
572
672
|
}
|
|
573
673
|
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
unmatchedItems.forEach(item => {
|
|
579
|
-
const itemText = item.querySelector('.comment-text')?.textContent;
|
|
580
|
-
const itemLine = item.querySelector('.unmatched-comment-title')?.textContent;
|
|
581
|
-
if (itemText === text && itemLine?.includes(`Line ${line}`)) {
|
|
582
|
-
targetItem = item;
|
|
583
|
-
}
|
|
584
|
-
});
|
|
585
|
-
|
|
674
|
+
// The row this belongs to, by position. It used to be found by comparing the
|
|
675
|
+
// rendered text against the comment's own text, which picked the wrong row
|
|
676
|
+
// when two unmatched comments on a file said the same thing on the same line.
|
|
677
|
+
const targetItem = unmatchedRow(index);
|
|
586
678
|
if (!targetItem) return;
|
|
587
679
|
|
|
588
680
|
const commentBody = targetItem.querySelector('.unmatched-comment-body');
|
|
@@ -592,13 +684,15 @@ function addFollowUpToUnmatched(file, line, text) {
|
|
|
592
684
|
const inputBox = document.createElement('div');
|
|
593
685
|
inputBox.className = 'followup-input-box';
|
|
594
686
|
|
|
595
|
-
//
|
|
596
|
-
|
|
687
|
+
// The input is named after the row, which is one number, so the button that
|
|
688
|
+
// saves it needs to carry nothing but that number. It used to carry the file
|
|
689
|
+
// name, the comment text and a random id, all quoted into the handler.
|
|
690
|
+
const inputId = `followupInput-${Number(index)}`;
|
|
597
691
|
|
|
598
692
|
inputBox.innerHTML = `
|
|
599
693
|
<textarea placeholder="Enter your follow-up (Cmd/Ctrl+Enter to save)..." id="${inputId}"></textarea>
|
|
600
694
|
<div class="actions">
|
|
601
|
-
<button onclick="saveFollowUpToUnmatched(
|
|
695
|
+
<button onclick="saveFollowUpToUnmatched(${Number(index)})">Add Follow-up</button>
|
|
602
696
|
<button class="cancel-btn" onclick="this.closest('.followup-input-box').remove()">Cancel</button>
|
|
603
697
|
</div>
|
|
604
698
|
`;
|
|
@@ -625,7 +719,7 @@ function addFollowUpToUnmatched(file, line, text) {
|
|
|
625
719
|
textarea.addEventListener('keydown', (e) => {
|
|
626
720
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
627
721
|
e.preventDefault();
|
|
628
|
-
saveFollowUpToUnmatched(
|
|
722
|
+
saveFollowUpToUnmatched(index);
|
|
629
723
|
}
|
|
630
724
|
if (e.key === 'Escape') {
|
|
631
725
|
e.preventDefault();
|
|
@@ -635,8 +729,8 @@ function addFollowUpToUnmatched(file, line, text) {
|
|
|
635
729
|
}
|
|
636
730
|
|
|
637
731
|
// Save follow-up to an unmatched comment
|
|
638
|
-
function saveFollowUpToUnmatched(
|
|
639
|
-
const input = document.getElementById(
|
|
732
|
+
function saveFollowUpToUnmatched(index) {
|
|
733
|
+
const input = document.getElementById(`followupInput-${Number(index)}`);
|
|
640
734
|
const followUpText = input?.value.trim();
|
|
641
735
|
|
|
642
736
|
if (!followUpText) {
|
|
@@ -644,7 +738,10 @@ function saveFollowUpToUnmatched(file, line, text, inputId) {
|
|
|
644
738
|
return;
|
|
645
739
|
}
|
|
646
740
|
|
|
647
|
-
|
|
741
|
+
// The row's position, not the three fields that used to be quoted into the
|
|
742
|
+
// button. `unmatchedShown` holds the same objects `comments` does, so the
|
|
743
|
+
// follow-up is pushed onto the comment itself rather than onto a copy.
|
|
744
|
+
const comment = unmatchedShown[Number(index)];
|
|
648
745
|
if (!comment) return;
|
|
649
746
|
|
|
650
747
|
// Initialize followUps if it doesn't exist
|
|
@@ -664,9 +761,9 @@ function saveFollowUpToUnmatched(file, line, text, inputId) {
|
|
|
664
761
|
updateCommentsSidebar();
|
|
665
762
|
|
|
666
763
|
// Reload the current file to show the follow-up
|
|
667
|
-
if (currentFile === file) {
|
|
668
|
-
const fileIndex = currentFiles.findIndex(f => f.path === file);
|
|
669
|
-
loadFile(file, fileIndex);
|
|
764
|
+
if (currentFile === comment.file) {
|
|
765
|
+
const fileIndex = currentFiles.findIndex(f => f.path === comment.file);
|
|
766
|
+
loadFile(comment.file, fileIndex);
|
|
670
767
|
}
|
|
671
768
|
}
|
|
672
769
|
|
|
@@ -750,6 +847,25 @@ function handleTextSelection(e) {
|
|
|
750
847
|
toggleCommentInputWithSelection(diffLineIndex, selectedText);
|
|
751
848
|
}
|
|
752
849
|
|
|
850
|
+
/**
|
|
851
|
+
* The selected code, as an attribute on the button that saves it.
|
|
852
|
+
*
|
|
853
|
+
* The save button used to carry the selection inside a template literal inside
|
|
854
|
+
* its handler, which is a JavaScript context: a `${` in the selected code
|
|
855
|
+
* opened an interpolation and ran whatever followed it, and the file being
|
|
856
|
+
* reviewed is where that code comes from. The selection is not an index into
|
|
857
|
+
* anything -- it is a fragment of a live selection -- so it travels as data on
|
|
858
|
+
* the element and the handler reads it back at click time. A `data-` attribute
|
|
859
|
+
* is an attribute and nothing more; `escapeHtml` is enough for it, and the
|
|
860
|
+
* browser hands the original string back through `dataset`.
|
|
861
|
+
*
|
|
862
|
+
* @param {string|null} selectedText
|
|
863
|
+
* @returns {string} an attribute to splice into the tag, or nothing
|
|
864
|
+
*/
|
|
865
|
+
function selectedTextData(selectedText) {
|
|
866
|
+
return selectedText ? ` data-selected-text="${escapeHtml(selectedText)}"` : '';
|
|
867
|
+
}
|
|
868
|
+
|
|
753
869
|
// Toggle comment input
|
|
754
870
|
function toggleCommentInput(diffLineIndex) {
|
|
755
871
|
toggleCommentInputWithSelection(diffLineIndex, null);
|
|
@@ -783,7 +899,7 @@ function toggleCommentInputWithSelection(diffLineIndex, selectedText = null) {
|
|
|
783
899
|
${selectedTextHtml}
|
|
784
900
|
<textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput"></textarea>
|
|
785
901
|
<div class="actions">
|
|
786
|
-
<button onclick="saveComment(${diffLineIndex},
|
|
902
|
+
<button${selectedTextData(selectedText)} onclick="saveComment(${Number(diffLineIndex)}, this.dataset.selectedText ?? null)">Save Comment</button>
|
|
787
903
|
<button class="cancel-btn" onclick="this.closest('.comment-input-box').remove()">Cancel</button>
|
|
788
904
|
</div>
|
|
789
905
|
`;
|
|
@@ -856,10 +972,17 @@ function saveComment(diffLineIndex, selectedText = null) {
|
|
|
856
972
|
}
|
|
857
973
|
|
|
858
974
|
// Edit comment
|
|
859
|
-
function editComment(diffLineIndex
|
|
975
|
+
function editComment(diffLineIndex) {
|
|
860
976
|
const comment = comments.find(c => c.file === currentFile && c.diffLineIndex === diffLineIndex);
|
|
861
977
|
if (!comment) return;
|
|
862
978
|
|
|
979
|
+
// The Edit button used to hand the selected code back in as an argument,
|
|
980
|
+
// quoted into its own handler -- and after `escapeHtml` learned to escape
|
|
981
|
+
// quotes, the hand-rolled backslash escaping beside it stopped matching
|
|
982
|
+
// anything at all, so a quote in the selected code ended the literal and the
|
|
983
|
+
// rest ran. The comment is looked up here anyway; the selection comes off it.
|
|
984
|
+
const selectedText = comment.selectedText ?? null;
|
|
985
|
+
|
|
863
986
|
// Remove the comment from the list temporarily
|
|
864
987
|
comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
|
|
865
988
|
|
|
@@ -884,7 +1007,7 @@ function editComment(diffLineIndex, selectedText = null) {
|
|
|
884
1007
|
${selectedTextHtml}
|
|
885
1008
|
<textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput">${escapeHtml(comment.text)}</textarea>
|
|
886
1009
|
<div class="actions">
|
|
887
|
-
<button onclick="saveComment(${diffLineIndex},
|
|
1010
|
+
<button${selectedTextData(selectedText)} onclick="saveComment(${Number(diffLineIndex)}, this.dataset.selectedText ?? null)">Save Comment</button>
|
|
888
1011
|
<button class="cancel-btn" onclick="this.closest('.comment-input-box').remove()">Cancel</button>
|
|
889
1012
|
</div>
|
|
890
1013
|
`;
|
|
@@ -985,8 +1108,8 @@ function showReviewModal(reviewContent, filename) {
|
|
|
985
1108
|
<pre class="review-text">${escapeHtml(reviewContent)}</pre>
|
|
986
1109
|
</div>
|
|
987
1110
|
<div class="review-modal-footer">
|
|
988
|
-
<button onclick="downloadReview(
|
|
989
|
-
Download ${filename}
|
|
1111
|
+
<button data-filename="${escapeHtml(filename)}" onclick="downloadReview(this.dataset.filename, this.closest('.review-modal').querySelector('.review-text').textContent)">
|
|
1112
|
+
Download ${escapeHtml(filename)}
|
|
990
1113
|
</button>
|
|
991
1114
|
<button onclick="copyReviewToClipboard(this.closest('.review-modal').querySelector('.review-text').textContent)">
|
|
992
1115
|
Copy to Clipboard
|
|
@@ -1046,6 +1169,7 @@ function resetApp() {
|
|
|
1046
1169
|
comments = [];
|
|
1047
1170
|
|
|
1048
1171
|
document.getElementById('repoPath').value = '';
|
|
1172
|
+
setRepoButton(null);
|
|
1049
1173
|
document.getElementById('sidebar').classList.add('hidden');
|
|
1050
1174
|
document.getElementById('resizeHandle').classList.add('hidden');
|
|
1051
1175
|
document.getElementById('codeSection').classList.add('hidden');
|
|
@@ -1053,6 +1177,8 @@ function resetApp() {
|
|
|
1053
1177
|
document.getElementById('submitReviewBtn').classList.add('hidden');
|
|
1054
1178
|
document.getElementById('status').textContent = '';
|
|
1055
1179
|
document.getElementById('status').className = 'status';
|
|
1180
|
+
|
|
1181
|
+
showPicker();
|
|
1056
1182
|
}
|
|
1057
1183
|
|
|
1058
1184
|
// Show status message
|
|
@@ -1183,20 +1309,19 @@ function displayFullContext(filePath, lines) {
|
|
|
1183
1309
|
${hasComment ? '<span class="comment-indicator"></span>' : ''}
|
|
1184
1310
|
<div class="line-numbers">
|
|
1185
1311
|
<span class="old-line-number">${diffLine.oldLine || ''}</span>
|
|
1186
|
-
<span class="new-line-number" ${diffLineIndex !== undefined ? `onclick="toggleCommentInput(${diffLineIndex})"` : ''}>${diffLine.newLine || ''}</span>
|
|
1312
|
+
<span class="new-line-number" ${diffLineIndex !== undefined ? `onclick="toggleCommentInput(${Number(diffLineIndex)})"` : ''}>${diffLine.newLine || ''}</span>
|
|
1187
1313
|
</div>
|
|
1188
1314
|
<div class="line-content">${escapeHtml(diffLine.content) || ' '}</div>
|
|
1189
1315
|
</div>
|
|
1190
1316
|
`;
|
|
1191
1317
|
|
|
1192
1318
|
if (hasComment) {
|
|
1193
|
-
const selectedTextAttr = hasComment.selectedText ? `'${escapeHtml(hasComment.selectedText).replace(/'/g, "\\'")}'` : 'null';
|
|
1194
1319
|
html += `
|
|
1195
1320
|
<div class="comment-box">
|
|
1196
1321
|
<span class="comment-text">${escapeHtml(hasComment.text)}</span>
|
|
1197
1322
|
<div class="comment-actions">
|
|
1198
|
-
<button class="comment-edit" onclick="editComment(${diffLineIndex}
|
|
1199
|
-
<button class="comment-delete" onclick="deleteComment(${diffLineIndex})">Delete</button>
|
|
1323
|
+
<button class="comment-edit" onclick="editComment(${Number(diffLineIndex)})">Edit</button>
|
|
1324
|
+
<button class="comment-delete" onclick="deleteComment(${Number(diffLineIndex)})">Delete</button>
|
|
1200
1325
|
</div>
|
|
1201
1326
|
</div>
|
|
1202
1327
|
`;
|
|
@@ -1216,9 +1341,18 @@ function displayFullContext(filePath, lines) {
|
|
|
1216
1341
|
|
|
1217
1342
|
// Escape HTML
|
|
1218
1343
|
function escapeHtml(text) {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1344
|
+
// The DOM does this for &, < and > and leaves both quote characters alone,
|
|
1345
|
+
// which is right for a text node and wrong for an attribute -- and most of
|
|
1346
|
+
// the callers here are attributes. A file or folder whose name contained a
|
|
1347
|
+
// double quote closed the attribute early, and everything after it in the
|
|
1348
|
+
// name was read as markup. Escaped here rather than at each call site,
|
|
1349
|
+
// because the safe version has to be the one that is easy to reach for.
|
|
1350
|
+
return String(text ?? '')
|
|
1351
|
+
.replace(/&/g, '&')
|
|
1352
|
+
.replace(/</g, '<')
|
|
1353
|
+
.replace(/>/g, '>')
|
|
1354
|
+
.replace(/"/g, '"')
|
|
1355
|
+
.replace(/'/g, ''');
|
|
1222
1356
|
}
|
|
1223
1357
|
|
|
1224
1358
|
// Sidebar resize functionality
|
|
@@ -1324,11 +1458,16 @@ function updateCommentsSidebar() {
|
|
|
1324
1458
|
? `<div class="comment-item-selected">${escapeHtml(comment.selectedText)}</div>`
|
|
1325
1459
|
: '';
|
|
1326
1460
|
|
|
1327
|
-
|
|
1461
|
+
// Both controls used to carry the file name, quoted into a handler
|
|
1462
|
+
// with `'` replaced by `\'` and nothing else -- so a name with a
|
|
1463
|
+
// backslash before the quote escaped the escape and ended the string
|
|
1464
|
+
// anyway. A comment's identity here is its position in `comments`,
|
|
1465
|
+
// and that is a number. See #46.
|
|
1466
|
+
const commentIndex = comments.indexOf(comment);
|
|
1328
1467
|
html += `
|
|
1329
1468
|
<div class="comment-item">
|
|
1330
|
-
<button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(
|
|
1331
|
-
<div class="comment-item-content" onclick="jumpToComment(
|
|
1469
|
+
<button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(${Number(commentIndex)})" title="Delete comment">×</button>
|
|
1470
|
+
<div class="comment-item-content" onclick="jumpToComment(${Number(commentIndex)})">
|
|
1332
1471
|
<div class="comment-item-file">${escapeHtml(file)}</div>
|
|
1333
1472
|
<div class="comment-item-line">Line ${comment.line}</div>
|
|
1334
1473
|
<div class="comment-item-text">${escapeHtml(comment.text)}</div>
|
|
@@ -1343,7 +1482,11 @@ function updateCommentsSidebar() {
|
|
|
1343
1482
|
}
|
|
1344
1483
|
|
|
1345
1484
|
// Delete comment from sidebar
|
|
1346
|
-
function deleteCommentFromSidebar(
|
|
1485
|
+
function deleteCommentFromSidebar(commentIndex) {
|
|
1486
|
+
const target = comments[Number(commentIndex)];
|
|
1487
|
+
if (!target) return;
|
|
1488
|
+
|
|
1489
|
+
const { file, diffLineIndex } = target;
|
|
1347
1490
|
comments = comments.filter(c => !(c.file === file && c.diffLineIndex === diffLineIndex));
|
|
1348
1491
|
|
|
1349
1492
|
// Auto-save to backend
|
|
@@ -1360,7 +1503,12 @@ function deleteCommentFromSidebar(file, diffLineIndex) {
|
|
|
1360
1503
|
}
|
|
1361
1504
|
|
|
1362
1505
|
// Jump to a specific comment
|
|
1363
|
-
function jumpToComment(
|
|
1506
|
+
function jumpToComment(commentIndex) {
|
|
1507
|
+
const target = comments[Number(commentIndex)];
|
|
1508
|
+
if (!target) return;
|
|
1509
|
+
|
|
1510
|
+
const { file, diffLineIndex } = target;
|
|
1511
|
+
|
|
1364
1512
|
// Find the file index
|
|
1365
1513
|
const fileIndex = currentFiles.findIndex(f => f.path === file);
|
|
1366
1514
|
if (fileIndex === -1) return;
|
|
@@ -1429,11 +1577,259 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1429
1577
|
}
|
|
1430
1578
|
});
|
|
1431
1579
|
|
|
1580
|
+
for (const id of ['recentList', 'browseList']) {
|
|
1581
|
+
const list = document.getElementById(id);
|
|
1582
|
+
list.addEventListener('click', onPickerActivate);
|
|
1583
|
+
list.addEventListener('keydown', onPickerActivate);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// Escape, and a click on the dimmed area around it, both mean "never mind"
|
|
1587
|
+
// -- but only when there is a review behind the picker to go back to.
|
|
1588
|
+
document.addEventListener('keydown', event => {
|
|
1589
|
+
if (event.key === 'Escape') closePicker();
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
document.getElementById('picker').addEventListener('click', event => {
|
|
1593
|
+
if (event.target.id === 'picker') closePicker();
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1432
1596
|
// `reviewer /path/to/repo` opens the page with the repository in the query
|
|
1433
1597
|
// string, so the review is on screen without anyone typing a path.
|
|
1434
1598
|
const requestedRepo = new URLSearchParams(window.location.search).get('repo');
|
|
1435
1599
|
if (requestedRepo) {
|
|
1436
1600
|
document.getElementById('repoPath').value = requestedRepo;
|
|
1601
|
+
// Hidden before the request goes out, so the picker does not flash up for
|
|
1602
|
+
// the fraction of a second it takes to load a repository that was named.
|
|
1603
|
+
hidePicker();
|
|
1437
1604
|
loadRepo();
|
|
1605
|
+
} else {
|
|
1606
|
+
showPicker();
|
|
1438
1607
|
}
|
|
1439
1608
|
});
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
/* Repository picker
|
|
1612
|
+
* ---------------------------------------------------------------------------
|
|
1613
|
+
* What the page shows before a repository is open. It used to show nothing but
|
|
1614
|
+
* an empty path box in the header, which asked you to already know the path you
|
|
1615
|
+
* were looking for -- and to type it exactly.
|
|
1616
|
+
*/
|
|
1617
|
+
|
|
1618
|
+
/** Where the browse list is pointed. Null until the first listing arrives. */
|
|
1619
|
+
let browseAt = null;
|
|
1620
|
+
let browseParent = null;
|
|
1621
|
+
let browseIsRepository = false;
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* Show the picker.
|
|
1625
|
+
*
|
|
1626
|
+
* With nothing open it is the page. With a review open it is a layer over the
|
|
1627
|
+
* review, because being asked which repository you want should not cost you
|
|
1628
|
+
* the one you are reading.
|
|
1629
|
+
*/
|
|
1630
|
+
function showPicker() {
|
|
1631
|
+
const reviewing = currentRepoId !== null;
|
|
1632
|
+
|
|
1633
|
+
document.getElementById('picker').classList.remove('hidden');
|
|
1634
|
+
document.getElementById('picker').classList.toggle('as-overlay', reviewing);
|
|
1635
|
+
document.getElementById('pickerClose').classList.toggle('hidden', !reviewing);
|
|
1636
|
+
|
|
1637
|
+
loadRecent();
|
|
1638
|
+
loadBrowse(browseAt);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function hidePicker() {
|
|
1642
|
+
document.getElementById('picker').classList.add('hidden');
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/** Dismiss the picker, which only means anything when there is something behind it. */
|
|
1646
|
+
function closePicker() {
|
|
1647
|
+
if (currentRepoId !== null) hidePicker();
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* Reveal the path box, for pasting a path or typing one you already know.
|
|
1652
|
+
*
|
|
1653
|
+
* Kept out of the way rather than removed: a navigator is the slow way round
|
|
1654
|
+
* when the path is already on your clipboard.
|
|
1655
|
+
*/
|
|
1656
|
+
function togglePathEntry() {
|
|
1657
|
+
const entry = document.getElementById('pathEntry');
|
|
1658
|
+
const toggle = document.getElementById('pathToggle');
|
|
1659
|
+
const showing = entry.classList.toggle('hidden') === false;
|
|
1660
|
+
|
|
1661
|
+
toggle.setAttribute('aria-expanded', String(showing));
|
|
1662
|
+
|
|
1663
|
+
if (showing) {
|
|
1664
|
+
const input = document.getElementById('repoPath');
|
|
1665
|
+
input.focus();
|
|
1666
|
+
input.select();
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* Name the repository on the button that opens the picker.
|
|
1672
|
+
*
|
|
1673
|
+
* @param {string|null} repoPath
|
|
1674
|
+
*/
|
|
1675
|
+
function setRepoButton(repoPath) {
|
|
1676
|
+
const name = repoPath ? repoPath.split(/[\\/]/).filter(Boolean).pop() : null;
|
|
1677
|
+
|
|
1678
|
+
document.getElementById('repoButtonLabel').textContent = name ?? 'Open a repository';
|
|
1679
|
+
document.getElementById('repoButton').title = repoPath ?? 'Choose a repository';
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* How long ago, in the least fussy words that are still true.
|
|
1684
|
+
*
|
|
1685
|
+
* @param {string} iso
|
|
1686
|
+
* @returns {string}
|
|
1687
|
+
*/
|
|
1688
|
+
function timeAgo(iso) {
|
|
1689
|
+
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
|
1690
|
+
|
|
1691
|
+
if (!Number.isFinite(seconds) || seconds < 60) return 'just now';
|
|
1692
|
+
|
|
1693
|
+
for (const [unit, size] of [['minute', 60], ['hour', 3600], ['day', 86400]]) {
|
|
1694
|
+
if (seconds < size * 60 || unit === 'day') {
|
|
1695
|
+
const count = Math.floor(seconds / size);
|
|
1696
|
+
if (unit === 'day' && count > 30) return new Date(iso).toLocaleDateString();
|
|
1697
|
+
return `${count} ${unit}${count === 1 ? '' : 's'} ago`;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/** Repositories opened before, newest first. */
|
|
1703
|
+
async function loadRecent() {
|
|
1704
|
+
const section = document.getElementById('recentSection');
|
|
1705
|
+
const list = document.getElementById('recentList');
|
|
1706
|
+
|
|
1707
|
+
let projects;
|
|
1708
|
+
try {
|
|
1709
|
+
projects = (await (await fetch(`${API_BASE}/recent`)).json()).projects ?? [];
|
|
1710
|
+
} catch {
|
|
1711
|
+
// Not being able to remember is not worth an error message on a page whose
|
|
1712
|
+
// other half works. The browser below still opens anything.
|
|
1713
|
+
section.classList.add('hidden');
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
if (projects.length === 0) {
|
|
1718
|
+
section.classList.add('hidden');
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
section.classList.remove('hidden');
|
|
1723
|
+
list.innerHTML = projects
|
|
1724
|
+
.map(project => {
|
|
1725
|
+
const meta = project.exists
|
|
1726
|
+
? [project.comments > 0 ? `${project.comments} comment${project.comments === 1 ? '' : 's'}` : '', timeAgo(project.openedAt)]
|
|
1727
|
+
.filter(Boolean)
|
|
1728
|
+
.join(' · ')
|
|
1729
|
+
: 'no longer there';
|
|
1730
|
+
|
|
1731
|
+
return `
|
|
1732
|
+
<div class="picker-row${project.exists ? '' : ' missing'}"
|
|
1733
|
+
${project.exists ? `role="button" tabindex="0" data-open="${escapeHtml(project.path)}"` : ''}
|
|
1734
|
+
title="${escapeHtml(project.path)}">
|
|
1735
|
+
<span class="picker-row-icon">${project.exists ? '▸' : '×'}</span>
|
|
1736
|
+
<span class="picker-row-name">${escapeHtml(project.name)}</span>
|
|
1737
|
+
<span class="picker-row-path">${escapeHtml(project.location ?? '')}</span>
|
|
1738
|
+
<span class="picker-row-meta">${escapeHtml(meta)}</span>
|
|
1739
|
+
</div>`;
|
|
1740
|
+
})
|
|
1741
|
+
.join('');
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* List one directory.
|
|
1746
|
+
*
|
|
1747
|
+
* @param {string|null} path directory to list; the home directory when null
|
|
1748
|
+
*/
|
|
1749
|
+
async function loadBrowse(path) {
|
|
1750
|
+
const list = document.getElementById('browseList');
|
|
1751
|
+
|
|
1752
|
+
let listing;
|
|
1753
|
+
try {
|
|
1754
|
+
const response = await fetch(`${API_BASE}/browse?path=${encodeURIComponent(path ?? '')}`);
|
|
1755
|
+
listing = await response.json();
|
|
1756
|
+
if (!response.ok) throw new Error(listing.error || 'Could not list that directory');
|
|
1757
|
+
} catch (error) {
|
|
1758
|
+
list.innerHTML = `<div class="picker-empty">${escapeHtml(error.message)}</div>`;
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
browseAt = listing.path;
|
|
1763
|
+
browseParent = listing.parent;
|
|
1764
|
+
browseIsRepository = listing.isRepository;
|
|
1765
|
+
|
|
1766
|
+
document.getElementById('browsePath').textContent = listing.display ?? listing.path;
|
|
1767
|
+
document.getElementById('browseUpBtn').disabled = listing.parent === null;
|
|
1768
|
+
document
|
|
1769
|
+
.getElementById('browseOpenBtn')
|
|
1770
|
+
.classList.toggle('hidden', !listing.isRepository);
|
|
1771
|
+
|
|
1772
|
+
if (listing.entries.length === 0) {
|
|
1773
|
+
list.innerHTML = '<div class="picker-empty">No folders in here.</div>';
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// A repository row goes both ways: the row walks into it, the button opens
|
|
1778
|
+
// it. Walking in matters for the repository that has another one inside it,
|
|
1779
|
+
// and one click has to be enough for the ordinary case.
|
|
1780
|
+
list.innerHTML = listing.entries
|
|
1781
|
+
.map(
|
|
1782
|
+
entry => `
|
|
1783
|
+
<div class="picker-row" role="button" tabindex="0" data-into="${escapeHtml(entry.path)}"
|
|
1784
|
+
title="${escapeHtml(entry.path)}">
|
|
1785
|
+
<span class="picker-row-icon">${entry.isRepository ? '◉' : '▸'}</span>
|
|
1786
|
+
<span class="picker-row-name">${escapeHtml(entry.name)}</span>
|
|
1787
|
+
${entry.isRepository ? '<span class="picker-badge">git</span>' : ''}
|
|
1788
|
+
<span class="picker-row-path"></span>
|
|
1789
|
+
${entry.isRepository ? `<button class="picker-open" data-open="${escapeHtml(entry.path)}">Open</button>` : ''}
|
|
1790
|
+
</div>`
|
|
1791
|
+
)
|
|
1792
|
+
.join('');
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
function browseUp() {
|
|
1796
|
+
if (browseParent) loadBrowse(browseParent);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function openBrowsed() {
|
|
1800
|
+
if (browseIsRepository) openProject(browseAt);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Load a repository the way typing its path and pressing the button would.
|
|
1805
|
+
*
|
|
1806
|
+
* @param {string} path
|
|
1807
|
+
*/
|
|
1808
|
+
function openProject(path) {
|
|
1809
|
+
document.getElementById('repoPath').value = path;
|
|
1810
|
+
loadRepo();
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* One handler for both lists: open what carries `data-open`, walk into what
|
|
1815
|
+
* carries `data-into`. Delegation rather than an `onclick` per row, because a
|
|
1816
|
+
* path is not safe to interpolate into an attribute that is then evaluated.
|
|
1817
|
+
*
|
|
1818
|
+
* @param {Event} event
|
|
1819
|
+
*/
|
|
1820
|
+
function onPickerActivate(event) {
|
|
1821
|
+
if (event.type === 'keydown') {
|
|
1822
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
1823
|
+
event.preventDefault();
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const open = event.target.closest('[data-open]');
|
|
1827
|
+
if (open) {
|
|
1828
|
+
event.stopPropagation();
|
|
1829
|
+
openProject(open.dataset.open);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
const into = event.target.closest('[data-into]');
|
|
1834
|
+
if (into) loadBrowse(into.dataset.into);
|
|
1835
|
+
}
|