git-reviewer 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -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,13 +174,16 @@ 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
172
181
  const savedComments = await loadCommentsFromBackend();
173
182
  if (savedComments.length > 0) {
174
- // Store saved comments in a global variable for matching later
175
- window.savedComments = savedComments;
183
+ // These went into `window.savedComments` as well, as a second copy for
184
+ // the matcher to read. Nothing reads it now, and leaving a stale copy of
185
+ // the review lying around is how the resurrection happened in the first
186
+ // place -- so there is one list, and it is this one.
176
187
 
177
188
  // Add all saved comments to the comments array immediately
178
189
  comments = savedComments.map(c => ({
@@ -187,8 +198,23 @@ async function loadRepo() {
187
198
  showStatus(`${data.message} - Loaded ${savedComments.length} saved comment(s)`, 'success');
188
199
  }
189
200
 
201
+ // Open something. A loaded repository used to sit behind an empty pane
202
+ // until a file was clicked, which reads as a failure rather than as a
203
+ // result (#47). Done here rather than in `displayFiles` because the saved
204
+ // comments have to be in hand first, or the diff renders without them.
205
+ //
206
+ // The file already open wins if it is still in the list, so reloading a
207
+ // repository does not move you off what you were reading.
208
+ if (currentFiles.length > 0) {
209
+ const previous = currentFiles.findIndex(file => file.path === currentFile);
210
+ const opening = previous === -1 ? 0 : previous;
211
+
212
+ loadFile(currentFiles[opening].path, opening);
213
+ }
214
+
190
215
  } catch (error) {
191
216
  showStatus(`Error: ${error.message}`, 'error');
217
+ showPicker();
192
218
  } finally {
193
219
  document.getElementById('loadBtn').disabled = false;
194
220
  }
@@ -209,12 +235,25 @@ function displayFiles(files) {
209
235
  const filename = parts[parts.length - 1];
210
236
  const path = parts.slice(0, -1).join('/');
211
237
 
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>`;
238
+ // Because of RTL, we need to reverse the order in HTML. The label is
239
+ // markup -- the name is split so the last segment can be emphasised -- so
240
+ // both halves are escaped going in. They were not, and a name is chosen by
241
+ // whatever repository is open: markup in one was drawn as markup, without
242
+ // anyone clicking anything.
243
+ const displayText = path
244
+ ? `${escapeHtml(path)}/<span class="filename">${escapeHtml(filename)}</span>`
245
+ : `<span class="filename">${escapeHtml(filename)}</span>`;
214
246
  const statusClass = status === 'A' ? 'file-status-added' : 'file-status-modified';
215
247
  const statusBadge = `<span class="file-status ${statusClass}">${status}</span>`;
216
248
 
217
- return `<div class="file-item" onclick="loadFile('${file}', ${index})" title="${escapeHtml(file)}">
249
+ // The row used to carry the file name itself, inside a string literal
250
+ // inside the attribute the browser evaluates as JavaScript. A name with a
251
+ // quote in it ended that literal and the rest of the name ran on click
252
+ // (#46). Escaping cannot close it: entities in an attribute are decoded
253
+ // before the handler is parsed, so an escaped quote is a quote again by
254
+ // the time it matters. The row carries its position instead, and the name
255
+ // is read back out of `currentFiles`, where it stays data.
256
+ return `<div class="file-item" onclick="openFileAt(${Number(index)})" title="${escapeHtml(file)}">
218
257
  ${statusBadge}<span class="file-path-text">${displayText}</span>
219
258
  </div>`;
220
259
  }).join('');
@@ -228,6 +267,22 @@ function displayFiles(files) {
228
267
  updateCommentsSidebar();
229
268
  }
230
269
 
270
+ /**
271
+ * Open the file at a position in the list. The handler the file rows carry.
272
+ *
273
+ * `loadFile` still takes a path, because every other caller already has one in
274
+ * hand and none of them travels through an attribute. This one does, so it
275
+ * takes the index and looks the path up here. See the note in `displayFiles`.
276
+ *
277
+ * @param {number} index position in `currentFiles`
278
+ */
279
+ function openFileAt(index) {
280
+ const file = currentFiles[Number(index)];
281
+ if (!file) return;
282
+
283
+ loadFile(file.path, Number(index));
284
+ }
285
+
231
286
  // Load file content
232
287
  async function loadFile(filePath, index) {
233
288
  if (!currentRepoId) return Promise.resolve();
@@ -255,9 +310,20 @@ async function loadFile(filePath, index) {
255
310
  throw new Error(data.error || 'Failed to load file');
256
311
  }
257
312
 
258
- // Match saved comments to current diff if available
259
- if (window.savedComments && window.savedComments.length > 0) {
260
- const matchedComments = matchCommentsToDiff(window.savedComments, filePath, data.diffLines);
313
+ // Give this file's comments their position in the diff just rendered.
314
+ //
315
+ // The source here used to be `window.savedComments`, a snapshot taken when
316
+ // the repository was opened and never updated again. Everything else works
317
+ // from `comments`, the live list -- so a comment you deleted was still in
318
+ // the snapshot, was not found in the live list, and the branch below added
319
+ // it straight back. It vanished from disk and reappeared on screen, and
320
+ // the next save wrote the resurrected copy back over the deletion. Editing
321
+ // one did the same thing by a different route: the text no longer matched,
322
+ // so the pre-edit copy was added alongside the edited one (#52).
323
+ //
324
+ // `comments` is what exists. A comment that is not in it does not exist.
325
+ if (comments.length > 0) {
326
+ const matchedComments = matchCommentsToDiff(comments, filePath, data.diffLines);
261
327
 
262
328
  // Update existing comments in the array with matched diff indices
263
329
  matchedComments.forEach(mc => {
@@ -277,6 +343,10 @@ async function loadFile(filePath, index) {
277
343
  existing.followUps = mc.followUps;
278
344
  }
279
345
  } else {
346
+ // Unreachable now that the matcher is fed the live list: everything
347
+ // it returns came from there. Kept as a guard rather than removed,
348
+ // because silently dropping a comment is the worse failure of the
349
+ // two, and this is the line that would do it.
280
350
  // Add new comment if it doesn't exist
281
351
  comments.push({
282
352
  file: mc.file,
@@ -342,8 +412,13 @@ function displayCode(filePath, diffLines, binary) {
342
412
  const clickableLineNum = diffLine.newLine || diffLine.oldLine;
343
413
 
344
414
  // 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})"` : '';
415
+ // `Number(...)` at every interpolation into a handler, here and below. The
416
+ // rule the page now keeps is that nothing but a number goes into an
417
+ // attribute the browser evaluates as code, and coercing it where it is
418
+ // written makes that true of the markup rather than true of whatever the
419
+ // caller happened to pass. See `displayFiles` and #46.
420
+ const oldLineClick = diffLine.type === 'delete' ? `onclick="toggleCommentInput(${Number(diffLineIndex)})"` : '';
421
+ const newLineClick = diffLine.type !== 'delete' ? `onclick="toggleCommentInput(${Number(diffLineIndex)})"` : '';
347
422
 
348
423
  html += `
349
424
  <div class="${lineClass}" data-diff-index="${diffLineIndex}">
@@ -357,7 +432,6 @@ function displayCode(filePath, diffLines, binary) {
357
432
  `;
358
433
 
359
434
  if (hasComment) {
360
- const selectedTextAttr = hasComment.selectedText ? `'${escapeHtml(hasComment.selectedText).replace(/'/g, "\\'")}'` : 'null';
361
435
  const matchTypeClass = hasComment.matchType === 'fuzzy' ? 'comment-box-fuzzy' : '';
362
436
  const fuzzyWarning = hasComment.matchType === 'fuzzy' ? `
363
437
  <div class="comment-fuzzy-warning">
@@ -388,9 +462,9 @@ function displayCode(filePath, diffLines, binary) {
388
462
  <span class="comment-text">${escapeHtml(hasComment.text)}</span>
389
463
  ${followUpsHtml}
390
464
  <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>
465
+ <button class="comment-reply" onclick="addFollowUp(${Number(diffLineIndex)})">Reply</button>
466
+ <button class="comment-edit" onclick="editComment(${Number(diffLineIndex)})">Edit</button>
467
+ <button class="comment-delete" onclick="deleteComment(${Number(diffLineIndex)})">Delete</button>
394
468
  </div>
395
469
  </div>
396
470
  `;
@@ -399,11 +473,10 @@ function displayCode(filePath, diffLines, binary) {
399
473
 
400
474
  // Show unmatched comments at the end
401
475
  const unmatchedComments = fileComments.filter(c => c.matchType === 'unmatched');
476
+ unmatchedShown = unmatchedComments;
402
477
  if (unmatchedComments.length > 0) {
403
478
  html += '<div class="unmatched-comments-section">';
404
- unmatchedComments.forEach(comment => {
405
- const commentId = `unmatched-${Math.random().toString(36).substr(2, 9)}`;
406
-
479
+ unmatchedComments.forEach((comment, unmatchedIndex) => {
407
480
  // Generate follow-ups for unmatched comments
408
481
  let unmatchedFollowUpsHtml = '';
409
482
  if (comment.followUps && comment.followUps.length > 0) {
@@ -420,9 +493,16 @@ function displayCode(filePath, diffLines, binary) {
420
493
  unmatchedFollowUpsHtml += '</div>';
421
494
  }
422
495
 
496
+ // Every control in here used to carry the comment's file name and text,
497
+ // quoted into an attribute that is evaluated as JavaScript -- with `'`
498
+ // replaced by `\'` and nothing else, which a name ending in a backslash
499
+ // walks straight out of. They carry the row's position instead, and the
500
+ // three fields are read back from `unmatchedShown`, the array this was
501
+ // rendered from. The row's random id went with it: the position is the
502
+ // identifier now.
423
503
  html += `
424
- <div class="unmatched-comment-item collapsed" id="${commentId}">
425
- <div class="unmatched-comment-header" onclick="toggleUnmatchedComment('${commentId}')">
504
+ <div class="unmatched-comment-item collapsed">
505
+ <div class="unmatched-comment-header" onclick="toggleUnmatchedComment(${Number(unmatchedIndex)})">
426
506
  <span class="unmatched-comment-icon">⚠️</span>
427
507
  <span class="unmatched-comment-title">Comment not found (Line ${comment.line} changed)</span>
428
508
  <span class="unmatched-comment-toggle">▶</span>
@@ -436,8 +516,8 @@ function displayCode(filePath, diffLines, binary) {
436
516
  ${comment.selectedText ? `<div class="comment-item-selected">${escapeHtml(comment.selectedText)}</div>` : ''}
437
517
  ${unmatchedFollowUpsHtml}
438
518
  <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>
519
+ <button class="comment-reply" onclick="addFollowUpToUnmatched(${Number(unmatchedIndex)})">Reply</button>
520
+ <button class="comment-delete" onclick="deleteUnmatchedComment(${Number(unmatchedIndex)})">Delete</button>
441
521
  </div>
442
522
  </div>
443
523
  </div>
@@ -456,9 +536,29 @@ function displayCode(filePath, diffLines, binary) {
456
536
  codeViewer.addEventListener('mouseup', handleTextSelection);
457
537
  }
458
538
 
539
+ /**
540
+ * The unmatched comments the code pane is showing, in the order they were
541
+ * drawn. The rows below are identified by their position in here, because the
542
+ * fields that used to identify them -- a file name and the comment's own text
543
+ * -- cannot be written into a handler attribute safely. See #46.
544
+ *
545
+ * @type {object[]}
546
+ */
547
+ let unmatchedShown = [];
548
+
549
+ /**
550
+ * The row an unmatched comment was drawn into.
551
+ *
552
+ * @param {number} index position in `unmatchedShown`
553
+ * @returns {Element|null}
554
+ */
555
+ function unmatchedRow(index) {
556
+ return document.querySelectorAll('.unmatched-comment-item')[Number(index)] ?? null;
557
+ }
558
+
459
559
  // Toggle unmatched comment expansion
460
- function toggleUnmatchedComment(commentId) {
461
- const element = document.getElementById(commentId);
560
+ function toggleUnmatchedComment(index) {
561
+ const element = unmatchedRow(index);
462
562
  if (!element) return;
463
563
 
464
564
  element.classList.toggle('collapsed');
@@ -469,7 +569,11 @@ function toggleUnmatchedComment(commentId) {
469
569
  }
470
570
 
471
571
  // Delete unmatched comment
472
- function deleteUnmatchedComment(file, line, text) {
572
+ function deleteUnmatchedComment(index) {
573
+ const target = unmatchedShown[Number(index)];
574
+ if (!target) return;
575
+
576
+ const { file, line, text } = target;
473
577
  comments = comments.filter(c => !(c.file === file && c.line === line && c.text === text));
474
578
 
475
579
  // Auto-save to backend
@@ -506,7 +610,7 @@ function addFollowUp(diffLineIndex) {
506
610
  inputBox.innerHTML = `
507
611
  <textarea placeholder="Enter your follow-up (Cmd/Ctrl+Enter to save)..." id="followupInput"></textarea>
508
612
  <div class="actions">
509
- <button onclick="saveFollowUp(${diffLineIndex})">Add Follow-up</button>
613
+ <button onclick="saveFollowUp(${Number(diffLineIndex)})">Add Follow-up</button>
510
614
  <button class="cancel-btn" onclick="this.closest('.followup-input-box').remove()">Cancel</button>
511
615
  </div>
512
616
  `;
@@ -577,25 +681,17 @@ function saveFollowUp(diffLineIndex) {
577
681
  }
578
682
 
579
683
  // Add follow-up to an unmatched comment
580
- function addFollowUpToUnmatched(file, line, text) {
684
+ function addFollowUpToUnmatched(index) {
581
685
  // Remove any existing follow-up input
582
686
  const existingInput = document.querySelector('.followup-input-box');
583
687
  if (existingInput) {
584
688
  existingInput.remove();
585
689
  }
586
690
 
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
-
691
+ // The row this belongs to, by position. It used to be found by comparing the
692
+ // rendered text against the comment's own text, which picked the wrong row
693
+ // when two unmatched comments on a file said the same thing on the same line.
694
+ const targetItem = unmatchedRow(index);
599
695
  if (!targetItem) return;
600
696
 
601
697
  const commentBody = targetItem.querySelector('.unmatched-comment-body');
@@ -605,13 +701,15 @@ function addFollowUpToUnmatched(file, line, text) {
605
701
  const inputBox = document.createElement('div');
606
702
  inputBox.className = 'followup-input-box';
607
703
 
608
- // Generate unique ID for this specific input
609
- const inputId = `followupInput-${Math.random().toString(36).substr(2, 9)}`;
704
+ // The input is named after the row, which is one number, so the button that
705
+ // saves it needs to carry nothing but that number. It used to carry the file
706
+ // name, the comment text and a random id, all quoted into the handler.
707
+ const inputId = `followupInput-${Number(index)}`;
610
708
 
611
709
  inputBox.innerHTML = `
612
710
  <textarea placeholder="Enter your follow-up (Cmd/Ctrl+Enter to save)..." id="${inputId}"></textarea>
613
711
  <div class="actions">
614
- <button onclick="saveFollowUpToUnmatched('${file.replace(/'/g, "\\'")}', ${line}, '${text.replace(/'/g, "\\'")}', '${inputId}')">Add Follow-up</button>
712
+ <button onclick="saveFollowUpToUnmatched(${Number(index)})">Add Follow-up</button>
615
713
  <button class="cancel-btn" onclick="this.closest('.followup-input-box').remove()">Cancel</button>
616
714
  </div>
617
715
  `;
@@ -638,7 +736,7 @@ function addFollowUpToUnmatched(file, line, text) {
638
736
  textarea.addEventListener('keydown', (e) => {
639
737
  if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
640
738
  e.preventDefault();
641
- saveFollowUpToUnmatched(file, line, text, inputId);
739
+ saveFollowUpToUnmatched(index);
642
740
  }
643
741
  if (e.key === 'Escape') {
644
742
  e.preventDefault();
@@ -648,8 +746,8 @@ function addFollowUpToUnmatched(file, line, text) {
648
746
  }
649
747
 
650
748
  // Save follow-up to an unmatched comment
651
- function saveFollowUpToUnmatched(file, line, text, inputId) {
652
- const input = document.getElementById(inputId);
749
+ function saveFollowUpToUnmatched(index) {
750
+ const input = document.getElementById(`followupInput-${Number(index)}`);
653
751
  const followUpText = input?.value.trim();
654
752
 
655
753
  if (!followUpText) {
@@ -657,7 +755,10 @@ function saveFollowUpToUnmatched(file, line, text, inputId) {
657
755
  return;
658
756
  }
659
757
 
660
- const comment = comments.find(c => c.file === file && c.line === line && c.text === text);
758
+ // The row's position, not the three fields that used to be quoted into the
759
+ // button. `unmatchedShown` holds the same objects `comments` does, so the
760
+ // follow-up is pushed onto the comment itself rather than onto a copy.
761
+ const comment = unmatchedShown[Number(index)];
661
762
  if (!comment) return;
662
763
 
663
764
  // Initialize followUps if it doesn't exist
@@ -677,9 +778,9 @@ function saveFollowUpToUnmatched(file, line, text, inputId) {
677
778
  updateCommentsSidebar();
678
779
 
679
780
  // 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);
781
+ if (currentFile === comment.file) {
782
+ const fileIndex = currentFiles.findIndex(f => f.path === comment.file);
783
+ loadFile(comment.file, fileIndex);
683
784
  }
684
785
  }
685
786
 
@@ -763,6 +864,25 @@ function handleTextSelection(e) {
763
864
  toggleCommentInputWithSelection(diffLineIndex, selectedText);
764
865
  }
765
866
 
867
+ /**
868
+ * The selected code, as an attribute on the button that saves it.
869
+ *
870
+ * The save button used to carry the selection inside a template literal inside
871
+ * its handler, which is a JavaScript context: a `${` in the selected code
872
+ * opened an interpolation and ran whatever followed it, and the file being
873
+ * reviewed is where that code comes from. The selection is not an index into
874
+ * anything -- it is a fragment of a live selection -- so it travels as data on
875
+ * the element and the handler reads it back at click time. A `data-` attribute
876
+ * is an attribute and nothing more; `escapeHtml` is enough for it, and the
877
+ * browser hands the original string back through `dataset`.
878
+ *
879
+ * @param {string|null} selectedText
880
+ * @returns {string} an attribute to splice into the tag, or nothing
881
+ */
882
+ function selectedTextData(selectedText) {
883
+ return selectedText ? ` data-selected-text="${escapeHtml(selectedText)}"` : '';
884
+ }
885
+
766
886
  // Toggle comment input
767
887
  function toggleCommentInput(diffLineIndex) {
768
888
  toggleCommentInputWithSelection(diffLineIndex, null);
@@ -796,7 +916,7 @@ function toggleCommentInputWithSelection(diffLineIndex, selectedText = null) {
796
916
  ${selectedTextHtml}
797
917
  <textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput"></textarea>
798
918
  <div class="actions">
799
- <button onclick="saveComment(${diffLineIndex}, ${selectedText ? `\`${escapeHtml(selectedText).replace(/`/g, '\\`')}\`` : 'null'})">Save Comment</button>
919
+ <button${selectedTextData(selectedText)} onclick="saveComment(${Number(diffLineIndex)}, this.dataset.selectedText ?? null)">Save Comment</button>
800
920
  <button class="cancel-btn" onclick="this.closest('.comment-input-box').remove()">Cancel</button>
801
921
  </div>
802
922
  `;
@@ -869,10 +989,17 @@ function saveComment(diffLineIndex, selectedText = null) {
869
989
  }
870
990
 
871
991
  // Edit comment
872
- function editComment(diffLineIndex, selectedText = null) {
992
+ function editComment(diffLineIndex) {
873
993
  const comment = comments.find(c => c.file === currentFile && c.diffLineIndex === diffLineIndex);
874
994
  if (!comment) return;
875
995
 
996
+ // The Edit button used to hand the selected code back in as an argument,
997
+ // quoted into its own handler -- and after `escapeHtml` learned to escape
998
+ // quotes, the hand-rolled backslash escaping beside it stopped matching
999
+ // anything at all, so a quote in the selected code ended the literal and the
1000
+ // rest ran. The comment is looked up here anyway; the selection comes off it.
1001
+ const selectedText = comment.selectedText ?? null;
1002
+
876
1003
  // Remove the comment from the list temporarily
877
1004
  comments = comments.filter(c => !(c.file === currentFile && c.diffLineIndex === diffLineIndex));
878
1005
 
@@ -897,7 +1024,7 @@ function editComment(diffLineIndex, selectedText = null) {
897
1024
  ${selectedTextHtml}
898
1025
  <textarea placeholder="Enter your comment (Cmd/Ctrl+Enter to save)..." id="commentInput">${escapeHtml(comment.text)}</textarea>
899
1026
  <div class="actions">
900
- <button onclick="saveComment(${diffLineIndex}, ${selectedText ? `\`${escapeHtml(selectedText).replace(/`/g, '\\`')}\`` : 'null'})">Save Comment</button>
1027
+ <button${selectedTextData(selectedText)} onclick="saveComment(${Number(diffLineIndex)}, this.dataset.selectedText ?? null)">Save Comment</button>
901
1028
  <button class="cancel-btn" onclick="this.closest('.comment-input-box').remove()">Cancel</button>
902
1029
  </div>
903
1030
  `;
@@ -998,8 +1125,8 @@ function showReviewModal(reviewContent, filename) {
998
1125
  <pre class="review-text">${escapeHtml(reviewContent)}</pre>
999
1126
  </div>
1000
1127
  <div class="review-modal-footer">
1001
- <button onclick="downloadReview('${filename}', this.closest('.review-modal').querySelector('.review-text').textContent)">
1002
- Download ${filename}
1128
+ <button data-filename="${escapeHtml(filename)}" onclick="downloadReview(this.dataset.filename, this.closest('.review-modal').querySelector('.review-text').textContent)">
1129
+ Download ${escapeHtml(filename)}
1003
1130
  </button>
1004
1131
  <button onclick="copyReviewToClipboard(this.closest('.review-modal').querySelector('.review-text').textContent)">
1005
1132
  Copy to Clipboard
@@ -1059,6 +1186,7 @@ function resetApp() {
1059
1186
  comments = [];
1060
1187
 
1061
1188
  document.getElementById('repoPath').value = '';
1189
+ setRepoButton(null);
1062
1190
  document.getElementById('sidebar').classList.add('hidden');
1063
1191
  document.getElementById('resizeHandle').classList.add('hidden');
1064
1192
  document.getElementById('codeSection').classList.add('hidden');
@@ -1066,6 +1194,8 @@ function resetApp() {
1066
1194
  document.getElementById('submitReviewBtn').classList.add('hidden');
1067
1195
  document.getElementById('status').textContent = '';
1068
1196
  document.getElementById('status').className = 'status';
1197
+
1198
+ showPicker();
1069
1199
  }
1070
1200
 
1071
1201
  // Show status message
@@ -1196,20 +1326,19 @@ function displayFullContext(filePath, lines) {
1196
1326
  ${hasComment ? '<span class="comment-indicator"></span>' : ''}
1197
1327
  <div class="line-numbers">
1198
1328
  <span class="old-line-number">${diffLine.oldLine || ''}</span>
1199
- <span class="new-line-number" ${diffLineIndex !== undefined ? `onclick="toggleCommentInput(${diffLineIndex})"` : ''}>${diffLine.newLine || ''}</span>
1329
+ <span class="new-line-number" ${diffLineIndex !== undefined ? `onclick="toggleCommentInput(${Number(diffLineIndex)})"` : ''}>${diffLine.newLine || ''}</span>
1200
1330
  </div>
1201
1331
  <div class="line-content">${escapeHtml(diffLine.content) || ' '}</div>
1202
1332
  </div>
1203
1333
  `;
1204
1334
 
1205
1335
  if (hasComment) {
1206
- const selectedTextAttr = hasComment.selectedText ? `'${escapeHtml(hasComment.selectedText).replace(/'/g, "\\'")}'` : 'null';
1207
1336
  html += `
1208
1337
  <div class="comment-box">
1209
1338
  <span class="comment-text">${escapeHtml(hasComment.text)}</span>
1210
1339
  <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>
1340
+ <button class="comment-edit" onclick="editComment(${Number(diffLineIndex)})">Edit</button>
1341
+ <button class="comment-delete" onclick="deleteComment(${Number(diffLineIndex)})">Delete</button>
1213
1342
  </div>
1214
1343
  </div>
1215
1344
  `;
@@ -1229,9 +1358,18 @@ function displayFullContext(filePath, lines) {
1229
1358
 
1230
1359
  // Escape HTML
1231
1360
  function escapeHtml(text) {
1232
- const div = document.createElement('div');
1233
- div.textContent = text;
1234
- return div.innerHTML;
1361
+ // The DOM does this for &, < and > and leaves both quote characters alone,
1362
+ // which is right for a text node and wrong for an attribute -- and most of
1363
+ // the callers here are attributes. A file or folder whose name contained a
1364
+ // double quote closed the attribute early, and everything after it in the
1365
+ // name was read as markup. Escaped here rather than at each call site,
1366
+ // because the safe version has to be the one that is easy to reach for.
1367
+ return String(text ?? '')
1368
+ .replace(/&/g, '&amp;')
1369
+ .replace(/</g, '&lt;')
1370
+ .replace(/>/g, '&gt;')
1371
+ .replace(/"/g, '&quot;')
1372
+ .replace(/'/g, '&#39;');
1235
1373
  }
1236
1374
 
1237
1375
  // Sidebar resize functionality
@@ -1337,11 +1475,16 @@ function updateCommentsSidebar() {
1337
1475
  ? `<div class="comment-item-selected">${escapeHtml(comment.selectedText)}</div>`
1338
1476
  : '';
1339
1477
 
1340
- const fileEscaped = file.replace(/'/g, "\\'");
1478
+ // Both controls used to carry the file name, quoted into a handler
1479
+ // with `'` replaced by `\'` and nothing else -- so a name with a
1480
+ // backslash before the quote escaped the escape and ended the string
1481
+ // anyway. A comment's identity here is its position in `comments`,
1482
+ // and that is a number. See #46.
1483
+ const commentIndex = comments.indexOf(comment);
1341
1484
  html += `
1342
1485
  <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})">
1486
+ <button class="comment-item-delete" onclick="event.stopPropagation(); deleteCommentFromSidebar(${Number(commentIndex)})" title="Delete comment">×</button>
1487
+ <div class="comment-item-content" onclick="jumpToComment(${Number(commentIndex)})">
1345
1488
  <div class="comment-item-file">${escapeHtml(file)}</div>
1346
1489
  <div class="comment-item-line">Line ${comment.line}</div>
1347
1490
  <div class="comment-item-text">${escapeHtml(comment.text)}</div>
@@ -1356,7 +1499,11 @@ function updateCommentsSidebar() {
1356
1499
  }
1357
1500
 
1358
1501
  // Delete comment from sidebar
1359
- function deleteCommentFromSidebar(file, diffLineIndex) {
1502
+ function deleteCommentFromSidebar(commentIndex) {
1503
+ const target = comments[Number(commentIndex)];
1504
+ if (!target) return;
1505
+
1506
+ const { file, diffLineIndex } = target;
1360
1507
  comments = comments.filter(c => !(c.file === file && c.diffLineIndex === diffLineIndex));
1361
1508
 
1362
1509
  // Auto-save to backend
@@ -1373,7 +1520,12 @@ function deleteCommentFromSidebar(file, diffLineIndex) {
1373
1520
  }
1374
1521
 
1375
1522
  // Jump to a specific comment
1376
- function jumpToComment(file, diffLineIndex) {
1523
+ function jumpToComment(commentIndex) {
1524
+ const target = comments[Number(commentIndex)];
1525
+ if (!target) return;
1526
+
1527
+ const { file, diffLineIndex } = target;
1528
+
1377
1529
  // Find the file index
1378
1530
  const fileIndex = currentFiles.findIndex(f => f.path === file);
1379
1531
  if (fileIndex === -1) return;
@@ -1442,11 +1594,259 @@ document.addEventListener('DOMContentLoaded', () => {
1442
1594
  }
1443
1595
  });
1444
1596
 
1597
+ for (const id of ['recentList', 'browseList']) {
1598
+ const list = document.getElementById(id);
1599
+ list.addEventListener('click', onPickerActivate);
1600
+ list.addEventListener('keydown', onPickerActivate);
1601
+ }
1602
+
1603
+ // Escape, and a click on the dimmed area around it, both mean "never mind"
1604
+ // -- but only when there is a review behind the picker to go back to.
1605
+ document.addEventListener('keydown', event => {
1606
+ if (event.key === 'Escape') closePicker();
1607
+ });
1608
+
1609
+ document.getElementById('picker').addEventListener('click', event => {
1610
+ if (event.target.id === 'picker') closePicker();
1611
+ });
1612
+
1445
1613
  // `reviewer /path/to/repo` opens the page with the repository in the query
1446
1614
  // string, so the review is on screen without anyone typing a path.
1447
1615
  const requestedRepo = new URLSearchParams(window.location.search).get('repo');
1448
1616
  if (requestedRepo) {
1449
1617
  document.getElementById('repoPath').value = requestedRepo;
1618
+ // Hidden before the request goes out, so the picker does not flash up for
1619
+ // the fraction of a second it takes to load a repository that was named.
1620
+ hidePicker();
1450
1621
  loadRepo();
1622
+ } else {
1623
+ showPicker();
1451
1624
  }
1452
1625
  });
1626
+
1627
+
1628
+ /* Repository picker
1629
+ * ---------------------------------------------------------------------------
1630
+ * What the page shows before a repository is open. It used to show nothing but
1631
+ * an empty path box in the header, which asked you to already know the path you
1632
+ * were looking for -- and to type it exactly.
1633
+ */
1634
+
1635
+ /** Where the browse list is pointed. Null until the first listing arrives. */
1636
+ let browseAt = null;
1637
+ let browseParent = null;
1638
+ let browseIsRepository = false;
1639
+
1640
+ /**
1641
+ * Show the picker.
1642
+ *
1643
+ * With nothing open it is the page. With a review open it is a layer over the
1644
+ * review, because being asked which repository you want should not cost you
1645
+ * the one you are reading.
1646
+ */
1647
+ function showPicker() {
1648
+ const reviewing = currentRepoId !== null;
1649
+
1650
+ document.getElementById('picker').classList.remove('hidden');
1651
+ document.getElementById('picker').classList.toggle('as-overlay', reviewing);
1652
+ document.getElementById('pickerClose').classList.toggle('hidden', !reviewing);
1653
+
1654
+ loadRecent();
1655
+ loadBrowse(browseAt);
1656
+ }
1657
+
1658
+ function hidePicker() {
1659
+ document.getElementById('picker').classList.add('hidden');
1660
+ }
1661
+
1662
+ /** Dismiss the picker, which only means anything when there is something behind it. */
1663
+ function closePicker() {
1664
+ if (currentRepoId !== null) hidePicker();
1665
+ }
1666
+
1667
+ /**
1668
+ * Reveal the path box, for pasting a path or typing one you already know.
1669
+ *
1670
+ * Kept out of the way rather than removed: a navigator is the slow way round
1671
+ * when the path is already on your clipboard.
1672
+ */
1673
+ function togglePathEntry() {
1674
+ const entry = document.getElementById('pathEntry');
1675
+ const toggle = document.getElementById('pathToggle');
1676
+ const showing = entry.classList.toggle('hidden') === false;
1677
+
1678
+ toggle.setAttribute('aria-expanded', String(showing));
1679
+
1680
+ if (showing) {
1681
+ const input = document.getElementById('repoPath');
1682
+ input.focus();
1683
+ input.select();
1684
+ }
1685
+ }
1686
+
1687
+ /**
1688
+ * Name the repository on the button that opens the picker.
1689
+ *
1690
+ * @param {string|null} repoPath
1691
+ */
1692
+ function setRepoButton(repoPath) {
1693
+ const name = repoPath ? repoPath.split(/[\\/]/).filter(Boolean).pop() : null;
1694
+
1695
+ document.getElementById('repoButtonLabel').textContent = name ?? 'Open a repository';
1696
+ document.getElementById('repoButton').title = repoPath ?? 'Choose a repository';
1697
+ }
1698
+
1699
+ /**
1700
+ * How long ago, in the least fussy words that are still true.
1701
+ *
1702
+ * @param {string} iso
1703
+ * @returns {string}
1704
+ */
1705
+ function timeAgo(iso) {
1706
+ const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
1707
+
1708
+ if (!Number.isFinite(seconds) || seconds < 60) return 'just now';
1709
+
1710
+ for (const [unit, size] of [['minute', 60], ['hour', 3600], ['day', 86400]]) {
1711
+ if (seconds < size * 60 || unit === 'day') {
1712
+ const count = Math.floor(seconds / size);
1713
+ if (unit === 'day' && count > 30) return new Date(iso).toLocaleDateString();
1714
+ return `${count} ${unit}${count === 1 ? '' : 's'} ago`;
1715
+ }
1716
+ }
1717
+ }
1718
+
1719
+ /** Repositories opened before, newest first. */
1720
+ async function loadRecent() {
1721
+ const section = document.getElementById('recentSection');
1722
+ const list = document.getElementById('recentList');
1723
+
1724
+ let projects;
1725
+ try {
1726
+ projects = (await (await fetch(`${API_BASE}/recent`)).json()).projects ?? [];
1727
+ } catch {
1728
+ // Not being able to remember is not worth an error message on a page whose
1729
+ // other half works. The browser below still opens anything.
1730
+ section.classList.add('hidden');
1731
+ return;
1732
+ }
1733
+
1734
+ if (projects.length === 0) {
1735
+ section.classList.add('hidden');
1736
+ return;
1737
+ }
1738
+
1739
+ section.classList.remove('hidden');
1740
+ list.innerHTML = projects
1741
+ .map(project => {
1742
+ const meta = project.exists
1743
+ ? [project.comments > 0 ? `${project.comments} comment${project.comments === 1 ? '' : 's'}` : '', timeAgo(project.openedAt)]
1744
+ .filter(Boolean)
1745
+ .join(' · ')
1746
+ : 'no longer there';
1747
+
1748
+ return `
1749
+ <div class="picker-row${project.exists ? '' : ' missing'}"
1750
+ ${project.exists ? `role="button" tabindex="0" data-open="${escapeHtml(project.path)}"` : ''}
1751
+ title="${escapeHtml(project.path)}">
1752
+ <span class="picker-row-icon">${project.exists ? '▸' : '×'}</span>
1753
+ <span class="picker-row-name">${escapeHtml(project.name)}</span>
1754
+ <span class="picker-row-path">${escapeHtml(project.location ?? '')}</span>
1755
+ <span class="picker-row-meta">${escapeHtml(meta)}</span>
1756
+ </div>`;
1757
+ })
1758
+ .join('');
1759
+ }
1760
+
1761
+ /**
1762
+ * List one directory.
1763
+ *
1764
+ * @param {string|null} path directory to list; the home directory when null
1765
+ */
1766
+ async function loadBrowse(path) {
1767
+ const list = document.getElementById('browseList');
1768
+
1769
+ let listing;
1770
+ try {
1771
+ const response = await fetch(`${API_BASE}/browse?path=${encodeURIComponent(path ?? '')}`);
1772
+ listing = await response.json();
1773
+ if (!response.ok) throw new Error(listing.error || 'Could not list that directory');
1774
+ } catch (error) {
1775
+ list.innerHTML = `<div class="picker-empty">${escapeHtml(error.message)}</div>`;
1776
+ return;
1777
+ }
1778
+
1779
+ browseAt = listing.path;
1780
+ browseParent = listing.parent;
1781
+ browseIsRepository = listing.isRepository;
1782
+
1783
+ document.getElementById('browsePath').textContent = listing.display ?? listing.path;
1784
+ document.getElementById('browseUpBtn').disabled = listing.parent === null;
1785
+ document
1786
+ .getElementById('browseOpenBtn')
1787
+ .classList.toggle('hidden', !listing.isRepository);
1788
+
1789
+ if (listing.entries.length === 0) {
1790
+ list.innerHTML = '<div class="picker-empty">No folders in here.</div>';
1791
+ return;
1792
+ }
1793
+
1794
+ // A repository row goes both ways: the row walks into it, the button opens
1795
+ // it. Walking in matters for the repository that has another one inside it,
1796
+ // and one click has to be enough for the ordinary case.
1797
+ list.innerHTML = listing.entries
1798
+ .map(
1799
+ entry => `
1800
+ <div class="picker-row" role="button" tabindex="0" data-into="${escapeHtml(entry.path)}"
1801
+ title="${escapeHtml(entry.path)}">
1802
+ <span class="picker-row-icon">${entry.isRepository ? '◉' : '▸'}</span>
1803
+ <span class="picker-row-name">${escapeHtml(entry.name)}</span>
1804
+ ${entry.isRepository ? '<span class="picker-badge">git</span>' : ''}
1805
+ <span class="picker-row-path"></span>
1806
+ ${entry.isRepository ? `<button class="picker-open" data-open="${escapeHtml(entry.path)}">Open</button>` : ''}
1807
+ </div>`
1808
+ )
1809
+ .join('');
1810
+ }
1811
+
1812
+ function browseUp() {
1813
+ if (browseParent) loadBrowse(browseParent);
1814
+ }
1815
+
1816
+ function openBrowsed() {
1817
+ if (browseIsRepository) openProject(browseAt);
1818
+ }
1819
+
1820
+ /**
1821
+ * Load a repository the way typing its path and pressing the button would.
1822
+ *
1823
+ * @param {string} path
1824
+ */
1825
+ function openProject(path) {
1826
+ document.getElementById('repoPath').value = path;
1827
+ loadRepo();
1828
+ }
1829
+
1830
+ /**
1831
+ * One handler for both lists: open what carries `data-open`, walk into what
1832
+ * carries `data-into`. Delegation rather than an `onclick` per row, because a
1833
+ * path is not safe to interpolate into an attribute that is then evaluated.
1834
+ *
1835
+ * @param {Event} event
1836
+ */
1837
+ function onPickerActivate(event) {
1838
+ if (event.type === 'keydown') {
1839
+ if (event.key !== 'Enter' && event.key !== ' ') return;
1840
+ event.preventDefault();
1841
+ }
1842
+
1843
+ const open = event.target.closest('[data-open]');
1844
+ if (open) {
1845
+ event.stopPropagation();
1846
+ openProject(open.dataset.open);
1847
+ return;
1848
+ }
1849
+
1850
+ const into = event.target.closest('[data-into]');
1851
+ if (into) loadBrowse(into.dataset.into);
1852
+ }