git-reviewer 2.6.0 → 2.8.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 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-272-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-284-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 # 272 tests
297
+ npm test # 284 tests
298
298
  npm run test:watch
299
299
  npm run test:coverage
300
300
  ```
package/lib/changes.js CHANGED
@@ -14,6 +14,119 @@
14
14
  * @property {'A'|'M'|'D'|'R'|'B'} status
15
15
  */
16
16
 
17
+ /**
18
+ * Unquotes git's octal-escaped, C-quoted filename strings (e.g. "\"caf\\303\\251.txt\"" -> "café.txt").
19
+ *
20
+ * When core.quotePath is enabled (default in git), paths containing non-ASCII
21
+ * or control characters are enclosed in double quotes and byte-escaped with octal
22
+ * sequences.
23
+ *
24
+ * @param {string} str
25
+ * @returns {string}
26
+ */
27
+ function unquoteGitPath(str) {
28
+ if (typeof str !== 'string') return str;
29
+ if (str.length >= 2 && str.startsWith('"') && str.endsWith('"')) {
30
+ const inner = str.slice(1, -1);
31
+ const bytes = [];
32
+ for (let i = 0; i < inner.length; i++) {
33
+ if (inner[i] === '\\' && i + 1 < inner.length) {
34
+ i++;
35
+ const next = inner[i];
36
+ if (next >= '0' && next <= '7') {
37
+ let octal = next;
38
+ if (i + 1 < inner.length && inner[i + 1] >= '0' && inner[i + 1] <= '7') {
39
+ i++;
40
+ octal += inner[i];
41
+ if (i + 1 < inner.length && inner[i + 1] >= '0' && inner[i + 1] <= '7') {
42
+ i++;
43
+ octal += inner[i];
44
+ }
45
+ }
46
+ bytes.push(parseInt(octal, 8));
47
+ } else if (next === 'a') {
48
+ bytes.push(0x07);
49
+ } else if (next === 'b') {
50
+ bytes.push(0x08);
51
+ } else if (next === 't') {
52
+ bytes.push(0x09);
53
+ } else if (next === 'n') {
54
+ bytes.push(0x0a);
55
+ } else if (next === 'v') {
56
+ bytes.push(0x0b);
57
+ } else if (next === 'f') {
58
+ bytes.push(0x0c);
59
+ } else if (next === 'r') {
60
+ bytes.push(0x0d);
61
+ } else if (next === '"') {
62
+ bytes.push(0x22);
63
+ } else if (next === '\\') {
64
+ bytes.push(0x5c);
65
+ } else {
66
+ const code = next.charCodeAt(0);
67
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < inner.length) {
68
+ const nextCode = inner.charCodeAt(i + 1);
69
+ if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
70
+ const pair = inner.slice(i, i + 2);
71
+ i++;
72
+ const buf = Buffer.from(pair, 'utf8');
73
+ for (const b of buf) bytes.push(b);
74
+ continue;
75
+ }
76
+ }
77
+ const buf = Buffer.from(next, 'utf8');
78
+ for (const b of buf) bytes.push(b);
79
+ }
80
+ } else {
81
+ const code = inner.charCodeAt(i);
82
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < inner.length) {
83
+ const nextCode = inner.charCodeAt(i + 1);
84
+ if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
85
+ const pair = inner.slice(i, i + 2);
86
+ i++;
87
+ const buf = Buffer.from(pair, 'utf8');
88
+ for (const b of buf) bytes.push(b);
89
+ continue;
90
+ }
91
+ }
92
+ const buf = Buffer.from(inner[i], 'utf8');
93
+ for (const b of buf) bytes.push(b);
94
+ }
95
+ }
96
+ return Buffer.from(bytes).toString('utf8');
97
+ }
98
+ return str;
99
+ }
100
+
101
+ /**
102
+ * Extracts the target path and whether the file entry represents a rename
103
+ * from git diff --stat display format (e.g. "old => new", "prefix/{old => new}.ext").
104
+ *
105
+ * @param {string} raw
106
+ * @returns {{ isRename: boolean, path: string }}
107
+ */
108
+ function parseCommitFilePath(raw) {
109
+ if (typeof raw !== 'string') return { isRename: false, path: raw ?? '' };
110
+
111
+ // 1. Brace rename: prefix{old => new}suffix or {old => new}
112
+ const braceMatch = raw.match(/^(.*?)\{(?:.*?) => (.*?)\}(.*)$/);
113
+ if (braceMatch) {
114
+ const rawTo = braceMatch[1] + braceMatch[2] + braceMatch[3];
115
+ const normalized = unquoteGitPath(rawTo).replace(/\/{2,}/g, '/').replace(/^\/+/, '');
116
+ return { isRename: true, path: normalized };
117
+ }
118
+
119
+ // 2. Arrow rename: old => new (or "old" => "new")
120
+ const arrowMatch = raw.match(/^(?:.*?) => (.*)$/);
121
+ if (arrowMatch) {
122
+ const rawTo = arrowMatch[1].trim();
123
+ const normalized = unquoteGitPath(rawTo).replace(/\/{2,}/g, '/').replace(/^\/+/, '');
124
+ return { isRename: true, path: normalized };
125
+ }
126
+
127
+ return { isRename: false, path: unquoteGitPath(raw).replace(/^\/+/, '') };
128
+ }
129
+
17
130
  /**
18
131
  * Collect working-directory changes from a `simple-git` status result.
19
132
  *
@@ -39,7 +152,8 @@ function collectWorkingChanges(status) {
39
152
  for (const [files, letter] of buckets) {
40
153
  for (const entry of files) {
41
154
  // `renamed` holds `{ from, to }`; every other bucket holds plain strings.
42
- const filePath = typeof entry === 'string' ? entry : entry?.to;
155
+ const rawPath = typeof entry === 'string' ? entry : entry?.to;
156
+ const filePath = unquoteGitPath(rawPath);
43
157
  if (!filePath || seen.has(filePath)) continue;
44
158
  seen.set(filePath, { path: filePath, status: letter });
45
159
  }
@@ -56,9 +170,10 @@ function collectWorkingChanges(status) {
56
170
  * it deletes none.
57
171
  *
58
172
  * @param {object} file entry from `git.diffSummary()`
59
- * @returns {'A'|'M'|'D'|'B'}
173
+ * @returns {'A'|'M'|'D'|'R'|'B'}
60
174
  */
61
175
  function classifyCommitFile(file) {
176
+ if (file?.file && parseCommitFilePath(file.file).isRename) return 'R';
62
177
  if (file?.binary) return 'B';
63
178
 
64
179
  const insertions = file?.insertions ?? 0;
@@ -78,7 +193,17 @@ function classifyCommitFile(file) {
78
193
  */
79
194
  function collectCommitChanges(diffSummary) {
80
195
  const files = diffSummary?.files ?? [];
81
- return files.map(file => ({ path: file.file, status: classifyCommitFile(file) }));
196
+ return files.map(file => {
197
+ const { path, isRename } = parseCommitFilePath(file?.file);
198
+ const status = isRename ? 'R' : classifyCommitFile(file);
199
+ return { path, status };
200
+ });
82
201
  }
83
202
 
84
- module.exports = { collectWorkingChanges, collectCommitChanges, classifyCommitFile };
203
+ module.exports = {
204
+ collectWorkingChanges,
205
+ collectCommitChanges,
206
+ classifyCommitFile,
207
+ unquoteGitPath,
208
+ parseCommitFilePath
209
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
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
@@ -426,7 +426,7 @@ function displayCode(filePath, diffLines, binary) {
426
426
  const newLineClick = diffLine.type !== 'delete' ? `onclick="toggleCommentInput(${Number(diffLineIndex)})"` : '';
427
427
 
428
428
  html += `
