git-reviewer 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-281-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-285-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  When an agent writes the code, reading it becomes the bottleneck — and the tool
@@ -294,7 +294,7 @@ rather than a public issue.
294
294
 
295
295
  ```bash
296
296
  npm install # 3 dependencies, no build step, ~6MB
297
- npm test # 281 tests
297
+ npm test # 285 tests
298
298
  npm run test:watch
299
299
  npm run test:coverage
300
300
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
4
4
  "description": "Review your coding agent's changes like a pull request — locally, with inline comments and threaded follow-ups, then hand the marked-up review back to the agent",
5
5
  "author": "Dheeraj Jha",
6
6
  "license": "MIT",
package/public/app.js CHANGED
@@ -222,6 +222,17 @@ async function loadRepo() {
222
222
  }
223
223
 
224
224
  // Display list of changed files
225
+ function fileStatusClass(status) {
226
+ switch (status) {
227
+ case 'A': return 'file-status-added';
228
+ case 'D': return 'file-status-deleted';
229
+ case 'R': return 'file-status-renamed';
230
+ case 'B': return 'file-status-binary';
231
+ case 'M':
232
+ default: return 'file-status-modified';
233
+ }
234
+ }
235
+
225
236
  function displayFiles(files) {
226
237
  const sidebar = document.getElementById('sidebar');
227
238
  const filesList = document.getElementById('filesList');
@@ -244,7 +255,7 @@ function displayFiles(files) {
244
255
  const displayText = path
245
256
  ? `${escapeHtml(path)}/<span class="filename">${escapeHtml(filename)}</span>`
246
257
  : `<span class="filename">${escapeHtml(filename)}</span>`;
247
- const statusClass = status === 'A' ? 'file-status-added' : 'file-status-modified';
258
+ const statusClass = fileStatusClass(status);
248
259
  const statusBadge = `<span class="file-status ${statusClass}">${status}</span>`;
249
260
 
250
261
  // The row used to carry the file name itself, inside a string literal
@@ -426,7 +437,7 @@ function displayCode(filePath, diffLines, binary) {
426
437
  const newLineClick = diffLine.type !== 'delete' ? `onclick="toggleCommentInput(${Number(diffLineIndex)})"` : '';
427
438
 
428
439
  html += `
429
- <div class="${lineClass}" data-diff-index="${diffLineIndex}">
440
+ <div class="${lineClass}" tabindex="0" data-diff-index="${diffLineIndex}">
430
441
  ${hasComment ? '<span class="comment-indicator"></span>' : ''}
431
442
  <div class="line-numbers">
432
443
  <span class="old-line-number" ${oldLineClick}>${diffLine.oldLine || ''}</span>
@@ -506,7 +517,7 @@ function displayCode(filePath, diffLines, binary) {
506
517
  // rendered from. The row's random id went with it: the position is the
507
518
  // identifier now.
508
519
  html += `
509
- <div class="unmatched-comment-item collapsed">
520
+ <div class="unmatched-comment-item collapsed" tabindex="0">
510
521
  <div class="unmatched-comment-header" onclick="toggleUnmatchedComment(${Number(unmatchedIndex)})">
511
522
  <span class="unmatched-comment-icon">⚠️</span>
512
523
  <span class="unmatched-comment-title">Comment not found (Line ${comment.line} changed)</span>
@@ -1357,7 +1368,7 @@ function displayFullContext(filePath, lines) {
1357
1368
  const dataDiffIndexAttr = diffLineIndex !== undefined ? `data-diff-index="${diffLineIndex}"` : '';
1358
1369
 
1359
1370
  html += `
1360
- <div class="${lineClass}" ${dataDiffIndexAttr}>
1371
+ <div class="${lineClass}" tabindex="0" ${dataDiffIndexAttr}>
1361
1372
  ${hasComment ? '<span class="comment-indicator"></span>' : ''}
1362
1373
  <div class="line-numbers">
1363
1374
  <span class="old-line-number">${diffLine.oldLine || ''}</span>
@@ -1636,6 +1647,76 @@ function navigateFiles(direction) {
1636
1647
  }
1637
1648
  }
1638
1649
 
