git-reviewer 2.8.0 → 2.9.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-284-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-291-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 # 284 tests
297
+ npm test # 291 tests
298
298
  npm run test:watch
299
299
  npm run test:coverage
300
300
  ```
package/bin/reviewer.js CHANGED
@@ -226,9 +226,25 @@ async function main() {
226
226
  stop();
227
227
  };
228
228
 
229
+ /**
230
+ * The last tab closed and did not come back.
231
+ *
232
+ * Only on a terminal. In the piped form, submitting is what hands the
233
+ * review over, and a closed tab is not a submitted review -- wiring this to
234
+ * the handoff would send a half-written review to something that starts
235
+ * editing files, which is a worse outcome by a wide margin than the Ctrl+C
236
+ * this saves.
237
+ */
238
+ const onIdle = handingOff
239
+ ? null
240
+ : () => {
241
+ note('\n Browser closed. Stopping.\n');
242
+ stop();
243
+ };
244
+
229
245
  const server = await listen(
230
246
  options.port ?? (Number(process.env.PORT) || DEFAULT_PORT),
231
- { onReviewSubmitted, handoff: handingOff }
247
+ { onReviewSubmitted, handoff: handingOff, onIdle }
232
248
  );
233
249
  const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath);
234
250
 
@@ -236,7 +252,7 @@ async function main() {
236
252
  if (repoPath) note(` reviewing ${repoPath}`);
237
253
  note(handingOff
238
254
  ? '\n Submit the review in the browser and it will be written here.\n'
239
- : '\n Press Ctrl+C to stop.\n');
255
+ : '\n Close the tab when you are done, or press Ctrl+C.\n');
240
256
 
241
257
  if (options.open && !(await openInBrowser(url))) {
242
258
  note(' Could not open a browser — open the URL above yourself.\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.8.0",
3
+ "version": "2.9.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
@@ -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
@@ -1707,7 +1718,31 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
1707
1718
  }
1708
1719
 
1709
1720
  // Allow Enter key to load repo
1721
+ /**
1722
+ * Tell the server this tab exists, by holding a connection open for as long
1723
+ * as it does.
1724
+ *
1725
+ * There is nothing to read from this stream. The point is the socket: the
1726
+ * browser closes it when the tab closes, which is the only notification of a
1727
+ * closed tab that arrives whether you quit the browser, kill the window or
1728
+ * lose the machine. `beforeunload` does not fire for all of those.
1729
+ *
1730
+ * EventSource reconnects on its own after a drop, which is what makes the
1731
+ * server's grace period work -- a flaky connection comes back inside it and
1732
+ * nothing is stopped.
1733
+ */
1734
+ function watchFromThisTab() {
1735
+ try {
1736
+ new EventSource(`${API_BASE}/alive`);
1737
+ } catch {
1738
+ // Older browsers, or a page opened from a file:// URL. Losing this costs
1739
+ // an automatic exit, not a review, so there is nothing to report.
1740
+ }
1741
+ }
1742
+
1710
1743
  document.addEventListener('DOMContentLoaded', () => {
1744
+ watchFromThisTab();
1745
+
1711
1746
  document.getElementById('repoPath').addEventListener('keypress', (e) => {
1712
1747
  if (e.key === 'Enter') {
1713
1748
  loadRepo();
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;
package/server.js CHANGED
@@ -55,6 +55,11 @@ class FileNotFoundError extends Error {
55
55
  * @param {boolean} [options.handoff] whether the caller is going to do
56
56
  * something with a submitted review. Reported to the page so it can say
57
57
  * where the review went instead of only offering a download.
58
+ * @param {() => void} [options.onIdle] called once the last browser tab has
59
+ * been gone for `idleGraceMs`, so the process that started the server can
60
+ * stop. Omitted means never: a server with no one to tell keeps serving.
61
+ * @param {number} [options.idleGraceMs] how long to wait after the last tab
62
+ * leaves before deciding it is not coming back.
58
63
  * @returns {import('express').Express}
59
64
  */
60
65
  function createApp(options = {}) {
@@ -63,7 +68,9 @@ function createApp(options = {}) {
63
68
  sessions = new SessionStore(),
64
69
  git: gitFactory = simpleGit,
65
70
  onReviewSubmitted = null,
66
- handoff = false
71
+ handoff = false,
72
+ onIdle = null,
73
+ idleGraceMs = 5000
67
74
  } = options;
68
75
 
69
76
  const app = express();
@@ -75,6 +82,11 @@ function createApp(options = {}) {
75
82
  app.locals.sessions = sessions;
76
83
  app.locals.reviewsDir = reviewsDir;
77
84
 
85
+ // How many tabs are currently holding `/api/alive` open, and the timer that
86
+ // starts when that reaches zero. See the route for why it is a timer.
87
+ let watching = 0;
88
+ let idleTimer = null;
89
+
78
90
  /**
79
91
  * Look up the session for a request, or answer 400.
80
92
  *
@@ -222,6 +234,59 @@ function createApp(options = {}) {
222
234
  next();
223
235
  }
224
236
 
237
+ /**
238
+ * Held open for as long as a tab is watching, so the server can tell when
239
+ * the last one has gone.
240
+ *
241
+ * The connection *is* the signal. An open page holds this response open and
242
+ * closing the page closes it, so there is no heartbeat interval to tune, and
243
+ * two tabs are two connections rather than one flag that either close
244
+ * clears. `sameOriginOnly` is here for the same reason it is on the two
245
+ * below: without it a page on another site could hold this open and keep a
246
+ * server alive that its owner had finished with.
247
+ *
248
+ * Nothing happens at the moment a watcher leaves. A reload closes and
249
+ * reopens within milliseconds, and a dropped connection reopens when
250
+ * EventSource retries -- at the instant it happens neither is
251
+ * distinguishable from someone closing the tab for good. So the last one
252
+ * leaving starts a timer and the next to arrive cancels it. Guessing wrong
253
+ * ends a review somebody is in the middle of, which is worth five seconds.
254
+ *
255
+ * A server nobody ever opened is not idle. `watching` only falls to zero
256
+ * after having been above it, so `reviewer` whose browser never launched
257
+ * keeps serving rather than exiting into an empty terminal.
258
+ */
259
+ app.get('/api/alive', sameOriginOnly, (req, res) => {
260
+ if (idleTimer) {
261
+ clearTimeout(idleTimer);
262
+ idleTimer = null;
263
+ }
264
+ watching += 1;
265
+
266
+ res.set({
267
+ 'Content-Type': 'text/event-stream',
268
+ 'Cache-Control': 'no-cache',
269
+ Connection: 'keep-alive'
270
+ });
271
+ // A comment frame, which EventSource ignores. Sending it now is what
272
+ // flushes the headers, so the client is connected rather than pending.
273
+ res.write(': watching\n\n');
274
+
275
+ res.on('close', () => {
276
+ watching -= 1;
277
+ if (watching > 0 || !onIdle) return;
278
+
279
+ idleTimer = setTimeout(() => {
280
+ idleTimer = null;
281
+ if (watching === 0) onIdle();
282
+ }, idleGraceMs);
283
+ // The listening socket is what holds the process open; this timer
284
+ // should not be able to on its own, or a closed server would sit
285
+ // waiting out the grace period before the process could exit.
286
+ idleTimer.unref();
287
+ });
288
+ });
289
+
225
290
  /** List the directories inside one, so a repository can be found by looking. */
226
291
  app.get('/api/browse', sameOriginOnly, async (req, res) => {
227
292
  try {