reviw 0.15.3 → 0.16.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.
Files changed (3) hide show
  1. package/README.md +3 -0
  2. package/cli.cjs +304 -57
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -51,6 +51,9 @@ A lightweight browser-based tool for reviewing and annotating tabular data, text
51
51
  - **Real-time updates**: Hot reload on file changes via SSE
52
52
  - **Comment persistence**: Auto-save comments to localStorage with recovery modal
53
53
  - **Keyboard shortcuts**: Cmd/Ctrl+Enter to open submit modal
54
+ - **Multi-tab sync**: Submit from any tab closes all tabs for the same file
55
+ - **Server detection**: Reuse existing server instead of starting a new one (via lock files)
56
+ - **Tab activation (macOS)**: Automatically activates existing browser tab via AppleScript
54
57
 
55
58
  ### Output
56
59
  - YAML format with file, mode, row, col, value, and comment text
package/cli.cjs CHANGED
@@ -15,7 +15,8 @@ const fs = require("fs");
15
15
  const http = require("http");
16
16
  const os = require("os");
17
17
  const path = require("path");
18
- const { spawn } = require("child_process");
18
+ const crypto = require("crypto");
19
+ const { spawn, execSync, spawnSync } = require("child_process");
19
20
  const chardet = require("chardet");
20
21
  const iconv = require("iconv-lite");
21
22
  const marked = require("marked");
