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.
- package/cli.cjs +316 -3
- 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}`);
|