reviw 0.10.0 → 0.10.2

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 (2) hide show
  1. package/cli.cjs +316 -3
  2. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -116,7 +116,7 @@ function runGitDiff() {
116
116
  });
117
117
  }
118
118
 
119
- // Validate all files exist (if files specified)
119
+ // Validate all files exist and are not directories (if files specified)
120
120
  const resolvedPaths = [];
121
121
  for (const fp of filePaths) {
122
122
  const resolved = path.resolve(fp);
@@ -124,6 +124,13 @@ for (const fp of filePaths) {
124
124
  console.error(`File not found: ${resolved}`);
125
125
  process.exit(1);
126
126
  }
127
+ const stat = fs.statSync(resolved);
128
+ if (stat.isDirectory()) {
129
+ console.error(`Cannot open directory: ${resolved}`);
130
+ console.error(`Usage: reviw <file> [file2...]`);
131
+ console.error(`Please specify a file, not a directory.`);
132
+ process.exit(1);
133
+ }
127
134
  resolvedPaths.push(resolved);
128
135
  }
129
136
 
@@ -477,6 +484,19 @@ function escapeHtmlChars(str) {
477
484
  }
478
485
 
479
486
  function loadData(filePath) {
487
+ // Check if path exists
488
+ if (!fs.existsSync(filePath)) {
489
+ throw new Error(`File not found: ${filePath}`);
490
+ }
491
+ // Check if path is a directory
492
+ const stat = fs.statSync(filePath);
493
+ if (stat.isDirectory()) {
494
+ throw new Error(
495
+ `Cannot open directory: ${filePath}\n` +
496
+ `Usage: reviw <file> [file2...]\n` +
497
+ `Please specify a file, not a directory.`
498
+ );
499
+ }
480
500
  const ext = path.extname(filePath).toLowerCase();
481
501
  if (ext === ".csv" || ext === ".tsv") {
482
502
  const data = loadCsv(filePath);
@@ -849,7 +869,7 @@ function diffHtmlTemplate(diffData) {
849
869
  #submit-modal.visible { display: flex; }
850
870
  #submit-modal .modal-dialog {
851
871
  pointer-events: auto;
852
- margin: 20px;
872
+ margin: 60px 20px 20px 20px; /* top margin avoids header button overlap */
853
873
  }
854
874
  .modal-dialog {
855
875
  background: var(--panel);
@@ -2122,7 +2142,7 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2122
2142
  #submit-modal.visible { display: flex; }
2123
2143
  #submit-modal .modal-dialog {
2124
2144
  pointer-events: auto;
2125
- margin: 20px;
2145
+ margin: 60px 20px 20px 20px; /* top margin avoids header button overlap */
2126
2146
  }
2127
2147
  .modal-dialog {
2128
2148
  background: var(--panel-solid);
@@ -3801,6 +3821,271 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3801
3821
  }
3802
3822
  });
3803
3823
  })();