@@ -1764,7 +1765,17 @@ function diffHtmlTemplate(diffData) {
1764
1765
  let es = null;
1765
1766
  const connect = () => {
1766
1767
  es = new EventSource('/sse');
1767
- es.onmessage = ev => { if (ev.data === 'reload') location.reload(); };
1768
+ es.onmessage = ev => {
1769
+ if (ev.data === 'reload') location.reload();
1770
+ if (ev.data === 'submitted') {
1771
+ // Another tab submitted - try to close this tab
1772
+ window.close();
1773
+ // If window.close() didn't work, show completion message
1774
+ setTimeout(() => {
1775
+ document.body.innerHTML = '<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;background:var(--bg,#1a1a2e);color:var(--text,#e0e0e0);font-family:system-ui,sans-serif;"><h1 style="font-size:2rem;margin-bottom:1rem;">✅ Review Submitted</h1><p style="color:var(--muted,#888);">Submitted from another tab. You can close this tab now.</p></div>';
1776
+ }, 100);
1777
+ }
1778
+ };
1768
1779
  es.onerror = () => { es.close(); setTimeout(connect, 1500); };
1769
1780
  };
1770
1781
  connect();
@@ -2385,7 +2396,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2385
2396
  .md-preview table:not(.frontmatter-table table) {
2386
2397
  width: 100%;
2387
2398
  border-collapse: collapse;
2388
- table-layout: auto;
2399
+ table-layout: fixed;
2389
2400
  margin: 16px 0;
2390
2401
  border: 1px solid var(--border);
2391
2402
  border-radius: 8px;
@@ -2396,14 +2407,31 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2396
2407
  text-align: left;
2397
2408
  border-bottom: 1px solid var(--border);
2398
2409
  vertical-align: top;
2410
+ word-break: break-word;
2411
+ overflow-wrap: anywhere;
2412
+ }
2413
+ .md-preview table:not(.frontmatter-table table) th:first-child,
2414
+ .md-preview table:not(.frontmatter-table table) td:first-child {
2415
+ width: 30%;
2416
+ min-width: 200px;
2417
+ }
2418
+ .md-preview table:not(.frontmatter-table table) th:last-child,
2419
+ .md-preview table:not(.frontmatter-table table) td:last-child {
2420
+ width: 180px;
2421
+ min-width: 180px;
2422
+ max-width: 180px;
2399
2423
  }
2400
2424
  .md-preview table:not(.frontmatter-table table) td:has(video),
2401
2425
  .md-preview table:not(.frontmatter-table table) td:has(img) {
2402
2426
  padding: 4px;
2403
- min-width: 150px;
2404
- white-space: nowrap;
2405
2427
  line-height: 0;
2406
2428
  }
2429
+ .md-preview table:not(.frontmatter-table table) td video,
2430
+ .md-preview table:not(.frontmatter-table table) td img {
2431
+ width: 100%;
2432
+ max-width: 100%;
2433
+ height: auto;
2434
+ }
2407
2435
  .md-preview table:not(.frontmatter-table table) th {
2408
2436
  background: var(--panel);
2409
2437
  font-weight: 600;
@@ -3362,6 +3390,14 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3362
3390
  if (ev.data === 'reload') {
3363
3391
  location.reload();
3364
3392
  }
3393
+ if (ev.data === 'submitted') {
3394
+ // Another tab submitted - try to close this tab
3395
+ window.close();
3396
+ // If window.close() didn't work, show completion message
3397
+ setTimeout(() => {
3398
+ document.body.innerHTML = '<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;background:var(--bg,#1a1a2e);color:var(--text,#e0e0e0);font-family:system-ui,sans-serif;"><h1 style="font-size:2rem;margin-bottom:1rem;">✅ Review Submitted</h1><p style="color:var(--muted,#888);">Submitted from another tab. You can close this tab now.</p></div>';
3399
+ }, 100);
3400
+ }
3365
3401
  };
3366
3402
  es.onerror = () => {
3367
3403
  es.close();
@@ -5612,6 +5648,229 @@ function readBody(req) {
5612
5648
  const MAX_PORT_ATTEMPTS = 100;
5613
5649
  const activeServers = new Map();
5614
5650
 
5651
+ // --- Lock File Management (for detecting existing servers) ---
5652
+ const LOCK_DIR = path.join(os.homedir(), '.reviw', 'locks');
5653
+
5654
+ function getLockFilePath(filePath) {
5655
+ // Use SHA256 hash of absolute path to prevent path traversal attacks
5656
+ const hash = crypto.createHash('sha256').update(path.resolve(filePath)).digest('hex').slice(0, 16);
5657
+ return path.join(LOCK_DIR, hash + '.lock');
5658
+ }
5659
+
5660
+ function ensureLockDir() {
5661
+ try {
5662
+ if (!fs.existsSync(LOCK_DIR)) {
5663
+ fs.mkdirSync(LOCK_DIR, { recursive: true, mode: 0o700 });
5664
+ }
5665
+ } catch (err) {
5666
+ // Ignore errors - locks are optional optimization
5667
+ }
5668
+ }
5669
+
5670
+ function writeLockFile(filePath, port) {
5671
+ try {
5672
+ ensureLockDir();
5673
+ const lockPath = getLockFilePath(filePath);
5674
+ const lockData = {
5675
+ pid: process.pid,
5676
+ port: port,
5677
+ file: path.resolve(filePath),
5678
+ created: Date.now()
5679
+ };
5680
+ fs.writeFileSync(lockPath, JSON.stringify(lockData), { mode: 0o600 });
5681
+ } catch (err) {
5682
+ // Ignore errors - locks are optional
5683
+ }
5684
+ }
5685
+
5686
+ function removeLockFile(filePath) {
5687
+ try {
5688
+ const lockPath = getLockFilePath(filePath);
5689
+ if (fs.existsSync(lockPath)) {
5690
+ fs.unlinkSync(lockPath);
5691
+ }
5692
+ } catch (err) {
5693
+ // Ignore errors
5694
+ }
5695
+ }
5696
+
5697
+ function checkExistingServer(filePath) {
5698
+ try {
5699
+ const lockPath = getLockFilePath(filePath);
5700
+ if (!fs.existsSync(lockPath)) {
5701
+ return null;
5702
+ }
5703
+
5704
+ const lockData = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
5705
+
5706
+ // Verify the process is still alive
5707
+ try {
5708
+ process.kill(lockData.pid, 0); // Signal 0 just checks if process exists
5709
+ } catch (err) {
5710
+ // Process doesn't exist - stale lock
5711
+ fs.unlinkSync(lockPath);
5712
+ return null;
5713
+ }
5714
+
5715
+ // Verify the server is actually responding
5716
+ return new Promise((resolve) => {
5717
+ const req = http.request({
5718
+ hostname: 'localhost',
5719
+ port: lockData.port,
5720
+ path: '/healthz',
5721
+ method: 'GET',
5722
+ timeout: 1000
5723
+ }, (res) => {
5724
+ if (res.statusCode === 200) {
5725
+ resolve(lockData);
5726
+ } else {
5727
+ // Server not healthy - remove stale lock
5728
+ try { fs.unlinkSync(lockPath); } catch (e) {}
5729
+ resolve(null);
5730
+ }
5731
+ });
5732
+ req.on('error', () => {
5733
+ // Server not responding - remove stale lock
5734
+ try { fs.unlinkSync(lockPath); } catch (e) {}
5735
+ resolve(null);
5736
+ });
5737
+ req.on('timeout', () => {
5738
+ req.destroy();
5739
+ try { fs.unlinkSync(lockPath); } catch (e) {}
5740
+ resolve(null);
5741
+ });
5742
+ req.end();
5743
+ });
5744
+ } catch (err) {
5745
+ return null;
5746
+ }
5747
+ }
5748
+
5749
+ // Try to activate an existing browser tab with the given URL (macOS only)
5750
+ // Returns true if a tab was activated, false otherwise
5751
+ function tryActivateExistingTab(url) {
5752
+ if (process.platform !== "darwin") {
5753
+ return false;
5754
+ }
5755
+
5756
+ try {
5757
+ // AppleScript to find and activate Chrome tab with URL
5758
+ // Uses "found" variable instead of return (AppleScript doesn't allow return at top level)
5759
+ const chromeScript = [
5760
+ 'set found to false',
5761
+ 'tell application "System Events"',
5762
+ ' if exists process "Google Chrome" then',
5763
+ ' tell application "Google Chrome"',
5764
+ ' set targetUrl to "' + url + '"',
5765
+ ' repeat with w in windows',
5766
+ ' set tabIndex to 1',
5767
+ ' repeat with t in tabs of w',
5768
+ ' if URL of t starts with targetUrl then',
5769
+ ' set active tab index of w to tabIndex',
5770
+ ' set index of w to 1',
5771
+ ' activate',
5772
+ ' set found to true',
5773
+ ' exit repeat',
5774
+ ' end if',
5775
+ ' set tabIndex to tabIndex + 1',
5776
+ ' end repeat',
5777
+ ' if found then exit repeat',
5778
+ ' end repeat',
5779
+ ' end tell',
5780
+ ' end if',
5781
+ 'end tell',
5782
+ 'found'
5783
+ ].join('\n');
5784
+
5785
+ const chromeResult = spawnSync('osascript', ['-e', chromeScript], {
5786
+ encoding: "utf8",
5787
+ timeout: 3000
5788
+ });
5789
+
5790
+ if (chromeResult.stdout && chromeResult.stdout.trim() === "true") {
5791
+ console.log("Activated existing Chrome tab: " + url);
5792
+ return true;
5793
+ }
5794
+
5795
+ // Try Safari as fallback
5796
+ const safariScript = [
5797
+ 'set found to false',
5798
+ 'tell application "System Events"',
5799
+ ' if exists process "Safari" then',
5800
+ ' tell application "Safari"',
5801
+ ' set targetUrl to "' + url + '"',
5802
+ ' repeat with w in windows',
5803
+ ' repeat with t in tabs of w',
5804
+ ' if URL of t starts with targetUrl then',
5805
+ ' set current tab of w to t',
5806
+ ' set index of w to 1',
5807
+ ' activate',
5808
+ ' set found to true',
5809
+ ' exit repeat',
5810
+ ' end if',
5811
+ ' end repeat',
5812
+ ' if found then exit repeat',
5813
+ ' end repeat',
5814
+ ' end tell',
5815
+ ' end if',
5816
+ 'end tell',
5817
+ 'found'
5818
+ ].join('\n');
5819
+
5820
+ const safariResult = spawnSync('osascript', ['-e', safariScript], {
5821
+ encoding: "utf8",
5822
+ timeout: 3000
5823
+ });
5824
+
5825
+ if (safariResult.stdout && safariResult.stdout.trim() === "true") {
5826
+ console.log("Activated existing Safari tab: " + url);
5827
+ return true;
5828
+ }
5829
+
5830
+ return false;
5831
+ } catch (err) {
5832
+ // AppleScript failed (not macOS, Chrome/Safari not installed, etc.)
5833
+ return false;
5834
+ }
5835
+ }
5836
+
5837
+ // Open browser with the given URL, trying to reuse existing tab first (macOS)
5838
+ function openBrowser(url, delay = 0) {
5839
+ const opener =
5840
+ process.platform === "darwin"
5841
+ ? "open"
5842
+ : process.platform === "win32"
5843
+ ? "start"
5844
+ : "xdg-open";
5845
+
5846
+ setTimeout(function() {
5847
+ // On macOS, try to activate existing tab first
5848
+ if (process.platform === "darwin") {
5849
+ var activated = tryActivateExistingTab(url);
5850
+ if (activated) {
5851
+ return; // Successfully activated existing tab
5852
+ }
5853
+ // If activation failed, fall through to open new tab
5854
+ }
5855
+
5856
+ try {
5857
+ var child = spawn(opener, [url], { stdio: "ignore", detached: true });
5858
+ child.on('error', function(err) {
5859
+ console.warn(
5860
+ "Failed to open browser automatically. Please open this URL manually:",
5861
+ url
5862
+ );
5863
+ });
5864
+ child.unref();
5865
+ } catch (err) {
5866
+ console.warn(
5867
+ "Failed to open browser automatically. Please open this URL manually:",
5868
+ url
5869
+ );
5870
+ }
5871
+ }, delay);
5872
+ }
5873
+
5615
5874
  function outputAllResults() {
5616
5875
  console.log("=== All comments received ===");
5617
5876
  if (allResults.length === 1) {
@@ -5723,6 +5982,7 @@ function createFileServer(filePath, fileIndex = 0) {
5723
5982
  ctx.server = null;
5724
5983
  }
5725
5984
  activeServers.delete(filePath);
5985
+ removeLockFile(filePath); // Clean up lock file
5726
5986
  if (result) allResults.push(result);
5727
5987
  serversRunning--;
5728
5988
  console.log(`Server for ${baseName} closed. (${serversRunning} remaining)`);
@@ -5763,12 +6023,15 @@ function createFileServer(filePath, fileIndex = 0) {
5763
6023
  }
5764
6024
  res.writeHead(200, { "Content-Type": "text/plain" });
5765
6025
  res.end("bye");
5766
- shutdownServer(payload);
6026
+ // Notify all tabs to close before shutting down
6027
+ broadcast("submitted");
6028
+ setTimeout(() => shutdownServer(payload), 300);
5767
6029
  } catch (err) {
5768
6030
  console.error("payload parse error", err);
5769
6031
  res.writeHead(400, { "Content-Type": "text/plain" });
5770
6032
  res.end("bad request");
5771
- shutdownServer(null);
6033
+ broadcast("submitted");
6034
+ setTimeout(() => shutdownServer(null), 300);
5772
6035
  }
5773
6036
  return;
5774
6037
  }
@@ -5930,33 +6193,12 @@ function createFileServer(filePath, fileIndex = 0) {
5930
6193
  nextPort = attemptPort + 1;
5931
6194
  activeServers.set(filePath, ctx);
5932
6195
  console.log(`Viewer started: http://localhost:${attemptPort} (file: ${baseName})`);
6196
+ writeLockFile(filePath, attemptPort); // Write lock file for server detection
5933
6197
  if (!noOpen) {
5934
6198
  const url = `http://localhost:${attemptPort}`;
5935
- const opener =
5936
- process.platform === "darwin"
5937
- ? "open"
5938
- : process.platform === "win32"
5939
- ? "start"
5940
- : "xdg-open";
5941
6199
  // Add delay for multiple files to avoid browser ignoring rapid open commands
5942
6200
  const delay = fileIndex * 300;
5943
- setTimeout(() => {
5944
- try {
5945
- const child = spawn(opener, [url], { stdio: "ignore", detached: true });
5946
- child.on('error', (err) => {
5947
- console.warn(
5948
- "Failed to open browser automatically. Please open this URL manually:",
5949
- url,
5950
- );
5951
- });
5952
- child.unref();
5953
- } catch (err) {
5954
- console.warn(
5955
- "Failed to open browser automatically. Please open this URL manually:",
5956
- url,
5957
- );
5958
- }
5959
- }, delay);
6201
+ openBrowser(url, delay);
5960
6202
  }
5961
6203
  startWatcher();
5962
6204
  resolve(ctx);
@@ -6043,12 +6285,15 @@ function createDiffServer(diffContent) {
6043
6285
  }
6044
6286
  res.writeHead(200, { "Content-Type": "text/plain" });
6045
6287
  res.end("bye");
6046
- shutdownServer(payload);
6288
+ // Notify all tabs to close before shutting down
6289
+ broadcast("submitted");
6290
+ setTimeout(() => shutdownServer(payload), 300);
6047
6291
  } catch (err) {
6048
6292
  console.error("payload parse error", err);
6049
6293
  res.writeHead(400, { "Content-Type": "text/plain" });
6050
6294
  res.end("bad request");
6051
- shutdownServer(null);
6295
+ broadcast("submitted");
6296
+ setTimeout(() => shutdownServer(null), 300);
6052
6297
  }
6053
6298
  return;
6054
6299
  }
@@ -6110,27 +6355,7 @@ function createDiffServer(diffContent) {
6110
6355
  console.log(`Diff viewer started: http://localhost:${attemptPort}`);
6111
6356
  if (!noOpen) {
6112
6357
  const url = `http://localhost:${attemptPort}`;
6113
- const opener =
6114
- process.platform === "darwin"
6115
- ? "open"
6116
- : process.platform === "win32"
6117
- ? "start"
6118
- : "xdg-open";
6119
- try {
6120
- const child = spawn(opener, [url], { stdio: "ignore", detached: true });
6121
- child.on('error', (err) => {
6122
- console.warn(
6123
- "Failed to open browser automatically. Please open this URL manually:",
6124
- url,
6125
- );
6126
- });
6127
- child.unref();
6128
- } catch (err) {
6129
- console.warn(
6130
- "Failed to open browser automatically. Please open this URL manually:",
6131
- url,
6132
- );
6133
- }
6358
+ openBrowser(url, 0);
6134
6359
  }
6135
6360
  resolve(ctx);
6136
6361
  });
@@ -6181,10 +6406,32 @@ if (require.main === module) {
6181
6406
  }
6182
6407
  } else if (resolvedPaths.length > 0) {
6183
6408
  // File mode: files specified
6184
- console.log(`Starting servers for ${resolvedPaths.length} file(s)...`);
6185
- serversRunning = resolvedPaths.length;
6186
- for (let i = 0; i < resolvedPaths.length; i++) {
6187
- await createFileServer(resolvedPaths[i], i);
6409
+ // Check for existing servers first
6410
+ let filesToStart = [];
6411
+ for (const fp of resolvedPaths) {
6412
+ const existing = await checkExistingServer(fp);
6413
+ if (existing) {
6414
+ console.log(`Server already running for ${path.basename(fp)} on port ${existing.port}`);
6415
+ const url = `http://localhost:${existing.port}`;
6416
+ if (!noOpen) {
6417
+ openBrowser(url, 0);
6418
+ }
6419
+ } else {
6420
+ filesToStart.push(fp);
6421
+ }
6422
+ }
6423
+
6424
+ if (filesToStart.length === 0) {
6425
+ console.log("All files already have running servers. Activating existing tabs.");
6426
+ // Wait a moment for browser activation to complete before exiting
6427
+ setTimeout(() => process.exit(0), 500);
6428
+ return;
6429
+ }
6430
+
6431
+ console.log(`Starting servers for ${filesToStart.length} file(s)...`);
6432
+ serversRunning = filesToStart.length;
6433
+ for (let i = 0; i < filesToStart.length; i++) {
6434
+ await createFileServer(filesToStart[i], i);
6188
6435
  }
6189
6436
  console.log("Close all browser tabs or Submit & Exit to finish.");
6190
6437
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.15.3",
3
+ "version": "0.16.1",
4
4
  "description": "Lightweight file reviewer with in-browser comments for CSV, TSV, Markdown, and Git diffs.",
5
5
  "type": "module",
6
6
  "bin": {