429
- <div class="${lineClass}" data-diff-index="${diffLineIndex}">
429
+ <div class="${lineClass}" tabindex="0" data-diff-index="${diffLineIndex}">
430
430
  ${hasComment ? '<span class="comment-indicator"></span>' : ''}
431
431
  <div class="line-numbers">
432
432
  <span class="old-line-number" ${oldLineClick}>${diffLine.oldLine || ''}</span>
@@ -506,7 +506,7 @@ function displayCode(filePath, diffLines, binary) {
506
506
  // rendered from. The row's random id went with it: the position is the
507
507
  // identifier now.
508
508
  html += `
509
- <div class="unmatched-comment-item collapsed">
509
+ <div class="unmatched-comment-item collapsed" tabindex="0">
510
510
  <div class="unmatched-comment-header" onclick="toggleUnmatchedComment(${Number(unmatchedIndex)})">
511
511
  <span class="unmatched-comment-icon">⚠️</span>
512
512
  <span class="unmatched-comment-title">Comment not found (Line ${comment.line} changed)</span>
@@ -1357,7 +1357,7 @@ function displayFullContext(filePath, lines) {
1357
1357
  const dataDiffIndexAttr = diffLineIndex !== undefined ? `data-diff-index="${diffLineIndex}"` : '';
1358
1358
 
1359
1359
  html += `
1360
- <div class="${lineClass}" ${dataDiffIndexAttr}>
1360
+ <div class="${lineClass}" tabindex="0" ${dataDiffIndexAttr}>
1361
1361
  ${hasComment ? '<span class="comment-indicator"></span>' : ''}
1362
1362
  <div class="line-numbers">
1363
1363
  <span class="old-line-number">${diffLine.oldLine || ''}</span>
@@ -1636,6 +1636,76 @@ function navigateFiles(direction) {
1636
1636
  }
1637
1637
  }