1650
+ // Navigate to comments in current file
1651
+ function navigateComments(direction) {
1652
+ const commentElements = Array.from(document.querySelectorAll('.line.commented, .unmatched-comment-item'));
1653
+ if (commentElements.length === 0) return;
1654
+
1655
+ const active = document.activeElement;
1656
+ const currentIndex = commentElements.findIndex(el => el === active || el.contains(active));
1657
+
1658
+ let newIndex = currentIndex;
1659
+ if (currentIndex === -1) {
1660
+ newIndex = direction === 'next' ? 0 : commentElements.length - 1;
1661
+ } else {
1662
+ // stop at boundaries, matching existing file navigation style
1663
+ if (direction === 'next') {
1664
+ newIndex = Math.min(currentIndex + 1, commentElements.length - 1);
1665
+ } else {
1666
+ newIndex = Math.max(currentIndex - 1, 0);
1667
+ }
1668
+ }
1669
+
1670
+ if (newIndex !== currentIndex && commentElements[newIndex]) {
1671
+ const target = commentElements[newIndex];
1672
+ target.focus();
1673
+ target.scrollIntoView({ block: 'nearest' });
1674
+ }
1675
+ }
1676
+
1677
+ function handleCommentShortcut() {
1678
+ const active = document.activeElement;
1679
+ if (!active) return;
1680
+ const line = active.closest('.line');
1681
+ if (!line) return;
1682
+ const diffIndex = parseInt(line.dataset.diffIndex, 10);
1683
+ if (!isNaN(diffIndex)) {
1684
+ toggleCommentInput(diffIndex);
1685
+ }
1686
+ }
1687
+
1688
+ /**
1689
+ * Pure function mapping keyboard events to application actions.
1690
+ * @param {string} key
1691
+ * @param {string} targetTagName
1692
+ * @param {{ctrl?: boolean, meta?: boolean, alt?: boolean}} [modifiers]
1693
+ * @returns {string|null} Action to take, or null if ignored.
1694
+ */
1695
+ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
1696
+ if (modifiers.ctrl || modifiers.meta || modifiers.alt) return null;
1697
+
1698
+ // Escape means "never mind" everywhere — including inside inputs — so it
1699
+ // must be checked before the typing guard.
1700
+ if (key === 'Escape') return 'escape';
1701
+
1702
+ if (targetTagName === 'INPUT' || targetTagName === 'TEXTAREA') {
1703
+ return null;
1704
+ }
1705
+
1706
+ const map = {
1707
+ 'ArrowUp': 'prevFile',
1708
+ 'k': 'prevFile',
1709
+ 'ArrowDown': 'nextFile',
1710
+ 'j': 'nextFile',
1711
+ 'n': 'nextComment',
1712
+ 'p': 'prevComment',
1713
+ 'c': 'commentFocus',
1714
+ '?': 'help'
1715
+ };
1716
+
1717
+ return map[key] || null;
1718
+ }
1719
+
1639
1720
  // Allow Enter key to load repo