3824
+
3825
+ // --- Preview Commenting ---
3826
+ (function initPreviewCommenting() {
3827
+ if (MODE !== 'markdown') return;
3828
+
3829
+ const preview = document.querySelector('.md-preview');
3830
+ if (!preview) return;
3831
+
3832
+ // Add visual hint for clickable elements
3833
+ const style = document.createElement('style');
3834
+ style.textContent = \`
3835
+ .md-preview > p:hover, .md-preview > h1:hover, .md-preview > h2:hover,
3836
+ .md-preview > h3:hover, .md-preview > h4:hover, .md-preview > h5:hover,
3837
+ .md-preview > h6:hover, .md-preview > ul > li:hover, .md-preview > ol > li:hover,
3838
+ .md-preview > pre:hover, .md-preview > blockquote:hover {
3839
+ background: rgba(99, 102, 241, 0.08);
3840
+ cursor: pointer;
3841
+ border-radius: 4px;
3842
+ }
3843
+ .md-preview img:hover {
3844
+ outline: 2px solid var(--accent);
3845
+ cursor: pointer;
3846
+ }
3847
+ \`;
3848
+ document.head.appendChild(style);
3849
+
3850
+ // Helper: strip markdown formatting from text
3851
+ function stripMarkdown(text) {
3852
+ return text
3853
+ .replace(/^[-*+]\\s+/, '') // List markers: - * +
3854
+ .replace(/^\\d+\\.\\s+/, '') // Numbered list: 1. 2.
3855
+ .replace(/\\*\\*([^*]+)\\*\\*/g, '$1') // Bold: **text**
3856
+ .replace(/\\*([^*]+)\\*/g, '$1') // Italic: *text*
3857
+ .replace(/__([^_]+)__/g, '$1') // Bold: __text__
3858
+ .replace(/_([^_]+)_/g, '$1') // Italic: _text_
3859
+ .replace(/\`([^\`]+)\`/g, '$1') // Inline code: \`code\`
3860
+ .replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1') // Links: [text](url)
3861
+ .trim();
3862
+ }
3863
+
3864
+ // Helper: find matching source line for text
3865
+ function findSourceLine(text) {
3866
+ if (!text) return -1;
3867
+ const normalized = text.trim().replace(/\\s+/g, ' ').slice(0, 100);
3868
+ if (!normalized) return -1;
3869
+
3870
+ for (let i = 0; i < DATA.length; i++) {
3871
+ const lineText = (DATA[i][0] || '').trim();
3872
+ if (!lineText) continue;
3873
+
3874
+ const lineNorm = lineText.replace(/\\s+/g, ' ').slice(0, 100);
3875
+ if (lineNorm === normalized) return i + 1;
3876
+ if (lineNorm.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
3877
+ if (normalized.includes(lineNorm.slice(0, 30)) && lineNorm.length > 5) return i + 1;
3878
+
3879
+ // Check for markdown headings: strip # from source and compare
3880
+ if (lineText.match(/^#+\\s/)) {
3881
+ const headingText = lineText.replace(/^#+\\s*/, '').trim();
3882
+ if (headingText === normalized || headingText.toLowerCase() === normalized.toLowerCase()) {
3883
+ return i + 1;
3884
+ }
3885
+ }
3886
+
3887
+ // Check for markdown list items: strip list markers and formatting
3888
+ if (lineText.match(/^[-*+]\\s|^\\d+\\.\\s/)) {
3889
+ const strippedLine = stripMarkdown(lineText).replace(/\\s+/g, ' ').slice(0, 100);
3890
+ if (strippedLine === normalized) return i + 1;
3891
+ if (strippedLine.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
3892
+ if (normalized.includes(strippedLine.slice(0, 30)) && strippedLine.length > 5) return i + 1;
3893
+ }
3894
+ }
3895
+ return -1;
3896
+ }
3897
+
3898
+ // Helper: find matching source line for table cell (prioritizes table rows)
3899
+ function findTableSourceLine(text) {
3900
+ if (!text) return -1;
3901
+ const normalized = text.trim().replace(/\\s+/g, ' ').slice(0, 100);
3902
+ if (!normalized) return -1;
3903
+
3904
+ // First pass: look for table rows (lines starting with |) containing the text
3905
+ for (let i = 0; i < DATA.length; i++) {
3906
+ const lineText = (DATA[i][0] || '').trim();
3907
+ if (!lineText || !lineText.startsWith('|')) continue;
3908
+
3909
+ const lineNorm = lineText.replace(/\\s+/g, ' ').slice(0, 100);
3910
+ if (lineNorm.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
3911
+ }
3912
+
3913
+ // Fallback to normal search
3914
+ return findSourceLine(text);
3915
+ }
3916
+
3917
+ // Helper: find code block range in source (fenced code blocks)
3918
+ function findCodeBlockRange(codeText) {
3919
+ const clickedLines = codeText.split('\\n').map(l => l.trim()).filter(l => l);
3920
+ const clickedContent = clickedLines.join('\\n');
3921
+
3922
+ // Extract all code blocks from DATA
3923
+ const codeBlocks = [];
3924
+ let currentBlock = null;
3925
+
3926
+ for (let i = 0; i < DATA.length; i++) {
3927
+ const lineText = (DATA[i][0] || '').trim();
3928
+
3929
+ if (lineText.startsWith('\`\`\`') && !currentBlock) {
3930
+ // Start of a code block
3931
+ currentBlock = { startLine: i + 1, lines: [] };
3932
+ } else if (lineText === '\`\`\`' && currentBlock) {
3933
+ // End of a code block
3934
+ currentBlock.endLine = i + 1;
3935
+ currentBlock.content = currentBlock.lines.map(l => l.trim()).filter(l => l).join('\\n');
3936
+ codeBlocks.push(currentBlock);
3937
+ currentBlock = null;
3938
+ } else if (currentBlock) {
3939
+ // Inside a code block
3940
+ currentBlock.lines.push(DATA[i][0] || '');
3941
+ }
3942
+ }
3943
+
3944
+ // Find the best matching code block by content similarity
3945
+ let bestMatch = null;
3946
+ let bestScore = 0;
3947
+
3948
+ for (const block of codeBlocks) {
3949
+ // Calculate similarity score
3950
+ let score = 0;
3951
+
3952
+ // Exact match
3953
+ if (block.content === clickedContent) {
3954
+ score = 1000;
3955
+ } else {
3956
+ // Check line-by-line matches
3957
+ const blockLines = block.content.split('\\n');
3958
+ for (const clickedLine of clickedLines) {
3959
+ if (clickedLine.length > 3) {
3960
+ for (const blockLine of blockLines) {
3961
+ if (blockLine.includes(clickedLine) || clickedLine.includes(blockLine)) {
3962
+ score += clickedLine.length;
3963
+ }
3964
+ }
3965
+ }
3966
+ }
3967
+ }
3968
+
3969
+ if (score > bestScore) {
3970
+ bestScore = score;
3971
+ bestMatch = block;
3972
+ }
3973
+ }
3974
+
3975
+ if (bestMatch) {
3976
+ return { startLine: bestMatch.startLine, endLine: bestMatch.endLine };
3977
+ }
3978
+
3979
+ // Fallback: find by first line content matching
3980
+ const firstCodeLine = clickedLines[0];
3981
+ if (firstCodeLine && firstCodeLine.length > 3) {
3982
+ for (let i = 0; i < DATA.length; i++) {
3983
+ const lineText = (DATA[i][0] || '').trim();
3984
+ if (lineText.includes(firstCodeLine.slice(0, 30))) {
3985
+ return { startLine: i + 1, endLine: i + 1 };
3986
+ }
3987
+ }
3988
+ }
3989
+
3990
+ return { startLine: -1, endLine: -1 };
3991
+ }
3992
+
3993
+ // Helper: find source line for image by src
3994
+ function findImageSourceLine(src) {
3995
+ if (!src) return -1;
3996
+ const filename = src.split('/').pop().split('?')[0];
3997
+ for (let i = 0; i < DATA.length; i++) {
3998
+ const lineText = DATA[i][0] || '';
3999
+ if (lineText.includes(filename) || lineText.includes(src)) {
4000
+ return i + 1;
4001
+ }
4002
+ }
4003
+ return -1;
4004
+ }
4005
+
4006
+ // Trigger source cell selection (reuse existing comment flow)
4007
+ function selectSourceRange(startRow, endRow) {
4008
+ selection = { startRow, endRow: endRow || startRow, startCol: 1, endCol: 1 };
4009
+ updateSelectionVisual();
4010
+
4011
+ // Clear header selection
4012
+ document.querySelectorAll('thead th.selected').forEach(el => el.classList.remove('selected'));
4013
+
4014
+ // Scroll source table to show the selected row, then open card
4015
+ const sourceTd = tbody.querySelector('td[data-row="' + startRow + '"][data-col="1"]');
4016
+ if (sourceTd) {
4017
+ sourceTd.scrollIntoView({ behavior: 'smooth', block: 'center' });
4018
+ // Wait for scroll to complete before positioning card
4019
+ setTimeout(() => openCardForSelection(), 350);
4020
+ } else {
4021
+ openCardForSelection();
4022
+ }
4023
+ }
4024
+
4025
+ // Click on block elements
4026
+ preview.addEventListener('click', (e) => {
4027
+ // Handle image clicks
4028
+ if (e.target.tagName === 'IMG') {
4029
+ if (!e.defaultPrevented) {
4030
+ const line = findImageSourceLine(e.target.src);
4031
+ if (line > 0) {
4032
+ e.preventDefault();
4033
+ e.stopPropagation();
4034
+ selectSourceRange(line);
4035
+ }
4036
+ }
4037
+ return;
4038
+ }
4039
+
4040
+ // Ignore clicks on links, mermaid, video overlay
4041
+ if (e.target.closest('a, .mermaid-container, .video-fullscreen-overlay')) return;
4042
+
4043
+ // Handle code blocks - select entire block
4044
+ const pre = e.target.closest('pre');
4045
+ if (pre) {
4046
+ const code = pre.querySelector('code') || pre;
4047
+ const { startLine, endLine } = findCodeBlockRange(code.textContent);
4048
+ if (startLine > 0) {
4049
+ e.preventDefault();
4050
+ selectSourceRange(startLine, endLine);
4051
+ }
4052
+ return;
4053
+ }
4054
+
4055
+ const target = e.target.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
4056
+ if (!target) return;
4057
+
4058
+ // Use table-specific search for table cells
4059
+ const isTableCell = target.tagName === 'TD' || target.tagName === 'TH';
4060
+ const line = isTableCell ? findTableSourceLine(target.textContent) : findSourceLine(target.textContent);
4061
+ if (line <= 0) return;
4062
+
4063
+ e.preventDefault();
4064
+ selectSourceRange(line);
4065
+ });
4066
+
4067
+ // Text selection to open comment for range
4068
+ preview.addEventListener('mouseup', (e) => {
4069
+ setTimeout(() => {
4070
+ const sel = window.getSelection();
4071
+ if (!sel || sel.isCollapsed) return;
4072
+
4073
+ const text = sel.toString().trim();
4074
+ if (!text || text.length < 5) return;
4075
+
4076
+ const lines = text.split('\\n').filter(l => l.trim());
4077
+ if (lines.length === 0) return;
4078
+
4079
+ const startLine = findSourceLine(lines[0]);
4080
+ const endLine = lines.length > 1 ? findSourceLine(lines[lines.length - 1]) : startLine;
4081
+
4082
+ if (startLine <= 0) return;
4083
+
4084
+ sel.removeAllRanges();
4085
+ selectSourceRange(startLine, endLine > 0 ? endLine : startLine);
4086
+ }, 10);
4087
+ });
4088
+ })();
3804
4089
  </script>
3805
4090
  </body>
3806
4091
  </html>`;
@@ -4039,7 +4324,14 @@ function createFileServer(filePath) {
4039
4324
  res.end("not found");
4040
4325
  });
4041
4326
 
4327
+ let serverStarted = false;
4328
+
4042
4329
  function tryListen(attemptPort, attempts = 0) {
4330
+ if (serverStarted) return; // Prevent double-start race condition
4331
+ // Clear previous listeners from failed attempts to avoid duplicate
4332
+ // "listening" callbacks firing after a later successful bind.
4333
+ ctx.server.removeAllListeners("error");
4334
+ ctx.server.removeAllListeners("listening");
4043
4335
  if (attempts >= MAX_PORT_ATTEMPTS) {
4044
4336
  console.error(
4045
4337
  `Could not find an available port for ${baseName} after ${MAX_PORT_ATTEMPTS} attempts.`,
@@ -4050,6 +4342,7 @@ function createFileServer(filePath) {
4050
4342
  }
4051
4343
 
4052
4344
  ctx.server.once("error", (err) => {
4345
+ if (serverStarted) return; // Already started on another port
4053
4346
  if (err.code === "EADDRINUSE") {
4054
4347
  tryListen(attemptPort + 1, attempts + 1);
4055
4348
  } else {
@@ -4060,6 +4353,12 @@ function createFileServer(filePath) {
4060
4353
  });
4061
4354
 
4062
4355
  ctx.server.listen(attemptPort, () => {
4356
+ if (serverStarted) {
4357
+ // Race condition: server started on multiple ports, close this one
4358
+ try { ctx.server.close(); } catch (_) {}
4359
+ return;
4360
+ }
4361
+ serverStarted = true;
4063
4362
  ctx.port = attemptPort;
4064
4363
  nextPort = attemptPort + 1;
4065
4364
  activeServers.set(filePath, ctx);
@@ -4193,7 +4492,14 @@ function createDiffServer(diffContent) {
4193
4492
  res.end("not found");
4194
4493
  });
4195
4494
 
4495
+ let serverStarted = false;
4496
+
4196
4497
  function tryListen(attemptPort, attempts = 0) {
4498
+ if (serverStarted) return; // Prevent double-start race condition
4499
+ // Clear listeners from previous failed attempts to prevent stale
4500
+ // "listening" handlers from firing on the successful bind.
4501
+ ctx.server.removeAllListeners("error");
4502
+ ctx.server.removeAllListeners("listening");
4197
4503
  if (attempts >= MAX_PORT_ATTEMPTS) {
4198
4504
  console.error(
4199
4505
  `Could not find an available port for diff viewer after ${MAX_PORT_ATTEMPTS} attempts.`,
@@ -4204,6 +4510,7 @@ function createDiffServer(diffContent) {
4204
4510
  }
4205
4511
 
4206
4512
  ctx.server.once("error", (err) => {
4513
+ if (serverStarted) return; // Already started on another port
4207
4514
  if (err.code === "EADDRINUSE") {
4208
4515
  tryListen(attemptPort + 1, attempts + 1);
4209
4516
  } else {
@@ -4214,6 +4521,12 @@ function createDiffServer(diffContent) {
4214
4521
  });
4215
4522
 
4216
4523
  ctx.server.listen(attemptPort, () => {
4524
+ if (serverStarted) {
4525
+ // Race condition: server started on multiple ports, close this one
4526
+ try { ctx.server.close(); } catch (_) {}
4527
+ return;
4528
+ }
4529
+ serverStarted = true;
4217
4530
  ctx.port = attemptPort;
4218
4531
  ctx.heartbeat = setInterval(() => broadcast("ping"), 25000);
4219
4532
  console.log(`Diff viewer started: http://localhost:${attemptPort}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Lightweight file reviewer with in-browser comments for CSV, TSV, Markdown, and Git diffs.",
5
5
  "type": "module",
6
6
  "bin": {