1638
1638
 
1639
+ // Navigate to comments in current file
1640
+ function navigateComments(direction) {
1641
+ const commentElements = Array.from(document.querySelectorAll('.line.commented, .unmatched-comment-item'));
1642
+ if (commentElements.length === 0) return;
1643
+
1644
+ const active = document.activeElement;
1645
+ const currentIndex = commentElements.findIndex(el => el === active || el.contains(active));
1646
+
1647
+ let newIndex = currentIndex;
1648
+ if (currentIndex === -1) {
1649
+ newIndex = direction === 'next' ? 0 : commentElements.length - 1;
1650
+ } else {
1651
+ // stop at boundaries, matching existing file navigation style
1652
+ if (direction === 'next') {
1653
+ newIndex = Math.min(currentIndex + 1, commentElements.length - 1);
1654
+ } else {
1655
+ newIndex = Math.max(currentIndex - 1, 0);
1656
+ }
1657
+ }
1658
+
1659
+ if (newIndex !== currentIndex && commentElements[newIndex]) {
1660
+ const target = commentElements[newIndex];
1661
+ target.focus();
1662
+ target.scrollIntoView({ block: 'nearest' });
1663
+ }
1664
+ }
1665
+
1666
+ function handleCommentShortcut() {
1667
+ const active = document.activeElement;
1668
+ if (!active) return;
1669
+ const line = active.closest('.line');
1670
+ if (!line) return;
1671
+ const diffIndex = parseInt(line.dataset.diffIndex, 10);
1672
+ if (!isNaN(diffIndex)) {
1673
+ toggleCommentInput(diffIndex);
1674
+ }
1675
+ }
1676
+
1677
+ /**
1678
+ * Pure function mapping keyboard events to application actions.
1679
+ * @param {string} key
1680
+ * @param {string} targetTagName
1681
+ * @param {{ctrl?: boolean, meta?: boolean, alt?: boolean}} [modifiers]
1682
+ * @returns {string|null} Action to take, or null if ignored.
1683
+ */
1684
+ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
1685
+ if (modifiers.ctrl || modifiers.meta || modifiers.alt) return null;
1686
+
1687
+ // Escape means "never mind" everywhere — including inside inputs — so it
1688
+ // must be checked before the typing guard.
1689
+ if (key === 'Escape') return 'escape';
1690
+
1691
+ if (targetTagName === 'INPUT' || targetTagName === 'TEXTAREA') {
1692
+ return null;
1693
+ }
1694
+
1695
+ const map = {
1696
+ 'ArrowUp': 'prevFile',
1697
+ 'k': 'prevFile',
1698
+ 'ArrowDown': 'nextFile',
1699
+ 'j': 'nextFile',
1700
+ 'n': 'nextComment',
1701
+ 'p': 'prevComment',
1702
+ 'c': 'commentFocus',
1703
+ '?': 'help'
1704
+ };
1705
+
1706
+ return map[key] || null;
1707
+ }
1708
+
1639
1709
  // Allow Enter key to load repo