1640
1721
  document.addEventListener('DOMContentLoaded', () => {
1641
1722
  document.getElementById('repoPath').addEventListener('keypress', (e) => {
@@ -1647,22 +1728,31 @@ document.addEventListener('DOMContentLoaded', () => {
1647
1728
  // Initialize resize handle
1648
1729
  initResizeHandle();
1649
1730
 
1650
- // Add keyboard navigation for files
1731
+ // Add keyboard navigation
1651
1732
  document.addEventListener('keydown', (e) => {
1652
- // Only handle arrow keys when not in an input/textarea
1653
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
1654
- return;
1655
- }
1733
+ const action = getKeyboardShortcut(e.key, e.target.tagName, { ctrl: e.ctrlKey, meta: e.metaKey, alt: e.altKey });
1734
+ if (!action || action === 'escape') return; // escape is handled separately
1656
1735
 
1657
- if (e.key === 'ArrowUp') {
1736
+ if (action === 'prevFile') {
1658
1737
  e.preventDefault();
1659
1738
  navigateFiles('up');
1660
- } else if (e.key === 'ArrowDown') {
1739
+ } else if (action === 'nextFile') {
1661
1740
  e.preventDefault();
1662
1741
  navigateFiles('down');
1742
+ } else if (action === 'nextComment') {
1743
+ e.preventDefault();
1744
+ navigateComments('next');
1745
+ } else if (action === 'prevComment') {
1746
+ e.preventDefault();
1747
+ navigateComments('prev');
1748
+ } else if (action === 'commentFocus') {
1749
+ e.preventDefault();
1750
+ handleCommentShortcut();
1751
+ } else if (action === 'help') {
1752
+ e.preventDefault();
1753
+ document.getElementById('helpButton').click();
1663
1754
  }
1664
1755
  });
1665
-
1666
1756
  for (const id of ['recentList', 'browseList']) {
1667
1757
  const list = document.getElementById(id);
1668
1758
  list.addEventListener('click', onPickerActivate);
@@ -1672,13 +1762,22 @@ document.addEventListener('DOMContentLoaded', () => {
1672
1762
  // Escape, and a click on the dimmed area around it, both mean "never mind"
1673
1763
  // -- but only when there is a review behind the picker to go back to.
1674
1764
  document.addEventListener('keydown', event => {
1675
- if (event.key !== 'Escape') return;
1765
+ if (getKeyboardShortcut(event.key, event.target.tagName, { ctrl: event.ctrlKey, meta: event.metaKey, alt: event.altKey }) !== 'escape') {
1766
+ return;
1767
+ }
1676
1768
 
1677
1769
  // Topmost first: the help sits over the picker, which sits over a review.
1678
1770
  if (!document.getElementById('helpModal').classList.contains('hidden')) {
1679
1771
  closeHelp();
1680
1772
  return;
1681
1773
  }
1774
+
1775
+ const reviewModal = document.querySelector('.review-modal');
1776
+ if (reviewModal) {
1777
+ reviewModal.remove();
1778
+ return;
1779
+ }
1780
+
1682
1781
  closePicker();
1683
1782
  });
1684
1783
 
@@ -1817,8 +1916,8 @@ async function loadRecent() {
1817
1916
  .map(project => {
1818
1917
  const meta = project.exists
1819
1918
  ? [project.comments > 0 ? `${project.comments} comment${project.comments === 1 ? '' : 's'}` : '', timeAgo(project.openedAt)]
1820
- .filter(Boolean)
1821
- .join(' · ')
1919
+ .filter(Boolean)
1920
+ .join(' · ')
1822
1921
  : 'no longer there';
1823
1922
 
1824
1923
  return `
@@ -1972,3 +2071,9 @@ function offerHelpOnce() {
1972
2071
  if (helpWasSeen()) return;
1973
2072
  openHelp();
1974
2073
  }
2074
+
2075
+ if (typeof module !== 'undefined' && module.exports) {
2076
+ module.exports = {
2077
+ getKeyboardShortcut
2078
+ };
2079
+ }
package/public/index.html CHANGED
@@ -149,7 +149,8 @@
149
149
  <div class="help-foot">
150
150
  <span class="help-keys">
151
151
  <kbd>⌘</kbd><kbd>Enter</kbd> save · <kbd>Esc</kbd> cancel ·
152
- <kbd>↑</kbd><kbd>↓</kbd> move between files
152
+ <kbd>↑</kbd><kbd>↓</kbd><kbd>j</kbd><kbd>k</kbd> files ·
153
+ <kbd>n</kbd><kbd>p</kbd> comments · <kbd>c</kbd> new comment · <kbd>?</kbd> help
153
154
  </span>
154
155
  <button class="help-done" onclick="closeHelp()">Got it</button>
155
156
  </div>
package/public/style.css CHANGED
@@ -221,6 +221,21 @@ button:disabled {
221
221
  color: white;
222
222
  }
223
223
 
224
+ .file-status-deleted {
225
+ background: #d73a49;
226
+ color: white;
227
+ }
228
+
229
+ .file-status-renamed {
230
+ background: #6f42c1;
231
+ color: white;
232
+ }
233
+
234
+ .file-status-binary {
235
+ background: #6a737d;
236
+ color: white;
237
+ }
238
+
224
239
  .file-item:hover {
225
240
  background: #f3f4f6;
226
241
  border-left-color: #0366d6;
@@ -441,6 +456,11 @@ button:disabled {
441
456
  background: rgba(0, 0, 0, 0.03);
442
457
  }
443
458
 
459
+ .line:focus, .unmatched-comment-item:focus {
460
+ outline: 2px solid #0366d6;
461
+ outline-offset: -2px;
462
+ }
463
+
444
464
  /* Diff-specific styling */
445
465
  .diff-add {
446
466
  background: #e6ffed;