git-reviewer 2.2.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/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
- const displayText = path ? `${path}/<span class="filename">${filename}</span>` : `<span class="filename">${filename}</span>`;
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
- return `<div class="file-item" onclick="loadFile('${file}', ${index})" title="${escapeHtml(file)}">
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();
@@ -342,8 +395,13 @@ function displayCode(filePath, diffLines, binary) {
342
395
  const clickableLineNum = diffLine.newLine || diffLine.oldLine;
343
396
 
344
397
  // Make both old and new line numbers clickable
345
- const oldLineClick = diffLine.type === 'delete' ? `onclick="toggleCommentInput(${diffLineIndex})"` : '';
346
- const newLineClick = diffLine.type !== 'delete' ? `onclick="toggleCommentInput(${diffLineIndex})"` : '';
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)})"` : '';
347
405
 
348
406
  html += `
349
407
  <div class="${lineClass}" data-diff-index="${diffLineIndex}">
@@ -357,7 +415,6 @@ function displayCode(filePath, diffLines, binary) {
357
415
  `;
358
416
 
359
417
  if (hasComment) {
360
- const selectedTextAttr = hasComment.selectedText ? `'${escapeHtml(hasComment.selectedText).replace(/'/g, "\\'")}'` : 'null';
361
418
  const matchTypeClass = hasComment.matchType === 'fuzzy' ? 'comment-box-fuzzy' : '';
362
419
  const fuzzyWarning = hasComment.matchType === 'fuzzy' ? `
363
420
  <div class="comment-fuzzy-warning">
@@ -388,9 +445,9 @@ function displayCode(filePath, diffLines, binary) {
388
445
  <span class="comment-text">${escapeHtml(hasComment.text)}</span>
389
446
  ${followUpsHtml}
390
447
  <div class="comment-actions">
391
- <button class="comment-reply" onclick="addFollowUp(${diffLineIndex})">Reply</button>
392
- <button class="comment-edit" onclick="editComment(${diffLineIndex}, ${selectedTextAttr})">Edit</button>
393
- <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>
394
451
  </div>
395
452
  </div>
396
453
  `;
@@ -399,11 +456,10 @@ function displayCode(filePath, diffLines, binary) {
399
456
 
400
457
  // Show unmatched comments at the end
401
458
  const unmatchedComments = fileComments.filter(c => c.matchType === 'unmatched');
459
+ unmatchedShown = unmatchedComments;
402
460
  if (unmatchedComments.length > 0) {
403
461
  html += '<div class="unmatched-comments-section">';
404
- unmatchedComments.forEach(comment => {
405
- const commentId = `unmatched-${Math.random().toString(36).substr(2, 9)}`;
406
-
462
+ unmatchedComments.forEach((comment, unmatchedIndex) => {
407
463
  // Generate follow-ups for unmatched comments
408
464
  let unmatchedFollowUpsHtml = '';
409
465
  if (comment.followUps && comment.followUps.length > 0) {
@@ -420,9 +476,16 @@ function displayCode(filePath, diffLines, binary) {
420
476
  unmatchedFollowUpsHtml += '</div>';
421
477
  }
422
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.
423
486
  html += `
424
- <div class="unmatched-comment-item collapsed" id="${commentId}">
425
- <div class="unmatched-comment-header" onclick="toggleUnmatchedComment('${commentId}')">
487
+ <div class="unmatched-comment-item collapsed">
488
+ <div class="unmatched-comment-header" onclick="toggleUnmatchedComment(${Number(unmatchedIndex)})">
426
489
  <span class="unmatched-comment-icon">⚠️</span>
427
490
  <span class="unmatched-comment-title">Comment not found (Line ${comment.line} changed)</span>
428
491
  <span class="unmatched-comment-toggle">▶</span>
@@ -436,8 +499,8 @@ function displayCode(filePath, diffLines, binary) {
436
499
  ${comment.selectedText ? `<div class="comment-item-selected">${escapeHtml(comment.selectedText)}</div>` : ''}
437
500
  ${unmatchedFollowUpsHtml}
438
501
  <div class="comment-actions">
439
- <button class="comment-reply" onclick="addFollowUpToUnmatched('${comment.file.replace(/'/g, "\\'")}', ${comment.line}, '${comment.text.replace(/'/g, "\\'")}')">Reply</button>
440
- <button class="comment-delete" onclick="deleteUnmatchedComment('${comment.file.replace(/'/g, "\\'")}', ${comment.line}, '${comment.text.replace(/'/g, "\\'")}')">Delete</button>
502
+ <button class="comment-reply" onclick="addFollowUpToUnmatched(${Number(unmatchedIndex)})">Reply</button>
503
+ <button class="comment-delete" onclick="deleteUnmatchedComment(${Number(unmatchedIndex)})">Delete</button>
441
504
  </div>
442
505
  </div>
443
506
  </div>
@@ -456,9 +519,29 @@ function displayCode(filePath, diffLines, binary) {
456
519
  codeViewer.addEventListener('mouseup', handleTextSelection);
457
520
  }
458
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
+
459
542
  // Toggle unmatched comment expansion
460
- function toggleUnmatchedComment(commentId) {
461
- const element = document.getElementById(commentId);
543
+ function toggleUnmatchedComment(index) {
544
+ const element = unmatchedRow(index);
462
545
  if (!element) return;
463
546
 
464
547
  element.classList.toggle('collapsed');
@@ -469,7 +552,11 @@ function toggleUnmatchedComment(commentId) {
469
552
  }
470
553
 
471
554
  // Delete unmatched comment
472
- function deleteUnmatchedComment(file, line, text) {
555
+ function deleteUnmatchedComment(index) {
556
+ const target = unmatchedShown[Number(index)];
557
+ if (!target) return;
558
+
559
+ const { file, line, text } = target;
473
560
  comments = comments.filter(c => !(c.file === file && c.line === line && c.text === text));
474
561
 
475
562
  // Auto-save to backend
@@ -506,7 +593,7 @@ function addFollowUp(diffLineIndex) {
506
593
  inputBox.innerHTML = `
507
594
  <textarea placeholder="Enter your follow-up (Cmd/Ctrl+Enter to save)..." id="followupInput"></textarea>
508
595
  <div class="actions">
509
- <button onclick="saveFollowUp(${diffLineIndex})">Add Follow-up</button>
596
+ <button onclick="saveFollowUp(${Number(diffLineIndex)})">Add Follow-up</button>
510
597
  <button class="cancel-btn" onclick="this.closest('.followup-input-box').remove()">Cancel</button>
511
598
  </div>
512
599
  `;
@@ -577,25 +664,17 @@ function saveFollowUp(diffLineIndex) {
577
664
  }
578
665
 
579
666
  // Add follow-up to an unmatched comment
580
- function addFollowUpToUnmatched(file, line, text) {
667
+ function addFollowUpToUnmatched(index) {
581
668
  // Remove any existing follow-up input
582
669
  const existingInput = document.querySelector('.followup-input-box');
583
670
  if (existingInput) {
584
671
  existingInput.remove();
585
672
  }
586
673
 
587
- // Find the unmatched comment element
588
- const unmatchedItems = document.querySelectorAll('.unmatched-comment-item');
589
- let targetItem = null;
590
-
591
- unmatchedItems.forEach(item => {
592
- const itemText = item.querySelector('.comment-text')?.textContent;
593
- const itemLine = item.querySelector('.unmatched-comment-title')?.textContent;
594
- if (itemText === text && itemLine?.includes(`Line ${line}`)) {
595
- targetItem = item;
596
- }
597
- });
598
-
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);
599
678
  if (!targetItem) return;
600
679
 
601
680
  const commentBody = targetItem.querySelector('.unmatched-comment-body');
@@ -605,13 +684,15 @@ function addFollowUpToUnmatched(file, line, text) {
605
684
  const inputBox = document.createElement('div');
606
685
  inputBox.className = 'followup-input-box';
607
686
 
608
- // Generate unique ID for this specific input
609
- const inputId = `followupInput-${Math.random().toString(36).substr(2, 9)}`;
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)}`;
610
691
 
611
692
  inputBox.innerHTML = `
612
693
  <textarea placeholder="Enter your follow-up (Cmd/Ctrl+Enter to save)..." id="${inputId}"></textarea>
613
694
  <div class="actions">
614
- <button onclick="saveFollowUpToUnmatched('${file.replace(/'/g, "\\'")}', ${line}, '${text.replace(/'/g, "\\'")}', '${inputId}')">Add Follow-up</button>
695
+ <button onclick="saveFollowUpToUnmatched(${Number(index)})">Add Follow-up</button>
615
696
  <button class="cancel-btn" onclick="this.closest('.followup-input-box').remove()">Cancel</button>
616
697
  </div>
617
698
  `;
@@ -638,7 +719,7 @@ function addFollowUpToUnmatched(file, line, text) {
638
719
  textarea.addEventListener('keydown', (e) => {
639
720
  if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
640
721
  e.preventDefault();
641
- saveFollowUpToUnmatched(file, line, text, inputId);
722
+ saveFollowUpToUnmatched(index);
642
723
  }
643
724
  if (e.key === 'Escape') {
644
725
  e.preventDefault();
@@ -648,8 +729,8 @@ function addFollowUpToUnmatched(file, line, text) {
648
729
  }
649
730
 
650
731
  // Save follow-up to an unmatched comment
651
- function saveFollowUpToUnmatched(file, line, text, inputId) {
652
- const input = document.getElementById(inputId);
732
+ function saveFollowUpToUnmatched(index) {
733
+ const input = document.getElementById(`followupInput-${Number(index)}`);
653
734
  const followUpText = input?.value.trim();
654
735
 
655
736
  if (!followUpText) {
@@ -657,7 +738,10 @@ function saveFollowUpToUnmatched(file, line, text, inputId) {
657
738
  return;
658
739
  }
659
740
 
660
- const comment = comments.find(c => c.file === file && c.line === line && c.text === text);
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)];
661
745
  if (!comment) return;
662
746
 
663
747
  // Initialize followUps if it doesn't exist
@@ -677,9 +761,9 @@ function saveFollowUpToUnmatched(file, line, text, inputId) {
677
761
  updateCommentsSidebar();
678
762
 
679
763
  // Reload the current file to show the follow-up
680
- if (currentFile === file) {
681
- const fileIndex = currentFiles.findIndex(f => f.path === file);
682
- loadFile(file, fileIndex);
764
+ if (currentFile === comment.file) {
765
+ const fileIndex = currentFiles.findIndex(f => f.path === comment.file);
766
+ loadFile(comment.file, fileIndex);
683
767
  }
684
768
  }
685
769
 
@@ -763,6 +847,25 @@ function handleTextSelection(e) {
763
847
  toggleCommentInputWithSelection(diffLineIndex, selectedText);
764
848
  }
765
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
+
766
869
  // Toggle comment input
767
870
  function toggleCommentInput(diffLineIndex) {
768
871
  toggleCommentInputWithSelection(diffLineIndex, null);
@@ -796,7 +899,7 @@ function toggleCommentInputWithSelection(diffLineIndex, selectedText = null) {
796
899
  ${selectedTextHtml}
797
900
  <textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput"></textarea>
798
901
  <div class="actions">
799
- <button onclick="saveComment(${diffLineIndex}, ${selectedText ? `\`${escapeHtml(selectedText).replace(/`/g, '\\`')}\`` : 'null'})">Save Comment</button>
902
+ <button${selectedTextData(selectedText)} onclick="saveComment(${Number(diffLineIndex)}, this.dataset.selectedText ?? null)">Save Comment</button>
800
903
  <button class="cancel-btn" onclick="this.closest('.comment-input-box').remove()">Cancel</button>
801
904
  </div>
802
905
  `;
@@ -869,10 +972,17 @@ function saveComment(diffLineIndex, selectedText = null) {
869
972
  }
870
973
 
871
974
  // Edit comment
872
- function editComment(diffLineIndex, selectedText = null) {
975
+ function editComment(diffLineIndex) {
873
976
  const comment = comments.find(c => c.file === currentFile && c.diffLineIndex === diffLineIndex);
874
977
  if (!comment) return;
875
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
+
876
986
  // Remove the comment from the list temporarily
877
987
  comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
878
988
 
@@ -897,7 +1007,7 @@ function editComment(diffLineIndex, selectedText = null) {
897
1007
  ${selectedTextHtml}
898
1008
  <textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput">${escapeHtml(comment.text)}</textarea>
899
1009
  <div class="actions">
900
- <button onclick="saveComment(${diffLineIndex}, ${selectedText ? `\`${escapeHtml(selectedText).replace(/`/g, '\\`')}\`` : 'null'})">Save Comment</button>
1010
+ <button${selectedTextData(selectedText)} onclick="saveComment(${Number(diffLineIndex)}, this.dataset.selectedText ?? null)">Save Comment</button>
901
1011
  <button class="cancel-btn" onclick="this.closest('.comment-input-box').remove()">Cancel</button>
902
1012
  </div>
903
1013
  `;
@@ -998,8 +1108,8 @@ function showReviewModal(reviewContent, filename) {
998
1108
  <pre class="review-text">${escapeHtml(reviewContent)}</pre>
999
1109
  </div>
1000
1110
  <div class="review-modal-footer">
1001
- <button onclick="downloadReview('${filename}', this.closest('.review-modal').querySelector('.review-text').textContent)">
1002
- 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)}
1003
1113
  </button>
1004
1114
  <button onclick="copyReviewToClipboard(this.closest('.review-modal').querySelector('.review-text').textContent)">
1005
1115
  Copy to Clipboard
@@ -1059,6 +1169,7 @@ function resetApp() {
1059
1169
  comments = [];
1060
1170
 
1061
1171
  document.getElementById('repoPath').value = '';
1172
+ setRepoButton(null);
1062
1173
  document.getElementById('sidebar').classList.add('hidden');
1063
1174
  document.getElementById('resizeHandle').classList.add('hidden');
1064
1175
  document.getElementById('codeSection').classList.add('hidden');
@@ -1066,6 +1177,8 @@ function resetApp() {
1066
1177
  document.getElementById('submitReviewBtn').classList.add('hidden');
1067
1178
  document.getElementById('status').textContent = '';
1068
1179
  document.getElementById('status').className = 'status';
1180
+
1181
+ showPicker();
1069
1182
  }
1070
1183
 
1071
1184
  // Show status message
@@ -1196,20 +1309,19 @@ function displayFullContext(filePath, lines) {
1196
1309
  ${hasComment ? '<span class="comment-indicator"></span>' : ''}
1197
1310
  <div class="line-numbers">
1198
1311
  <span class="old-line-number">${diffLine.oldLine || ''}</span>
1199
- <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>
1200
1313
  </div>
1201
1314
  <div class="line-content">${escapeHtml(diffLine.content) || ' '}</div>
1202
1315
  </div>
1203
1316
  `;
1204
1317
 
1205
1318
  if (hasComment) {
1206
- const selectedTextAttr = hasComment.selectedText ? `'${escapeHtml(hasComment.selectedText).replace(/'/g, "\\'")}'` : 'null';
1207
1319
  html += `
1208
1320
  <div class="comment-box">
1209
1321
  <span class="comment-text">${escapeHtml(hasComment.text)}</span>
1210
1322
  <div class="comment-actions">
1211
- <button class="comment-edit" onclick="editComment(${diffLineIndex}, ${selectedTextAttr})">Edit</button>
1212
- <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>
1213
1325
  </div>
1214
1326
  </div>
1215
1327
  `;
@@ -1229,9 +1341,18 @@ function displayFullContext(filePath, lines) {
1229
1341
 
1230
1342
  // Escape HTML
1231
1343
  function escapeHtml(text) {
1232
- const div = document.createElement('div');
1233
- div.textContent = text;
1234
- return div.innerHTML;
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, '&amp;')
1352
+ .replace(/</g, '&lt;')
1353
+ .replace(/>/g, '&gt;')
1354
+ .replace(/"/g, '&quot;')
1355
+ .replace(/'/g, '&#39;');
1235
1356
  }
1236
1357
 
1237
1358
  // Sidebar resize functionality
@@ -1337,11 +1458,16 @@ function updateCommentsSidebar() {
1337
1458
  ? `<div class="comment-item-selected">${escapeHtml(comment.selectedText)}</div>`
1338
1459
  : '';
1339
1460
 
1340
- const fileEscaped = file.replace(/'/g, "\\'");
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);
1341
1467
  html += `
1342
1468
  <div class="comment-item">
1343
- <button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar('${fileEscaped}', ${comment.diffLineIndex})" title="Delete comment">×</button>
1344
- <div class="comment-item-content" onclick="jumpToComment('${fileEscaped}', ${comment.diffLineIndex})">
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)})">
1345
1471
  <div class="comment-item-file">${escapeHtml(file)}</div>
1346
1472
  <div class="comment-item-line">Line ${comment.line}</div>
1347
1473
  <div class="comment-item-text">${escapeHtml(comment.text)}</div>
@@ -1356,7 +1482,11 @@ function updateCommentsSidebar() {
1356
1482
  }
1357
1483
 
1358
1484
  // Delete comment from sidebar
1359
- function deleteCommentFromSidebar(file, diffLineIndex) {
1485
+ function deleteCommentFromSidebar(commentIndex) {
1486
+ const target = comments[Number(commentIndex)];
1487
+ if (!target) return;
1488
+
1489
+ const { file, diffLineIndex } = target;
1360
1490
  comments = comments.filter(c => !(c.file === file && c.diffLineIndex === diffLineIndex));
1361
1491
 
1362
1492
  // Auto-save to backend
@@ -1373,7 +1503,12 @@ function deleteCommentFromSidebar(file, diffLineIndex) {
1373
1503
  }
1374
1504
 
1375
1505
  // Jump to a specific comment
1376
- function jumpToComment(file, diffLineIndex) {
1506
+ function jumpToComment(commentIndex) {
1507
+ const target = comments[Number(commentIndex)];
1508
+ if (!target) return;
1509
+
1510
+ const { file, diffLineIndex } = target;
1511
+
1377
1512
  // Find the file index
1378
1513
  const fileIndex = currentFiles.findIndex(f => f.path === file);
1379
1514
  if (fileIndex === -1) return;
@@ -1442,11 +1577,259 @@ document.addEventListener('DOMContentLoaded', () => {
1442
1577
  }
1443
1578
  });
1444
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
+
1445
1596
  // `reviewer /path/to/repo` opens the page with the repository in the query
1446
1597
  // string, so the review is on screen without anyone typing a path.
1447
1598
  const requestedRepo = new URLSearchParams(window.location.search).get('repo');
1448
1599
  if (requestedRepo) {
1449
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();
1450
1604
  loadRepo();
1605
+ } else {
1606
+ showPicker();
1451
1607
  }
1452
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
+ }