1640
1710
  document.addEventListener('DOMContentLoaded', () => {
1641
1711
  document.getElementById('repoPath').addEventListener('keypress', (e) => {
@@ -1647,22 +1717,31 @@ document.addEventListener('DOMContentLoaded', () => {
1647
1717
  // Initialize resize handle
1648
1718
  initResizeHandle();
1649
1719
 
1650
- // Add keyboard navigation for files
1720
+ // Add keyboard navigation
1651
1721
  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
- }
1722
+ const action = getKeyboardShortcut(e.key, e.target.tagName, { ctrl: e.ctrlKey, meta: e.metaKey, alt: e.altKey });
1723
+ if (!action || action === 'escape') return; // escape is handled separately
1656
1724
 
1657
- if (e.key === 'ArrowUp') {
1725
+ if (action === 'prevFile') {
1658
1726
  e.preventDefault();
1659
1727
  navigateFiles('up');
1660
- } else if (e.key === 'ArrowDown') {
1728
+ } else if (action === 'nextFile') {
1661
1729
  e.preventDefault();
1662
1730
  navigateFiles('down');
1731
+ } else if (action === 'nextComment') {
1732
+ e.preventDefault();
1733
+ navigateComments('next');
1734
+ } else if (action === 'prevComment') {
1735
+ e.preventDefault();
1736
+ navigateComments('prev');
1737
+ } else if (action === 'commentFocus') {
1738
+ e.preventDefault();
1739
+ handleCommentShortcut();
1740
+ } else if (action === 'help') {
1741
+ e.preventDefault();
1742
+ document.getElementById('helpButton').click();
1663
1743
  }
1664
1744
  });
1665
-
1666
1745
  for (const id of ['recentList', 'browseList']) {
1667
1746
  const list = document.getElementById(id);
1668
1747
  list.addEventListener('click', onPickerActivate);
@@ -1672,13 +1751,22 @@ document.addEventListener('DOMContentLoaded', () => {
1672
1751
  // Escape, and a click on the dimmed area around it, both mean "never mind"
1673
1752
  // -- but only when there is a review behind the picker to go back to.
1674
1753
  document.addEventListener('keydown', event => {
1675
- if (event.key !== 'Escape') return;
1754
+ if (getKeyboardShortcut(event.key, event.target.tagName, { ctrl: event.ctrlKey, meta: event.metaKey, alt: event.altKey }) !== 'escape') {
1755
+ return;
1756
+ }
1676
1757
 
1677
1758
  // Topmost first: the help sits over the picker, which sits over a review.
1678
1759
  if (!document.getElementById('helpModal').classList.contains('hidden')) {
1679
1760
  closeHelp();
1680
1761
  return;
1681
1762
  }
1763
+
1764
+ const reviewModal = document.querySelector('.review-modal');
1765
+ if (reviewModal) {
1766
+ reviewModal.remove();
1767
+ return;
1768
+ }
1769
+
1682
1770
  closePicker();
1683
1771
  });
1684
1772
 
@@ -1817,8 +1905,8 @@ async function loadRecent() {
1817
1905
  .map(project => {
1818
1906
  const meta = project.exists
1819
1907
  ? [project.comments > 0 ? `${project.comments} comment${project.comments === 1 ? '' : 's'}` : '', timeAgo(project.openedAt)]
1820
- .filter(Boolean)
1821
- .join(' · ')
1908
+ .filter(Boolean)
1909
+ .join(' · ')
1822
1910
  : 'no longer there';
1823
1911
 
1824
1912
  return `
@@ -1972,3 +2060,9 @@ function offerHelpOnce() {
1972
2060
  if (helpWasSeen()) return;
1973
2061
  openHelp();
1974
2062
  }
2063
+
2064
+ if (typeof module !== 'undefined' && module.exports) {
2065
+ module.exports = {
2066
+ getKeyboardShortcut
2067
+ };
2068
+ }
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
@@ -441,6 +441,11 @@ button:disabled {
441
441
  background: rgba(0, 0, 0, 0.03);
442
442
  }
443
443
 
444
+ .line:focus, .unmatched-comment-item:focus {
445
+ outline: 2px solid #0366d6;
446
+ outline-offset: -2px;
447
+ }
448
+
444
449
  /* Diff-specific styling */
445
450
  .diff-add {
446
451
  background: #e6ffed;