reviw 0.6.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/cli.cjs +3399 -0
  4. package/package.json +35 -0
package/cli.cjs ADDED
@@ -0,0 +1,3399 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lightweight CSV/Text/Markdown viewer with comment collection server
4
+ *
5
+ * Usage:
6
+ * reviw <file...> [--port 3000] [--encoding utf8|shift_jis|...] [--no-open]
7
+ *
8
+ * Multiple files can be specified. Each file opens on a separate port.
9
+ * Click cells in the browser to add comments.
10
+ * Close the tab or click "Submit & Exit" to send comments to the server.
11
+ * When all files are closed, outputs combined YAML to stdout and exits.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const http = require('http');
16
+ const path = require('path');
17
+ const { spawn } = require('child_process');
18
+ const chardet = require('chardet');
19
+ const iconv = require('iconv-lite');
20
+ const marked = require('marked');
21
+ const yaml = require('js-yaml');
22
+
23
+ // --- CLI arguments ---------------------------------------------------------
24
+ const args = process.argv.slice(2);
25
+
26
+ const filePaths = [];
27
+ let basePort = 3000;
28
+ let encodingOpt = null;
29
+ let noOpen = false;
30
+ let stdinMode = false;
31
+ let diffMode = false;
32
+ let stdinContent = null;
33
+
34
+ for (let i = 0; i < args.length; i += 1) {
35
+ const arg = args[i];
36
+ if (arg === '--port' && args[i + 1]) {
37
+ basePort = Number(args[i + 1]);
38
+ i += 1;
39
+ } else if ((arg === '--encoding' || arg === '-e') && args[i + 1]) {
40
+ encodingOpt = args[i + 1];
41
+ i += 1;
42
+ } else if (arg === '--no-open') {
43
+ noOpen = true;
44
+ } else if (arg === '--help' || arg === '-h') {
45
+ console.log(`Usage: reviw <file...> [options]
46
+ git diff | reviw [options]
47
+ reviw [options] (auto runs git diff HEAD)
48
+
49
+ Options:
50
+ --port <number> Server port (default: 3000)
51
+ --encoding <enc> Force encoding (utf8, shift_jis, etc.)
52
+ --no-open Don't open browser automatically
53
+ --help, -h Show this help message
54
+
55
+ Examples:
56
+ reviw data.csv # View CSV file
57
+ reviw README.md # View Markdown file
58
+ git diff | reviw # Review diff from stdin
59
+ git diff HEAD~3 | reviw # Review diff from last 3 commits
60
+ reviw # Auto run git diff HEAD`);
61
+ process.exit(0);
62
+ } else if (!arg.startsWith('-')) {
63
+ filePaths.push(arg);
64
+ }
65
+ }
66
+
67
+ // Check if stdin has data (pipe mode)
68
+ async function checkStdin() {
69
+ return new Promise((resolve) => {
70
+ if (process.stdin.isTTY) {
71
+ resolve(false);
72
+ return;
73
+ }
74
+ let data = '';
75
+ let resolved = false;
76
+ const timeout = setTimeout(() => {
77
+ if (!resolved) {
78
+ resolved = true;
79
+ resolve(data.length > 0 ? data : false);
80
+ }
81
+ }, 100);
82
+ process.stdin.setEncoding('utf8');
83
+ process.stdin.on('data', (chunk) => {
84
+ data += chunk;
85
+ });
86
+ process.stdin.on('end', () => {
87
+ clearTimeout(timeout);
88
+ if (!resolved) {
89
+ resolved = true;
90
+ resolve(data.length > 0 ? data : false);
91
+ }
92
+ });
93
+ process.stdin.on('error', () => {
94
+ clearTimeout(timeout);
95
+ if (!resolved) {
96
+ resolved = true;
97
+ resolve(false);
98
+ }
99
+ });
100
+ });
101
+ }
102
+
103
+ // Run git diff HEAD if no files and no stdin
104
+ function runGitDiff() {
105
+ return new Promise((resolve, reject) => {
106
+ const { execSync } = require('child_process');
107
+ try {
108
+ // Check if we're in a git repo
109
+ execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
110
+ // Run git diff HEAD
111
+ const diff = execSync('git diff HEAD', { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
112
+ resolve(diff);
113
+ } catch (err) {
114
+ reject(new Error('Not a git repository or git command failed'));
115
+ }
116
+ });
117
+ }
118
+
119
+ // Validate all files exist (if files specified)
120
+ const resolvedPaths = [];
121
+ for (const fp of filePaths) {
122
+ const resolved = path.resolve(fp);
123
+ if (!fs.existsSync(resolved)) {
124
+ console.error(`File not found: ${resolved}`);
125
+ process.exit(1);
126
+ }
127
+ resolvedPaths.push(resolved);
128
+ }
129
+
130
+ // --- Diff parsing -----------------------------------------------------------
131
+ function parseDiff(diffText) {
132
+ const files = [];
133
+ const lines = diffText.split('\n');
134
+ let currentFile = null;
135
+ let lineNumber = 0;
136
+
137
+ for (let i = 0; i < lines.length; i++) {
138
+ const line = lines[i];
139
+
140
+ // New file header
141
+ if (line.startsWith('diff --git')) {
142
+ if (currentFile) files.push(currentFile);
143
+ const match = line.match(/diff --git a\/(.+?) b\/(.+)/);
144
+ currentFile = {
145
+ oldPath: match ? match[1] : '',
146
+ newPath: match ? match[2] : '',
147
+ hunks: [],
148
+ isNew: false,
149
+ isDeleted: false,
150
+ isBinary: false
151
+ };
152
+ lineNumber = 0;
153
+ continue;
154
+ }
155
+
156
+ if (!currentFile) continue;
157
+
158
+ // File mode info
159
+ if (line.startsWith('new file mode')) {
160
+ currentFile.isNew = true;
161
+ continue;
162
+ }
163
+ if (line.startsWith('deleted file mode')) {
164
+ currentFile.isDeleted = true;
165
+ continue;
166
+ }
167
+ if (line.startsWith('Binary files')) {
168
+ currentFile.isBinary = true;
169
+ continue;
170
+ }
171
+
172
+ // Hunk header
173
+ if (line.startsWith('@@')) {
174
+ const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)/);
175
+ if (match) {
176
+ currentFile.hunks.push({
177
+ oldStart: parseInt(match[1], 10),
178
+ newStart: parseInt(match[2], 10),
179
+ context: match[3] || '',
180
+ lines: []
181
+ });
182
+ }
183
+ continue;
184
+ }
185
+
186
+ // Skip other headers
187
+ if (line.startsWith('---') || line.startsWith('+++') || line.startsWith('index ')) {
188
+ continue;
189
+ }
190
+
191
+ // Diff content
192
+ if (currentFile.hunks.length > 0) {
193
+ const hunk = currentFile.hunks[currentFile.hunks.length - 1];
194
+ if (line.startsWith('+')) {
195
+ hunk.lines.push({ type: 'add', content: line.slice(1), lineNum: ++lineNumber });
196
+ } else if (line.startsWith('-')) {
197
+ hunk.lines.push({ type: 'del', content: line.slice(1), lineNum: ++lineNumber });
198
+ } else if (line.startsWith(' ') || line === '') {
199
+ hunk.lines.push({ type: 'ctx', content: line.slice(1) || '', lineNum: ++lineNumber });
200
+ }
201
+ }
202
+ }
203
+
204
+ if (currentFile) files.push(currentFile);
205
+ return files;
206
+ }
207
+
208
+ function loadDiff(diffText) {
209
+ const files = parseDiff(diffText);
210
+
211
+ // Sort files: non-binary first, binary last
212
+ const sortedFiles = [...files].sort((a, b) => {
213
+ if (a.isBinary && !b.isBinary) return 1;
214
+ if (!a.isBinary && b.isBinary) return -1;
215
+ return 0;
216
+ });
217
+
218
+ // Calculate line count for each file
219
+ const COLLAPSE_THRESHOLD = 50;
220
+ sortedFiles.forEach((file) => {
221
+ let lineCount = 0;
222
+ if (!file.isBinary) {
223
+ file.hunks.forEach((hunk) => {
224
+ lineCount += hunk.lines.length;
225
+ });
226
+ }
227
+ file.lineCount = lineCount;
228
+ file.collapsed = lineCount > COLLAPSE_THRESHOLD;
229
+ });
230
+
231
+ // Convert to rows for display
232
+ const rows = [];
233
+ let rowIndex = 0;
234
+
235
+ sortedFiles.forEach((file, fileIdx) => {
236
+ // File header row
237
+ let label = file.newPath || file.oldPath;
238
+ if (file.isNew) label += ' (new)';
239
+ if (file.isDeleted) label += ' (deleted)';
240
+ if (file.isBinary) label += ' (binary)';
241
+ rows.push({
242
+ type: 'file',
243
+ content: label,
244
+ filePath: file.newPath || file.oldPath,
245
+ fileIndex: fileIdx,
246
+ lineCount: file.lineCount,
247
+ collapsed: file.collapsed,
248
+ isBinary: file.isBinary,
249
+ rowIndex: rowIndex++
250
+ });
251
+
252
+ if (file.isBinary) return;
253
+
254
+ file.hunks.forEach((hunk) => {
255
+ // Hunk header
256
+ rows.push({
257
+ type: 'hunk',
258
+ content: `@@ -${hunk.oldStart} +${hunk.newStart} @@${hunk.context}`,
259
+ fileIndex: fileIdx,
260
+ rowIndex: rowIndex++
261
+ });
262
+
263
+ hunk.lines.forEach((line) => {
264
+ rows.push({
265
+ type: line.type,
266
+ content: line.content,
267
+ fileIndex: fileIdx,
268
+ rowIndex: rowIndex++
269
+ });
270
+ });
271
+ });
272
+ });
273
+
274
+ return {
275
+ rows,
276
+ files: sortedFiles,
277
+ title: 'Git Diff',
278
+ mode: 'diff'
279
+ };
280
+ }
281
+
282
+ // --- Multi-file state management -------------------------------------------
283
+ const allResults = [];
284
+ let serversRunning = 0;
285
+ let nextPort = basePort;
286
+
287
+ // --- Simple CSV/TSV parser (RFC4180-style, handles " escaping and newlines) ----
288
+ function parseCsv(text, separator = ',') {
289
+ const rows = [];
290
+ let row = [];
291
+ let field = '';
292
+ let inQuotes = false;
293
+
294
+ for (let i = 0; i < text.length; i += 1) {
295
+ const ch = text[i];
296
+
297
+ if (inQuotes) {
298
+ if (ch === '"') {
299
+ if (text[i + 1] === '"') {
300
+ field += '"';
301
+ i += 1;
302
+ } else {
303
+ inQuotes = false;
304
+ }
305
+ } else {
306
+ field += ch;
307
+ }
308
+ } else if (ch === '"') {
309
+ inQuotes = true;
310
+ } else if (ch === separator) {
311
+ row.push(field);
312
+ field = '';
313
+ } else if (ch === '\n') {
314
+ row.push(field);
315
+ rows.push(row);
316
+ row = [];
317
+ field = '';
318
+ } else if (ch === '\r') {
319
+ // Ignore CR (for CRLF handling)
320
+ } else {
321
+ field += ch;
322
+ }
323
+ }
324
+
325
+ row.push(field);
326
+ rows.push(row);
327
+
328
+ // Remove trailing empty row if present
329
+ const last = rows[rows.length - 1];
330
+ if (last && last.every((v) => v === '')) {
331
+ rows.pop();
332
+ }
333
+
334
+ return rows;
335
+ }
336
+
337
+ const ENCODING_MAP = {
338
+ 'utf-8': 'utf8',
339
+ utf8: 'utf8',
340
+ 'shift_jis': 'shift_jis',
341
+ sjis: 'shift_jis',
342
+ 'windows-31j': 'cp932',
343
+ cp932: 'cp932',
344
+ 'euc-jp': 'euc-jp',
345
+ 'iso-8859-1': 'latin1',
346
+ latin1: 'latin1'
347
+ };
348
+
349
+ function normalizeEncoding(name) {
350
+ if (!name) return null;
351
+ const key = String(name).toLowerCase();
352
+ return ENCODING_MAP[key] || null;
353
+ }
354
+
355
+ function decodeBuffer(buf) {
356
+ const specified = normalizeEncoding(encodingOpt);
357
+ let encoding = specified;
358
+ if (!encoding) {
359
+ const detected = chardet.detect(buf) || '';
360
+ encoding = normalizeEncoding(detected) || 'utf8';
361
+ if (encoding !== 'utf8') {
362
+ console.log(`Detected encoding: ${detected} -> ${encoding}`);
363
+ }
364
+ }
365
+ try {
366
+ return iconv.decode(buf, encoding);
367
+ } catch (err) {
368
+ console.warn(`Decode failed (${encoding}): ${err.message}, falling back to utf8`);
369
+ return buf.toString('utf8');
370
+ }
371
+ }
372
+
373
+ function loadCsv(filePath) {
374
+ const raw = fs.readFileSync(filePath);
375
+ const csvText = decodeBuffer(raw);
376
+ const ext = path.extname(filePath).toLowerCase();
377
+ const separator = ext === '.tsv' ? '\t' : ',';
378
+ if (!csvText.includes('\n') && !csvText.includes(separator)) {
379
+ // heuristic: if no newline/separators, still treat as single row
380
+ }
381
+ const rows = parseCsv(csvText, separator);
382
+ const maxCols = rows.reduce((m, r) => Math.max(m, r.length), 0);
383
+ return {
384
+ rows,
385
+ cols: Math.max(1, maxCols),
386
+ title: path.basename(filePath)
387
+ };
388
+ }
389
+
390
+ function loadText(filePath) {
391
+ const raw = fs.readFileSync(filePath);
392
+ const text = decodeBuffer(raw);
393
+ const lines = text.split(/\r?\n/);
394
+ return {
395
+ rows: lines.map((line) => [line]),
396
+ cols: 1,
397
+ title: path.basename(filePath),
398
+ preview: null
399
+ };
400
+ }
401
+
402
+ function loadMarkdown(filePath) {
403
+ const raw = fs.readFileSync(filePath);
404
+ const text = decodeBuffer(raw);
405
+ const lines = text.split(/\r?\n/);
406
+ const preview = marked.parse(text, { breaks: true });
407
+ return {
408
+ rows: lines.map((line) => [line]),
409
+ cols: 1,
410
+ title: path.basename(filePath),
411
+ preview
412
+ };
413
+ }
414
+
415
+ function loadData(filePath) {
416
+ const ext = path.extname(filePath).toLowerCase();
417
+ if (ext === '.csv' || ext === '.tsv') {
418
+ const data = loadCsv(filePath);
419
+ return { ...data, mode: 'csv' };
420
+ }
421
+ if (ext === '.md' || ext === '.markdown') {
422
+ const data = loadMarkdown(filePath);
423
+ return { ...data, mode: 'markdown' };
424
+ }
425
+ if (ext === '.diff' || ext === '.patch') {
426
+ const content = fs.readFileSync(filePath, 'utf8');
427
+ const data = loadDiff(content);
428
+ return { ...data, mode: 'diff' };
429
+ }
430
+ // default text
431
+ const data = loadText(filePath);
432
+ return { ...data, mode: 'text' };
433
+ }
434
+
435
+ // --- Safe JSON serialization for inline scripts ---------------------------
436
+ // Prevents </script> breakage and template literal injection while keeping
437
+ // the original values intact once parsed by JS.
438
+ function serializeForScript(value) {
439
+ return JSON.stringify(value)
440
+ .replace(/</g, '\\u003c') // avoid closing the script tag
441
+ .replace(/>/g, '\\u003e')
442
+ .replace(/\u2028/g, '\\u2028') // line separator
443
+ .replace(/\u2029/g, '\\u2029') // paragraph separator
444
+ .replace(/`/g, '\\`') // keep template literal boundaries safe
445
+ .replace(/\$\{/g, '\\${');
446
+ }
447
+
448
+ function diffHtmlTemplate(diffData) {
449
+ const { rows, title } = diffData;
450
+ const serialized = serializeForScript(rows);
451
+ const fileCount = rows.filter(r => r.type === 'file').length;
452
+
453
+ return `<!doctype html>
454
+ <html lang="ja">
455
+ <head>
456
+ <meta charset="utf-8" />
457
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
458
+ <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
459
+ <meta http-equiv="Pragma" content="no-cache" />
460
+ <meta http-equiv="Expires" content="0" />
461
+ <title>${title} | reviw</title>
462
+ <style>
463
+ :root {
464
+ color-scheme: dark;
465
+ --bg: #0d1117;
466
+ --bg-gradient: linear-gradient(135deg, #0d1117 0%, #161b22 100%);
467
+ --panel: #161b22;
468
+ --panel-alpha: rgba(22, 27, 34, 0.95);
469
+ --border: #30363d;
470
+ --accent: #58a6ff;
471
+ --text: #c9d1d9;
472
+ --text-inverse: #0d1117;
473
+ --muted: #8b949e;
474
+ --add-bg: rgba(35, 134, 54, 0.15);
475
+ --add-line: rgba(35, 134, 54, 0.4);
476
+ --add-text: #3fb950;
477
+ --del-bg: rgba(248, 81, 73, 0.15);
478
+ --del-line: rgba(248, 81, 73, 0.4);
479
+ --del-text: #f85149;
480
+ --hunk-bg: rgba(56, 139, 253, 0.15);
481
+ --file-bg: #161b22;
482
+ --selected-bg: rgba(88, 166, 255, 0.15);
483
+ --shadow-color: rgba(0,0,0,0.4);
484
+ }
485
+ [data-theme="light"] {
486
+ color-scheme: light;
487
+ --bg: #ffffff;
488
+ --bg-gradient: linear-gradient(135deg, #ffffff 0%, #f6f8fa 100%);
489
+ --panel: #f6f8fa;
490
+ --panel-alpha: rgba(246, 248, 250, 0.95);
491
+ --border: #d0d7de;
492
+ --accent: #0969da;
493
+ --text: #24292f;
494
+ --text-inverse: #ffffff;
495
+ --muted: #57606a;
496
+ --add-bg: rgba(35, 134, 54, 0.1);
497
+ --add-line: rgba(35, 134, 54, 0.3);
498
+ --add-text: #1a7f37;
499
+ --del-bg: rgba(248, 81, 73, 0.1);
500
+ --del-line: rgba(248, 81, 73, 0.3);
501
+ --del-text: #cf222e;
502
+ --hunk-bg: rgba(56, 139, 253, 0.1);
503
+ --file-bg: #f6f8fa;
504
+ --selected-bg: rgba(9, 105, 218, 0.1);
505
+ --shadow-color: rgba(0,0,0,0.1);
506
+ }
507
+ * { box-sizing: border-box; }
508
+ body {
509
+ margin: 0;
510
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
511
+ background: var(--bg-gradient);
512
+ color: var(--text);
513
+ min-height: 100vh;
514
+ }
515
+ header {
516
+ position: sticky;
517
+ top: 0;
518
+ z-index: 10;
519
+ padding: 12px 20px;
520
+ background: var(--panel-alpha);
521
+ backdrop-filter: blur(8px);
522
+ border-bottom: 1px solid var(--border);
523
+ display: flex;
524
+ gap: 16px;
525
+ align-items: center;
526
+ justify-content: space-between;
527
+ }
528
+ header .meta { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
529
+ header .actions { display: flex; gap: 8px; align-items: center; }
530
+ header h1 { font-size: 16px; margin: 0; font-weight: 600; }
531
+ header .badge {
532
+ background: var(--selected-bg);
533
+ color: var(--text);
534
+ padding: 6px 10px;
535
+ border-radius: 6px;
536
+ font-size: 12px;
537
+ border: 1px solid var(--border);
538
+ }
539
+ header button {
540
+ background: linear-gradient(135deg, #238636, #2ea043);
541
+ color: #fff;
542
+ border: none;
543
+ border-radius: 6px;
544
+ padding: 8px 14px;
545
+ font-weight: 600;
546
+ cursor: pointer;
547
+ font-size: 13px;
548
+ }
549
+ header button:hover { opacity: 0.9; }
550
+ .theme-toggle {
551
+ background: var(--selected-bg);
552
+ color: var(--text);
553
+ border: 1px solid var(--border);
554
+ border-radius: 6px;
555
+ padding: 6px 8px;
556
+ font-size: 14px;
557
+ cursor: pointer;
558
+ width: 34px;
559
+ height: 34px;
560
+ display: flex;
561
+ align-items: center;
562
+ justify-content: center;
563
+ }
564
+ .theme-toggle:hover { background: var(--border); }
565
+
566
+ .wrap { padding: 16px 20px 60px; max-width: 1200px; margin: 0 auto; }
567
+ .diff-container {
568
+ background: var(--panel);
569
+ border: 1px solid var(--border);
570
+ border-radius: 8px;
571
+ overflow: hidden;
572
+ }
573
+ .diff-line {
574
+ display: flex;
575
+ font-size: 12px;
576
+ line-height: 20px;
577
+ border-bottom: 1px solid var(--border);
578
+ cursor: pointer;
579
+ transition: background 80ms ease;
580
+ }
581
+ .diff-line:last-child { border-bottom: none; }
582
+ .diff-line:hover { filter: brightness(1.05); }
583
+ .diff-line.selected { background: var(--selected-bg) !important; box-shadow: inset 3px 0 0 var(--accent); }
584
+ .diff-line.file-header {
585
+ background: var(--file-bg);
586
+ font-weight: 600;
587
+ padding: 10px 12px;
588
+ font-size: 13px;
589
+ color: var(--text);
590
+ border-bottom: 1px solid var(--border);
591
+ cursor: default;
592
+ display: flex;
593
+ align-items: center;
594
+ justify-content: space-between;
595
+ }
596
+ .file-header-left { display: flex; align-items: center; gap: 8px; }
597
+ .file-header-info {
598
+ font-size: 11px;
599
+ color: var(--muted);
600
+ font-weight: 400;
601
+ }
602
+ .toggle-btn {
603
+ background: var(--selected-bg);
604
+ border: 1px solid var(--border);
605
+ border-radius: 4px;
606
+ padding: 4px 10px;
607
+ font-size: 11px;
608
+ cursor: pointer;
609
+ color: var(--text);
610
+ }
611
+ .toggle-btn:hover { background: var(--border); }
612
+ .hidden-lines { display: none; }
613
+ .load-more-row {
614
+ display: flex;
615
+ align-items: center;
616
+ justify-content: center;
617
+ padding: 8px 12px;
618
+ background: var(--hunk-bg);
619
+ border-bottom: 1px solid var(--border);
620
+ cursor: pointer;
621
+ font-size: 12px;
622
+ color: var(--accent);
623
+ gap: 8px;
624
+ }
625
+ .load-more-row:hover { background: var(--selected-bg); }
626
+ .load-more-row .expand-icon { font-size: 10px; }
627
+ .diff-line.hunk-header {
628
+ background: var(--hunk-bg);
629
+ color: var(--muted);
630
+ padding: 6px 12px;
631
+ font-size: 11px;
632
+ }
633
+ .diff-line.add { background: var(--add-bg); }
634
+ .diff-line.add .line-content { color: var(--add-text); }
635
+ .diff-line.add .line-num { background: var(--add-line); color: var(--add-text); }
636
+ .diff-line.del { background: var(--del-bg); }
637
+ .diff-line.del .line-content { color: var(--del-text); }
638
+ .diff-line.del .line-num { background: var(--del-line); color: var(--del-text); }
639
+ .diff-line.ctx { background: transparent; }
640
+
641
+ .line-num {
642
+ min-width: 40px;
643
+ padding: 0 8px;
644
+ text-align: right;
645
+ color: var(--muted);
646
+ user-select: none;
647
+ border-right: 1px solid var(--border);
648
+ flex-shrink: 0;
649
+ }
650
+ .line-sign {
651
+ width: 20px;
652
+ text-align: center;
653
+ color: var(--muted);
654
+ user-select: none;
655
+ flex-shrink: 0;
656
+ }
657
+ .diff-line.add .line-sign { color: var(--add-text); }
658
+ .diff-line.del .line-sign { color: var(--del-text); }
659
+ .line-content {
660
+ flex: 1;
661
+ padding: 0 12px;
662
+ white-space: pre-wrap;
663
+ word-break: break-all;
664
+ overflow-wrap: break-word;
665
+ }
666
+ .line-content.empty { color: var(--muted); font-style: italic; }
667
+
668
+ .has-comment { position: relative; }
669
+ .has-comment::after {
670
+ content: '';
671
+ position: absolute;
672
+ right: 8px;
673
+ top: 50%;
674
+ transform: translateY(-50%);
675
+ width: 8px;
676
+ height: 8px;
677
+ border-radius: 50%;
678
+ background: #22c55e;
679
+ }
680
+
681
+ .floating {
682
+ position: fixed;
683
+ z-index: 20;
684
+ background: var(--panel);
685
+ border: 1px solid var(--border);
686
+ border-radius: 10px;
687
+ padding: 14px;
688
+ width: min(420px, calc(100vw - 32px));
689
+ box-shadow: 0 16px 40px var(--shadow-color);
690
+ display: none;
691
+ }
692
+ .floating header {
693
+ position: relative;
694
+ background: transparent;
695
+ border: none;
696
+ padding: 0 0 10px 0;
697
+ justify-content: space-between;
698
+ }
699
+ .floating h2 { font-size: 14px; margin: 0; font-weight: 600; }
700
+ .floating button {
701
+ background: var(--accent);
702
+ color: var(--text-inverse);
703
+ padding: 6px 10px;
704
+ border-radius: 6px;
705
+ font-size: 12px;
706
+ font-weight: 600;
707
+ border: none;
708
+ cursor: pointer;
709
+ }
710
+ .floating textarea {
711
+ width: 100%;
712
+ min-height: 100px;
713
+ resize: vertical;
714
+ border-radius: 6px;
715
+ border: 1px solid var(--border);
716
+ background: var(--bg);
717
+ color: var(--text);
718
+ padding: 10px;
719
+ font-size: 13px;
720
+ font-family: inherit;
721
+ }
722
+ .floating .actions {
723
+ display: flex;
724
+ gap: 8px;
725
+ justify-content: flex-end;
726
+ margin-top: 10px;
727
+ }
728
+ .floating .actions button.primary {
729
+ background: #238636;
730
+ color: #fff;
731
+ }
732
+
733
+ .comment-list {
734
+ position: fixed;
735
+ right: 16px;
736
+ bottom: 16px;
737
+ width: 300px;
738
+ max-height: 50vh;
739
+ overflow: auto;
740
+ border: 1px solid var(--border);
741
+ border-radius: 10px;
742
+ background: var(--panel-alpha);
743
+ backdrop-filter: blur(6px);
744
+ padding: 12px;
745
+ box-shadow: 0 16px 40px var(--shadow-color);
746
+ }
747
+ .comment-list h3 { margin: 0 0 8px; font-size: 13px; color: var(--muted); font-weight: 600; }
748
+ .comment-list ol { margin: 0; padding-left: 18px; font-size: 12px; line-height: 1.5; }
749
+ .comment-list li { margin-bottom: 6px; cursor: pointer; }
750
+ .comment-list li:hover { color: var(--accent); }
751
+ .comment-list .hint { color: var(--muted); font-size: 11px; margin-top: 8px; }
752
+ .comment-list.collapsed { opacity: 0; pointer-events: none; transform: translateY(8px); }
753
+ .comment-toggle {
754
+ position: fixed;
755
+ right: 16px;
756
+ bottom: 16px;
757
+ padding: 8px 12px;
758
+ border-radius: 6px;
759
+ border: 1px solid var(--border);
760
+ background: var(--panel-alpha);
761
+ color: var(--text);
762
+ cursor: pointer;
763
+ font-size: 12px;
764
+ }
765
+ .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 999px; background: var(--selected-bg); border: 1px solid var(--border); font-size: 12px; }
766
+ .pill strong { font-weight: 700; }
767
+
768
+ .modal-overlay {
769
+ position: fixed;
770
+ inset: 0;
771
+ background: rgba(0,0,0,0.6);
772
+ display: none;
773
+ align-items: center;
774
+ justify-content: center;
775
+ z-index: 100;
776
+ }
777
+ .modal-overlay.visible { display: flex; }
778
+ .modal-dialog {
779
+ background: var(--panel);
780
+ border: 1px solid var(--border);
781
+ border-radius: 10px;
782
+ padding: 20px;
783
+ width: 90%;
784
+ max-width: 480px;
785
+ box-shadow: 0 20px 40px var(--shadow-color);
786
+ }
787
+ .modal-dialog h3 { margin: 0 0 12px; font-size: 18px; color: var(--accent); }
788
+ .modal-summary { color: var(--muted); font-size: 13px; margin-bottom: 12px; }
789
+ .modal-dialog label { display: block; font-size: 13px; margin-bottom: 6px; color: var(--muted); }
790
+ .modal-dialog textarea {
791
+ width: 100%;
792
+ min-height: 100px;
793
+ background: var(--bg);
794
+ border: 1px solid var(--border);
795
+ border-radius: 6px;
796
+ color: var(--text);
797
+ padding: 10px;
798
+ font-size: 14px;
799
+ resize: vertical;
800
+ box-sizing: border-box;
801
+ }
802
+ .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
803
+ .modal-actions button {
804
+ padding: 8px 16px;
805
+ border-radius: 6px;
806
+ border: 1px solid var(--border);
807
+ background: var(--selected-bg);
808
+ color: var(--text);
809
+ cursor: pointer;
810
+ font-size: 14px;
811
+ }
812
+ .modal-actions button:hover { background: var(--border); }
813
+ .modal-actions button.primary { background: var(--accent); color: var(--text-inverse); border-color: var(--accent); }
814
+
815
+ .no-diff {
816
+ text-align: center;
817
+ padding: 60px 20px;
818
+ color: var(--muted);
819
+ }
820
+ .no-diff h2 { font-size: 20px; margin: 0 0 8px; color: var(--text); }
821
+ .no-diff p { font-size: 14px; margin: 0; }
822
+ </style>
823
+ </head>
824
+ <body>
825
+ <header>
826
+ <div class="meta">
827
+ <h1>${title}</h1>
828
+ <span class="badge">${fileCount} file${fileCount !== 1 ? 's' : ''} changed</span>
829
+ <span class="pill">Comments <strong id="comment-count">0</strong></span>
830
+ </div>
831
+ <div class="actions">
832
+ <button class="theme-toggle" id="theme-toggle" title="Toggle theme"><span id="theme-icon">🌙</span></button>
833
+ <button id="send-and-exit">Submit & Exit</button>
834
+ </div>
835
+ </header>
836
+
837
+ <div class="wrap">
838
+ ${rows.length === 0 ? '<div class="no-diff"><h2>No changes</h2><p>Working tree is clean</p></div>' : '<div class="diff-container" id="diff-container"></div>'}
839
+ </div>
840
+
841
+ <div class="floating" id="comment-card">
842
+ <header>
843
+ <h2 id="card-title">Line Comment</h2>
844
+ <div style="display:flex; gap:6px;">
845
+ <button id="close-card">Close</button>
846
+ <button id="clear-comment">Delete</button>
847
+ </div>
848
+ </header>
849
+ <div id="cell-preview" style="font-size:11px; color: var(--muted); margin-bottom:8px; white-space: pre-wrap; max-height: 60px; overflow: hidden;"></div>
850
+ <textarea id="comment-input" placeholder="Enter your comment"></textarea>
851
+ <div class="actions">
852
+ <button class="primary" id="save-comment">Save</button>
853
+ </div>
854
+ </div>
855
+
856
+ <aside class="comment-list">
857
+ <h3>Comments</h3>
858
+ <ol id="comment-list"></ol>
859
+ <p class="hint">Click "Submit & Exit" to finish review.</p>
860
+ </aside>
861
+ <button class="comment-toggle" id="comment-toggle">Comments (0)</button>
862
+
863
+ <div class="modal-overlay" id="submit-modal">
864
+ <div class="modal-dialog">
865
+ <h3>Submit Review</h3>
866
+ <p class="modal-summary" id="modal-summary"></p>
867
+ <label for="global-comment">Overall comment (optional)</label>
868
+ <textarea id="global-comment" placeholder="Add a summary or overall feedback..."></textarea>
869
+ <div class="modal-actions">
870
+ <button id="modal-cancel">Cancel</button>
871
+ <button class="primary" id="modal-submit">Submit</button>
872
+ </div>
873
+ </div>
874
+ </div>
875
+
876
+ <script>
877
+ const DATA = ${serialized};
878
+ const FILE_NAME = ${serializeForScript(title)};
879
+ const MODE = 'diff';
880
+
881
+ // Theme
882
+ (function initTheme() {
883
+ const toggle = document.getElementById('theme-toggle');
884
+ const icon = document.getElementById('theme-icon');
885
+ function getSystem() { return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; }
886
+ function getStored() { return localStorage.getItem('reviw-theme'); }
887
+ function set(t) {
888
+ if (t === 'light') { document.documentElement.setAttribute('data-theme', 'light'); icon.textContent = '☀️'; }
889
+ else { document.documentElement.removeAttribute('data-theme'); icon.textContent = '🌙'; }
890
+ localStorage.setItem('reviw-theme', t);
891
+ }
892
+ set(getStored() || getSystem());
893
+ toggle.addEventListener('click', () => {
894
+ const cur = document.documentElement.getAttribute('data-theme');
895
+ set(cur === 'light' ? 'dark' : 'light');
896
+ });
897
+ })();
898
+
899
+ const container = document.getElementById('diff-container');
900
+ const card = document.getElementById('comment-card');
901
+ const commentInput = document.getElementById('comment-input');
902
+ const cardTitle = document.getElementById('card-title');
903
+ const cellPreview = document.getElementById('cell-preview');
904
+ const commentList = document.getElementById('comment-list');
905
+ const commentCount = document.getElementById('comment-count');
906
+ const commentPanel = document.querySelector('.comment-list');
907
+ const commentToggle = document.getElementById('comment-toggle');
908
+
909
+ const comments = {};
910
+ let currentKey = null;
911
+ let panelOpen = false;
912
+ let isDragging = false;
913
+ let dragStart = null;
914
+ let dragEnd = null;
915
+ let selection = null;
916
+
917
+ function makeKey(start, end) {
918
+ return start === end ? String(start) : (start + '-' + end);
919
+ }
920
+
921
+ function keyToRange(key) {
922
+ if (!key) return null;
923
+ if (String(key).includes('-')) {
924
+ const [a, b] = String(key).split('-').map((n) => parseInt(n, 10));
925
+ return { start: Math.min(a, b), end: Math.max(a, b) };
926
+ }
927
+ const n = parseInt(key, 10);
928
+ return { start: n, end: n };
929
+ }
930
+
931
+ // localStorage
932
+ const STORAGE_KEY = 'reviw:comments:' + FILE_NAME;
933
+ const STORAGE_TTL = 3 * 60 * 60 * 1000;
934
+ function saveToStorage() {
935
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ comments: { ...comments }, timestamp: Date.now() })); } catch (_) {}
936
+ }
937
+ function loadFromStorage() {
938
+ try {
939
+ const raw = localStorage.getItem(STORAGE_KEY);
940
+ if (!raw) return null;
941
+ const d = JSON.parse(raw);
942
+ if (Date.now() - d.timestamp > STORAGE_TTL) { localStorage.removeItem(STORAGE_KEY); return null; }
943
+ return d;
944
+ } catch (_) { return null; }
945
+ }
946
+ function clearStorage() { try { localStorage.removeItem(STORAGE_KEY); } catch (_) {} }
947
+
948
+ function escapeHtml(s) { return s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c] || c)); }
949
+
950
+ function clearSelectionHighlight() {
951
+ container?.querySelectorAll('.diff-line.selected').forEach(el => el.classList.remove('selected'));
952
+ }
953
+
954
+ function updateSelectionHighlight() {
955
+ clearSelectionHighlight();
956
+ if (!selection) return;
957
+ for (let r = selection.start; r <= selection.end; r++) {
958
+ const el = container?.querySelector('[data-row="' + r + '"]');
959
+ if (el) el.classList.add('selected');
960
+ }
961
+ }
962
+
963
+ function beginDrag(row) {
964
+ isDragging = true;
965
+ document.body.classList.add('dragging');
966
+ dragStart = row;
967
+ dragEnd = row;
968
+ selection = { start: row, end: row };
969
+ updateSelectionHighlight();
970
+ }
971
+
972
+ function updateDrag(row) {
973
+ if (!isDragging) return;
974
+ const next = Math.max(0, Math.min(DATA.length - 1, row));
975
+ if (next === dragEnd) return;
976
+ dragEnd = next;
977
+ selection = { start: Math.min(dragStart, dragEnd), end: Math.max(dragStart, dragEnd) };
978
+ updateSelectionHighlight();
979
+ }
980
+
981
+ function finishDrag() {
982
+ if (!isDragging) return;
983
+ isDragging = false;
984
+ document.body.classList.remove('dragging');
985
+ if (!selection) { clearSelectionHighlight(); return; }
986
+ openCardRange(selection.start, selection.end);
987
+ }
988
+
989
+ const expandedFiles = {};
990
+ const PREVIEW_LINES = 10;
991
+
992
+ function renderDiff() {
993
+ if (!container) return;
994
+ container.innerHTML = '';
995
+ let currentFileIdx = null;
996
+ let currentFileContent = null;
997
+ let fileLineCount = 0;
998
+ let hiddenWrapper = null;
999
+ let hiddenCount = 0;
1000
+
1001
+ DATA.forEach((row, idx) => {
1002
+ const div = document.createElement('div');
1003
+ div.className = 'diff-line';
1004
+ div.dataset.row = idx;
1005
+
1006
+ if (row.type === 'file') {
1007
+ currentFileIdx = row.fileIndex;
1008
+ fileLineCount = 0;
1009
+ hiddenWrapper = null;
1010
+ hiddenCount = 0;
1011
+
1012
+ div.classList.add('file-header');
1013
+ const leftSpan = document.createElement('span');
1014
+ leftSpan.className = 'file-header-left';
1015
+ leftSpan.innerHTML = '<span>' + escapeHtml(row.content) + '</span>';
1016
+ if (row.lineCount > 0) {
1017
+ leftSpan.innerHTML += '<span class="file-header-info">(' + row.lineCount + ' lines)</span>';
1018
+ }
1019
+ div.appendChild(leftSpan);
1020
+ div.style.cursor = 'default';
1021
+ container.appendChild(div);
1022
+
1023
+ currentFileContent = document.createElement('div');
1024
+ currentFileContent.className = 'file-content';
1025
+ currentFileContent.dataset.fileIndex = row.fileIndex;
1026
+ container.appendChild(currentFileContent);
1027
+ } else if (row.type === 'hunk') {
1028
+ div.classList.add('hunk-header');
1029
+ div.innerHTML = '<span class="line-content">' + escapeHtml(row.content) + '</span>';
1030
+ if (currentFileContent) currentFileContent.appendChild(div);
1031
+ else container.appendChild(div);
1032
+ } else {
1033
+ fileLineCount++;
1034
+ const isExpanded = expandedFiles[currentFileIdx];
1035
+ const fileRow = DATA.find(r => r.type === 'file' && r.fileIndex === currentFileIdx);
1036
+ const shouldCollapse = fileRow && fileRow.collapsed && !isExpanded;
1037
+
1038
+ div.classList.add(row.type);
1039
+ const sign = row.type === 'add' ? '+' : row.type === 'del' ? '-' : ' ';
1040
+ div.innerHTML = '<span class="line-num">' + (idx + 1) + '</span>' +
1041
+ '<span class="line-sign">' + sign + '</span>' +
1042
+ '<span class="line-content' + (row.content === '' ? ' empty' : '') + '">' + escapeHtml(row.content || '(empty line)') + '</span>';
1043
+
1044
+ if (shouldCollapse && fileLineCount > PREVIEW_LINES) {
1045
+ if (!hiddenWrapper) {
1046
+ hiddenWrapper = document.createElement('div');
1047
+ hiddenWrapper.className = 'hidden-lines';
1048
+ hiddenWrapper.dataset.fileIndex = currentFileIdx;
1049
+ currentFileContent.appendChild(hiddenWrapper);
1050
+ }
1051
+ hiddenWrapper.appendChild(div);
1052
+ hiddenCount++;
1053
+ } else {
1054
+ if (currentFileContent) currentFileContent.appendChild(div);
1055
+ else container.appendChild(div);
1056
+ }
1057
+ }
1058
+
1059
+ // Insert "Load more" button after processing file content
1060
+ const nextRow = DATA[idx + 1];
1061
+ if (hiddenWrapper && hiddenCount > 0 && (nextRow?.type === 'file' || idx === DATA.length - 1)) {
1062
+ const loadMore = document.createElement('div');
1063
+ loadMore.className = 'load-more-row';
1064
+ const fileIdxForClick = currentFileIdx;
1065
+ const count = hiddenCount;
1066
+ loadMore.innerHTML = '<span class="expand-icon">▼</span> Show ' + count + ' more lines';
1067
+ loadMore.addEventListener('click', function() {
1068
+ expandedFiles[fileIdxForClick] = true;
1069
+ renderDiff();
1070
+ });
1071
+ currentFileContent.insertBefore(loadMore, hiddenWrapper);
1072
+ hiddenWrapper = null;
1073
+ hiddenCount = 0;
1074
+ }
1075
+ });
1076
+ }
1077
+
1078
+ function openCardRange(startRow, endRow) {
1079
+ const start = Math.min(startRow, endRow);
1080
+ const end = Math.max(startRow, endRow);
1081
+ const first = DATA[start];
1082
+ const last = DATA[end];
1083
+ if (!first || first.type === 'file' || first.type === 'hunk') return;
1084
+ selection = { start, end };
1085
+ currentKey = makeKey(start, end);
1086
+ const label = start === end
1087
+ ? 'Comment on line ' + (start + 1)
1088
+ : 'Comment on lines ' + (start + 1) + '–' + (end + 1);
1089
+ cardTitle.textContent = label;
1090
+ const previewText = start === end
1091
+ ? (first.content || '(empty)')
1092
+ : (first.content || '(empty)') + ' … ' + (last.content || '(empty)');
1093
+ cellPreview.textContent = previewText;
1094
+ commentInput.value = comments[currentKey]?.text || '';
1095
+ card.style.display = 'block';
1096
+ positionCard(start);
1097
+ commentInput.focus();
1098
+ updateSelectionHighlight();
1099
+ }
1100
+
1101
+ function positionCard(rowIdx) {
1102
+ const el = container.querySelector('[data-row="' + rowIdx + '"]');
1103
+ if (!el) return;
1104
+ const rect = el.getBoundingClientRect();
1105
+ const cardW = 420, cardH = 260;
1106
+ const margin = 12;
1107
+ let left = rect.right + margin;
1108
+ let top = rect.top;
1109
+ if (left + cardW > window.innerWidth) {
1110
+ left = rect.left - cardW - margin;
1111
+ }
1112
+ if (left < margin) left = margin;
1113
+ if (top + cardH > window.innerHeight) {
1114
+ top = window.innerHeight - cardH - margin;
1115
+ }
1116
+ card.style.left = left + 'px';
1117
+ card.style.top = Math.max(margin, top) + 'px';
1118
+ }
1119
+
1120
+ function closeCard() {
1121
+ card.style.display = 'none';
1122
+ currentKey = null;
1123
+ selection = null;
1124
+ clearSelectionHighlight();
1125
+ }
1126
+
1127
+ function setDotRange(start, end, on) {
1128
+ for (let r = start; r <= end; r++) {
1129
+ const el = container?.querySelector('[data-row="' + r + '"]');
1130
+ if (el) el.classList.toggle('has-comment', on);
1131
+ }
1132
+ }
1133
+
1134
+ function refreshList() {
1135
+ commentList.innerHTML = '';
1136
+ const items = Object.values(comments).sort((a, b) => (a.startRow ?? a.row) - (b.startRow ?? b.row));
1137
+ commentCount.textContent = items.length;
1138
+ commentToggle.textContent = 'Comments (' + items.length + ')';
1139
+ if (items.length === 0) panelOpen = false;
1140
+ commentPanel.classList.toggle('collapsed', !panelOpen || items.length === 0);
1141
+ if (!items.length) {
1142
+ const li = document.createElement('li');
1143
+ li.className = 'hint';
1144
+ li.textContent = 'No comments yet';
1145
+ commentList.appendChild(li);
1146
+ return;
1147
+ }
1148
+ items.forEach(c => {
1149
+ const li = document.createElement('li');
1150
+ const label = c.isRange
1151
+ ? 'L' + (c.startRow + 1) + '-L' + (c.endRow + 1)
1152
+ : 'L' + (c.row + 1);
1153
+ li.innerHTML = '<strong>' + label + '</strong> ' + escapeHtml(c.text.slice(0, 50)) + (c.text.length > 50 ? '...' : '');
1154
+ li.addEventListener('click', () => openCardRange(c.startRow ?? c.row, c.endRow ?? c.row));
1155
+ commentList.appendChild(li);
1156
+ });
1157
+ }
1158
+
1159
+ commentToggle.addEventListener('click', () => {
1160
+ panelOpen = !panelOpen;
1161
+ if (panelOpen && Object.keys(comments).length === 0) panelOpen = false;
1162
+ commentPanel.classList.toggle('collapsed', !panelOpen);
1163
+ });
1164
+
1165
+ function saveCurrent() {
1166
+ if (currentKey == null) return;
1167
+ const text = commentInput.value.trim();
1168
+ const range = keyToRange(currentKey);
1169
+ if (!range) return;
1170
+ const rowIdx = range.start;
1171
+ if (text) {
1172
+ if (range.start === range.end) {
1173
+ comments[currentKey] = { row: rowIdx, text, content: DATA[rowIdx]?.content || '' };
1174
+ } else {
1175
+ comments[currentKey] = {
1176
+ startRow: range.start,
1177
+ endRow: range.end,
1178
+ isRange: true,
1179
+ text,
1180
+ content: DATA.slice(range.start, range.end + 1).map(r => r?.content || '').join('\\n')
1181
+ };
1182
+ }
1183
+ setDotRange(range.start, range.end, true);
1184
+ } else {
1185
+ delete comments[currentKey];
1186
+ setDotRange(range.start, range.end, false);
1187
+ }
1188
+ refreshList();
1189
+ closeCard();
1190
+ saveToStorage();
1191
+ }
1192
+
1193
+ function clearCurrent() {
1194
+ if (currentKey == null) return;
1195
+ const range = keyToRange(currentKey);
1196
+ if (!range) return;
1197
+ delete comments[currentKey];
1198
+ setDotRange(range.start, range.end, false);
1199
+ refreshList();
1200
+ closeCard();
1201
+ saveToStorage();
1202
+ }
1203
+
1204
+ document.getElementById('save-comment').addEventListener('click', saveCurrent);
1205
+ document.getElementById('clear-comment').addEventListener('click', clearCurrent);
1206
+ document.getElementById('close-card').addEventListener('click', closeCard);
1207
+ document.addEventListener('keydown', e => {
1208
+ if (e.key === 'Escape') closeCard();
1209
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') saveCurrent();
1210
+ });
1211
+
1212
+ container?.addEventListener('mousedown', e => {
1213
+ const line = e.target.closest('.diff-line');
1214
+ if (!line || line.classList.contains('file-header') || line.classList.contains('hunk-header')) return;
1215
+ e.preventDefault();
1216
+ if (window.getSelection) { const sel = window.getSelection(); if (sel && sel.removeAllRanges) sel.removeAllRanges(); }
1217
+ beginDrag(parseInt(line.dataset.row, 10));
1218
+ });
1219
+
1220
+ container?.addEventListener('mousemove', e => {
1221
+ if (!isDragging) return;
1222
+ const line = e.target.closest('.diff-line');
1223
+ if (!line || line.classList.contains('file-header') || line.classList.contains('hunk-header')) return;
1224
+ updateDrag(parseInt(line.dataset.row, 10));
1225
+ });
1226
+
1227
+ container?.addEventListener('mouseup', () => finishDrag());
1228
+ window.addEventListener('mouseup', () => { if (isDragging) finishDrag(); });
1229
+
1230
+ // Submit
1231
+ let sent = false;
1232
+ let globalComment = '';
1233
+ const submitModal = document.getElementById('submit-modal');
1234
+ const modalSummary = document.getElementById('modal-summary');
1235
+ const globalCommentInput = document.getElementById('global-comment');
1236
+
1237
+ function payload(reason) {
1238
+ const data = { file: FILE_NAME, mode: MODE, reason, at: new Date().toISOString(), comments: Object.values(comments) };
1239
+ if (globalComment.trim()) data.summary = globalComment.trim();
1240
+ return data;
1241
+ }
1242
+ function sendAndExit(reason = 'button') {
1243
+ if (sent) return;
1244
+ sent = true;
1245
+ clearStorage();
1246
+ navigator.sendBeacon('/exit', new Blob([JSON.stringify(payload(reason))], { type: 'application/json' }));
1247
+ }
1248
+ function showSubmitModal() {
1249
+ const count = Object.keys(comments).length;
1250
+ modalSummary.textContent = count === 0 ? 'No comments added yet.' : count + ' comment' + (count === 1 ? '' : 's') + ' will be submitted.';
1251
+ globalCommentInput.value = globalComment;
1252
+ submitModal.classList.add('visible');
1253
+ globalCommentInput.focus();
1254
+ }
1255
+ function hideSubmitModal() { submitModal.classList.remove('visible'); }
1256
+ document.getElementById('send-and-exit').addEventListener('click', showSubmitModal);
1257
+ document.getElementById('modal-cancel').addEventListener('click', hideSubmitModal);
1258
+ function doSubmit() {
1259
+ globalComment = globalCommentInput.value;
1260
+ hideSubmitModal();
1261
+ sendAndExit('button');
1262
+ setTimeout(() => window.close(), 200);
1263
+ }
1264
+ document.getElementById('modal-submit').addEventListener('click', doSubmit);
1265
+ globalCommentInput.addEventListener('keydown', e => {
1266
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
1267
+ e.preventDefault();
1268
+ doSubmit();
1269
+ }
1270
+ });
1271
+ submitModal.addEventListener('click', e => { if (e.target === submitModal) hideSubmitModal(); });
1272
+
1273
+ // SSE
1274
+ (() => {
1275
+ let es = null;
1276
+ const connect = () => {
1277
+ es = new EventSource('/sse');
1278
+ es.onmessage = ev => { if (ev.data === 'reload') location.reload(); };
1279
+ es.onerror = () => { es.close(); setTimeout(connect, 1500); };
1280
+ };
1281
+ connect();
1282
+ })();
1283
+
1284
+ renderDiff();
1285
+ refreshList();
1286
+
1287
+ // Recovery
1288
+ (function checkRecovery() {
1289
+ const stored = loadFromStorage();
1290
+ if (!stored || Object.keys(stored.comments).length === 0) return;
1291
+ if (confirm('Restore ' + Object.keys(stored.comments).length + ' previous comment(s)?')) {
1292
+ Object.assign(comments, stored.comments);
1293
+ Object.values(stored.comments).forEach(c => setDot(c.row, true));
1294
+ refreshList();
1295
+ } else {
1296
+ clearStorage();
1297
+ }
1298
+ })();
1299
+ </script>
1300
+ </body>
1301
+ </html>`;
1302
+ }
1303
+
1304
+ // --- HTML template ---------------------------------------------------------
1305
+ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1306
+ const serialized = serializeForScript(dataRows);
1307
+ const modeJson = serializeForScript(mode);
1308
+ const titleJson = serializeForScript(title);
1309
+ const hasPreview = !!previewHtml;
1310
+ return `<!doctype html>
1311
+ <html lang="ja">
1312
+ <head>
1313
+ <meta charset="utf-8" />
1314
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
1315
+ <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate" />
1316
+ <meta http-equiv="Pragma" content="no-cache" />
1317
+ <meta http-equiv="Expires" content="0" />
1318
+ <title>${title} | reviw</title>
1319
+ <style>
1320
+ /* Dark theme (default) */
1321
+ :root {
1322
+ color-scheme: dark;
1323
+ --bg: #0f172a;
1324
+ --bg-gradient: radial-gradient(circle at 20% 20%, #1e293b 0%, #0b1224 35%, #0b1224 60%, #0f172a 100%);
1325
+ --panel: #111827;
1326
+ --panel-alpha: rgba(15, 23, 42, 0.9);
1327
+ --panel-solid: #0b1224;
1328
+ --card-bg: rgba(11, 18, 36, 0.95);
1329
+ --input-bg: rgba(15, 23, 42, 0.6);
1330
+ --border: #1f2937;
1331
+ --accent: #60a5fa;
1332
+ --accent-2: #f472b6;
1333
+ --text: #e5e7eb;
1334
+ --text-inverse: #0b1224;
1335
+ --muted: #94a3b8;
1336
+ --comment: #0f766e;
1337
+ --badge: #22c55e;
1338
+ --table-bg: rgba(15, 23, 42, 0.7);
1339
+ --row-even: rgba(30, 41, 59, 0.4);
1340
+ --row-odd: rgba(15, 23, 42, 0.2);
1341
+ --selected-bg: rgba(96,165,250,0.15);
1342
+ --hover-bg: rgba(96,165,250,0.08);
1343
+ --shadow-color: rgba(0,0,0,0.35);
1344
+ --code-bg: #1e293b;
1345
+ }
1346
+ /* Light theme */
1347
+ [data-theme="light"] {
1348
+ color-scheme: light;
1349
+ --bg: #f8fafc;
1350
+ --bg-gradient: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
1351
+ --panel: #ffffff;
1352
+ --panel-alpha: rgba(255, 255, 255, 0.95);
1353
+ --panel-solid: #ffffff;
1354
+ --card-bg: rgba(255, 255, 255, 0.98);
1355
+ --input-bg: #f1f5f9;
1356
+ --border: #e2e8f0;
1357
+ --accent: #3b82f6;
1358
+ --accent-2: #ec4899;
1359
+ --text: #1e293b;
1360
+ --text-inverse: #ffffff;
1361
+ --muted: #64748b;
1362
+ --comment: #14b8a6;
1363
+ --badge: #22c55e;
1364
+ --table-bg: #ffffff;
1365
+ --row-even: #f8fafc;
1366
+ --row-odd: #ffffff;
1367
+ --selected-bg: rgba(59,130,246,0.12);
1368
+ --hover-bg: rgba(59,130,246,0.06);
1369
+ --shadow-color: rgba(0,0,0,0.1);
1370
+ --code-bg: #f1f5f9;
1371
+ }
1372
+ * { box-sizing: border-box; }
1373
+ body {
1374
+ margin: 0;
1375
+ font-family: "Inter", "Hiragino Sans", system-ui, -apple-system, sans-serif;
1376
+ background: var(--bg-gradient);
1377
+ color: var(--text);
1378
+ min-height: 100vh;
1379
+ transition: background 200ms ease, color 200ms ease;
1380
+ }
1381
+ header {
1382
+ position: sticky;
1383
+ top: 0;
1384
+ z-index: 5;
1385
+ padding: 12px 16px;
1386
+ background: var(--panel-alpha);
1387
+ backdrop-filter: blur(8px);
1388
+ border-bottom: 1px solid var(--border);
1389
+ display: flex;
1390
+ gap: 12px;
1391
+ align-items: center;
1392
+ justify-content: space-between;
1393
+ transition: background 200ms ease, border-color 200ms ease;
1394
+ }
1395
+ header .meta { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
1396
+ header .actions { display: flex; gap: 8px; align-items: center; }
1397
+ header h1 { font-size: 16px; margin: 0; font-weight: 700; }
1398
+ header .badge {
1399
+ background: var(--selected-bg);
1400
+ color: var(--text);
1401
+ padding: 6px 10px;
1402
+ border-radius: 8px;
1403
+ font-size: 12px;
1404
+ border: 1px solid var(--border);
1405
+ }
1406
+ header button {
1407
+ background: linear-gradient(135deg, #38bdf8, #6366f1);
1408
+ color: var(--text-inverse);
1409
+ border: none;
1410
+ border-radius: 10px;
1411
+ padding: 10px 14px;
1412
+ font-weight: 700;
1413
+ cursor: pointer;
1414
+ box-shadow: 0 10px 30px var(--shadow-color);
1415
+ transition: transform 120ms ease, box-shadow 120ms ease;
1416
+ }
1417
+ header button:hover { transform: translateY(-1px); box-shadow: 0 16px 36px var(--shadow-color); }
1418
+ header button:active { transform: translateY(0); }
1419
+ /* Theme toggle button */
1420
+ .theme-toggle {
1421
+ background: var(--selected-bg);
1422
+ color: var(--text);
1423
+ border: 1px solid var(--border);
1424
+ border-radius: 8px;
1425
+ padding: 8px 10px;
1426
+ font-size: 16px;
1427
+ cursor: pointer;
1428
+ transition: background 120ms ease, transform 120ms ease;
1429
+ display: flex;
1430
+ align-items: center;
1431
+ justify-content: center;
1432
+ width: 38px;
1433
+ height: 38px;
1434
+ }
1435
+ .theme-toggle:hover { background: var(--hover-bg); transform: scale(1.05); }
1436
+
1437
+ .wrap { padding: 12px 16px 40px; }
1438
+ .toolbar {
1439
+ display: flex;
1440
+ gap: 12px;
1441
+ align-items: center;
1442
+ flex-wrap: wrap;
1443
+ margin: 10px 0 12px;
1444
+ color: var(--muted);
1445
+ font-size: 13px;
1446
+ }
1447
+ .toolbar button {
1448
+ background: rgba(96,165,250,0.12);
1449
+ color: var(--text);
1450
+ border: 1px solid var(--border);
1451
+ border-radius: 8px;
1452
+ padding: 8px 10px;
1453
+ font-size: 13px;
1454
+ cursor: pointer;
1455
+ }
1456
+ .toolbar button:hover { background: rgba(96,165,250,0.2); }
1457
+
1458
+ .table-box {
1459
+ background: var(--table-bg);
1460
+ border: 1px solid var(--border);
1461
+ border-radius: 12px;
1462
+ overflow: auto;
1463
+ max-height: calc(100vh - 110px);
1464
+ box-shadow: 0 20px 50px var(--shadow-color);
1465
+ transition: background 200ms ease, border-color 200ms ease;
1466
+ }
1467
+ table {
1468
+ border-collapse: collapse;
1469
+ width: 100%;
1470
+ min-width: 540px;
1471
+ table-layout: fixed;
1472
+ }
1473
+ thead th {
1474
+ position: sticky;
1475
+ top: 0;
1476
+ z-index: 3;
1477
+ background: var(--panel-solid);
1478
+ color: var(--muted);
1479
+ font-size: 12px;
1480
+ text-align: center;
1481
+ padding: 0;
1482
+ border-bottom: 1px solid var(--border);
1483
+ border-right: 1px solid var(--border);
1484
+ white-space: nowrap;
1485
+ transition: background 200ms ease;
1486
+ }
1487
+ thead th:first-child,
1488
+ tbody th {
1489
+ width: 28px;
1490
+ min-width: 28px;
1491
+ max-width: 28px;
1492
+ }
1493
+ thead th .th-inner {
1494
+ display: flex;
1495
+ align-items: center;
1496
+ justify-content: center;
1497
+ gap: 4px;
1498
+ padding: 8px 6px;
1499
+ position: relative;
1500
+ height: 100%;
1501
+ }
1502
+ thead th.filtered .th-inner {
1503
+ background: linear-gradient(135deg, rgba(96,165,250,0.18), rgba(34,197,94,0.18));
1504
+ color: #e5e7eb;
1505
+ border-radius: 6px;
1506
+ box-shadow: inset 0 -1px 0 rgba(255,255,255,0.05);
1507
+ }
1508
+ thead th.filtered .th-inner::after {
1509
+ content: 'FILTER';
1510
+ font-size: 10px;
1511
+ color: #c7d2fe;
1512
+ background: rgba(99,102,241,0.24);
1513
+ border: 1px solid rgba(99,102,241,0.45);
1514
+ padding: 1px 6px;
1515
+ border-radius: 999px;
1516
+ position: absolute;
1517
+ bottom: 4px;
1518
+ right: 6px;
1519
+ }
1520
+ .resizer {
1521
+ position: absolute;
1522
+ right: 2px;
1523
+ top: 0;
1524
+ width: 6px;
1525
+ height: 100%;
1526
+ cursor: col-resize;
1527
+ user-select: none;
1528
+ touch-action: none;
1529
+ opacity: 0.6;
1530
+ }
1531
+ .resizer::after {
1532
+ content: '';
1533
+ position: absolute;
1534
+ top: 10%;
1535
+ bottom: 10%;
1536
+ left: 2px;
1537
+ width: 2px;
1538
+ background: rgba(96,165,250,0.6);
1539
+ border-radius: 2px;
1540
+ opacity: 0;
1541
+ transition: opacity 120ms ease;
1542
+ }
1543
+ thead th:hover .resizer::after { opacity: 1; }
1544
+
1545
+ .freeze {
1546
+ position: sticky !important;
1547
+ background: var(--panel-solid);
1548
+ z-index: 4;
1549
+ }
1550
+ .freeze-row {
1551
+ position: sticky !important;
1552
+ background: var(--panel-solid);
1553
+ }
1554
+ .freeze-row.freeze {
1555
+ z-index: 6;
1556
+ }
1557
+ th.freeze-row {
1558
+ z-index: 6;
1559
+ }
1560
+ tbody th {
1561
+ position: sticky;
1562
+ left: 0;
1563
+ z-index: 2;
1564
+ background: var(--panel-solid);
1565
+ color: var(--muted);
1566
+ text-align: right;
1567
+ padding: 8px 10px;
1568
+ font-size: 12px;
1569
+ border-right: 1px solid var(--border);
1570
+ border-bottom: 1px solid var(--border);
1571
+ transition: background 200ms ease;
1572
+ }
1573
+ td {
1574
+ padding: 10px 10px;
1575
+ border-bottom: 1px solid var(--border);
1576
+ border-right: 1px solid var(--border);
1577
+ background: var(--row-odd);
1578
+ color: var(--text);
1579
+ font-size: 13px;
1580
+ line-height: 1.45;
1581
+ cursor: pointer;
1582
+ transition: background 120ms ease, box-shadow 120ms ease;
1583
+ position: relative;
1584
+ white-space: pre-wrap;
1585
+ word-break: break-word;
1586
+ max-width: 320px;
1587
+ }
1588
+ tr:nth-child(even) td:not(.selected):not(.has-comment) { background: var(--row-even); }
1589
+ td:hover:not(.selected) { background: var(--hover-bg); box-shadow: inset 0 0 0 1px rgba(96,165,250,0.25); }
1590
+ td.has-comment { background: rgba(34,197,94,0.12); box-shadow: inset 0 0 0 1px rgba(34,197,94,0.35); }
1591
+ td.selected, th.selected, thead th.selected { background: rgba(99,102,241,0.22) !important; box-shadow: inset 0 0 0 1px rgba(99,102,241,0.45); }
1592
+ body.dragging { user-select: none; cursor: crosshair; }
1593
+ body.dragging td, body.dragging tbody th { cursor: crosshair; }
1594
+ tbody th { cursor: pointer; }
1595
+ td .dot {
1596
+ position: absolute;
1597
+ right: 6px;
1598
+ top: 6px;
1599
+ width: 8px;
1600
+ height: 8px;
1601
+ border-radius: 99px;
1602
+ background: var(--badge);
1603
+ box-shadow: 0 0 0 4px rgba(34,197,94,0.15);
1604
+ }
1605
+ .floating {
1606
+ position: absolute;
1607
+ z-index: 10;
1608
+ background: var(--panel-solid);
1609
+ border: 1px solid var(--border);
1610
+ border-radius: 12px;
1611
+ padding: 12px;
1612
+ width: min(420px, calc(100vw - 32px));
1613
+ box-shadow: 0 20px 40px var(--shadow-color);
1614
+ display: none;
1615
+ transition: background 200ms ease, border-color 200ms ease;
1616
+ }
1617
+ .floating header {
1618
+ position: relative;
1619
+ top: 0;
1620
+ background: transparent;
1621
+ border: none;
1622
+ padding: 0 0 8px 0;
1623
+ justify-content: space-between;
1624
+ }
1625
+ .floating h2 { font-size: 14px; margin: 0; color: var(--text); }
1626
+ .floating button {
1627
+ margin-left: 8px;
1628
+ background: var(--accent);
1629
+ color: var(--text-inverse);
1630
+ border: 1px solid var(--accent);
1631
+ padding: 6px 10px;
1632
+ border-radius: 8px;
1633
+ font-size: 12px;
1634
+ font-weight: 600;
1635
+ cursor: pointer;
1636
+ transition: background 120ms ease, opacity 120ms ease;
1637
+ }
1638
+ .floating button:hover { opacity: 0.85; }
1639
+ .floating textarea {
1640
+ width: 100%;
1641
+ min-height: 110px;
1642
+ resize: vertical;
1643
+ border-radius: 8px;
1644
+ border: 1px solid var(--border);
1645
+ background: var(--input-bg);
1646
+ color: var(--text);
1647
+ padding: 10px;
1648
+ font-size: 13px;
1649
+ line-height: 1.4;
1650
+ transition: background 200ms ease, border-color 200ms ease;
1651
+ }
1652
+ .floating .actions {
1653
+ display: flex;
1654
+ gap: 8px;
1655
+ justify-content: flex-end;
1656
+ margin-top: 10px;
1657
+ }
1658
+ .floating .actions button.primary {
1659
+ background: linear-gradient(135deg, #22c55e, #16a34a);
1660
+ color: var(--text-inverse);
1661
+ border: none;
1662
+ font-weight: 700;
1663
+ box-shadow: 0 10px 30px rgba(22,163,74,0.35);
1664
+ }
1665
+ .comment-list {
1666
+ position: fixed;
1667
+ right: 14px;
1668
+ bottom: 14px;
1669
+ width: 320px;
1670
+ max-height: 60vh;
1671
+ overflow: auto;
1672
+ border: 1px solid var(--border);
1673
+ border-radius: 12px;
1674
+ background: var(--card-bg);
1675
+ box-shadow: 0 18px 40px var(--shadow-color);
1676
+ padding: 12px;
1677
+ backdrop-filter: blur(6px);
1678
+ transition: opacity 120ms ease, transform 120ms ease, background 200ms ease;
1679
+ }
1680
+ .comment-list h3 { margin: 0 0 8px 0; font-size: 13px; color: var(--muted); }
1681
+ .comment-list ol {
1682
+ margin: 0;
1683
+ padding-left: 18px;
1684
+ color: var(--text);
1685
+ font-size: 13px;
1686
+ line-height: 1.45;
1687
+ }
1688
+ .comment-list li { margin-bottom: 6px; }
1689
+ .comment-list .hint { color: var(--muted); font-size: 12px; }
1690
+ .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 999px; background: var(--selected-bg); border: 1px solid var(--border); font-size: 12px; color: var(--text); }
1691
+ .pill strong { color: var(--text); font-weight: 700; }
1692
+ .comment-list.collapsed {
1693
+ opacity: 0;
1694
+ pointer-events: none;
1695
+ transform: translateY(8px) scale(0.98);
1696
+ }
1697
+ .comment-toggle {
1698
+ position: fixed;
1699
+ right: 14px;
1700
+ bottom: 14px;
1701
+ padding: 10px 12px;
1702
+ border-radius: 10px;
1703
+ border: 1px solid var(--border);
1704
+ background: var(--selected-bg);
1705
+ color: var(--text);
1706
+ cursor: pointer;
1707
+ box-shadow: 0 10px 24px var(--shadow-color);
1708
+ font-size: 13px;
1709
+ transition: background 200ms ease, border-color 200ms ease;
1710
+ }
1711
+ .md-preview {
1712
+ background: var(--input-bg);
1713
+ border: 1px solid var(--border);
1714
+ border-radius: 12px;
1715
+ padding: 14px;
1716
+ margin-bottom: 12px;
1717
+ transition: background 200ms ease, border-color 200ms ease;
1718
+ }
1719
+ .md-layout {
1720
+ display: flex;
1721
+ gap: 16px;
1722
+ align-items: stretch;
1723
+ margin-top: 8px;
1724
+ height: calc(100vh - 140px);
1725
+ }
1726
+ .md-left {
1727
+ flex: 1;
1728
+ min-width: 0;
1729
+ overflow-y: auto;
1730
+ overflow-x: hidden;
1731
+ }
1732
+ .md-left .md-preview {
1733
+ max-height: none;
1734
+ }
1735
+ .md-right {
1736
+ flex: 1;
1737
+ min-width: 0;
1738
+ overflow-y: auto;
1739
+ overflow-x: auto;
1740
+ }
1741
+ .md-right .table-box {
1742
+ max-width: none;
1743
+ min-width: 0;
1744
+ max-height: none;
1745
+ overflow: visible;
1746
+ }
1747
+ .md-preview h1, .md-preview h2, .md-preview h3, .md-preview h4 {
1748
+ margin: 0.4em 0 0.2em;
1749
+ }
1750
+ .md-preview p { margin: 0.3em 0; line-height: 1.5; }
1751
+ .md-preview img { max-width: 100%; height: auto; border-radius: 8px; }
1752
+ .md-preview code { background: rgba(255,255,255,0.08); padding: 2px 4px; border-radius: 4px; }
1753
+ .md-preview pre {
1754
+ background: rgba(255,255,255,0.06);
1755
+ padding: 8px 10px;
1756
+ border-radius: 8px;
1757
+ overflow: auto;
1758
+ }
1759
+ @media (max-width: 960px) {
1760
+ .md-layout { flex-direction: column; }
1761
+ .md-left { max-width: 100%; flex: 0 0 auto; }
1762
+ }
1763
+ .filter-menu {
1764
+ position: absolute;
1765
+ background: var(--panel-solid);
1766
+ border: 1px solid var(--border);
1767
+ border-radius: 10px;
1768
+ box-shadow: 0 14px 30px var(--shadow-color);
1769
+ padding: 8px;
1770
+ display: none;
1771
+ z-index: 12;
1772
+ width: 180px;
1773
+ transition: background 200ms ease, border-color 200ms ease;
1774
+ }
1775
+ .filter-menu button {
1776
+ width: 100%;
1777
+ display: block;
1778
+ margin: 4px 0;
1779
+ padding: 8px 10px;
1780
+ background: var(--selected-bg);
1781
+ border: 1px solid var(--border);
1782
+ border-radius: 8px;
1783
+ color: var(--text);
1784
+ cursor: pointer;
1785
+ font-size: 13px;
1786
+ text-align: left;
1787
+ }
1788
+ .filter-menu button:hover { background: var(--hover-bg); }
1789
+ .modal-overlay {
1790
+ position: fixed;
1791
+ inset: 0;
1792
+ background: rgba(0,0,0,0.6);
1793
+ display: none;
1794
+ align-items: center;
1795
+ justify-content: center;
1796
+ z-index: 100;
1797
+ }
1798
+ .modal-overlay.visible { display: flex; }
1799
+ .modal-dialog {
1800
+ background: var(--panel-solid);
1801
+ border: 1px solid var(--border);
1802
+ border-radius: 14px;
1803
+ padding: 20px;
1804
+ width: 90%;
1805
+ max-width: 480px;
1806
+ box-shadow: 0 20px 40px var(--shadow-color);
1807
+ transition: background 200ms ease, border-color 200ms ease;
1808
+ }
1809
+ .modal-dialog h3 { margin: 0 0 12px; font-size: 18px; color: var(--accent); }
1810
+ .modal-summary { color: var(--muted); font-size: 13px; margin-bottom: 12px; }
1811
+ .modal-dialog label { display: block; font-size: 13px; margin-bottom: 6px; color: var(--muted); }
1812
+ .modal-dialog textarea {
1813
+ width: 100%;
1814
+ min-height: 100px;
1815
+ background: var(--input-bg);
1816
+ border: 1px solid var(--border);
1817
+ border-radius: 8px;
1818
+ color: var(--text);
1819
+ padding: 10px;
1820
+ font-size: 14px;
1821
+ resize: vertical;
1822
+ box-sizing: border-box;
1823
+ transition: background 200ms ease, border-color 200ms ease;
1824
+ }
1825
+ .modal-dialog textarea:focus { outline: none; border-color: var(--accent); }
1826
+ .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
1827
+ .modal-actions button {
1828
+ padding: 8px 16px;
1829
+ border-radius: 8px;
1830
+ border: 1px solid var(--border);
1831
+ background: var(--selected-bg);
1832
+ color: var(--text);
1833
+ cursor: pointer;
1834
+ font-size: 14px;
1835
+ }
1836
+ .modal-actions button:hover { background: var(--hover-bg); }
1837
+ .modal-actions button.primary { background: var(--accent); color: var(--text-inverse); border-color: var(--accent); }
1838
+ .modal-actions button.primary:hover { background: #7dd3fc; }
1839
+ body.dragging { user-select: none; cursor: crosshair; }
1840
+ body.dragging .diff-line { cursor: crosshair; }
1841
+ @media (max-width: 840px) {
1842
+ header { flex-direction: column; align-items: flex-start; }
1843
+ .comment-list { width: calc(100% - 24px); right: 12px; }
1844
+ }
1845
+ </style>
1846
+ </head>
1847
+ <body>
1848
+ <header>
1849
+ <div class="meta">
1850
+ <h1>${title}</h1>
1851
+ <span class="badge">Click to comment / ESC to cancel</span>
1852
+ <span class="pill">Comments <strong id="comment-count">0</strong></span>
1853
+ </div>
1854
+ <div class="actions">
1855
+ <button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
1856
+ <span id="theme-icon">🌙</span>
1857
+ </button>
1858
+ <button id="send-and-exit">Submit & Exit</button>
1859
+ </div>
1860
+ </header>
1861
+
1862
+ <div class="wrap">
1863
+ ${hasPreview && mode === 'markdown'
1864
+ ? `<div class="md-layout">
1865
+ <div class="md-left">
1866
+ <div class="md-preview">${previewHtml}</div>
1867
+ </div>
1868
+ <div class="md-right">
1869
+ <div class="table-box">
1870
+ <table id="csv-table">
1871
+ <colgroup id="colgroup"></colgroup>
1872
+ <thead>
1873
+ <tr>
1874
+ <th aria-label="row/col corner"></th>
1875
+ ${Array.from({ length: cols }).map((_, i) => `<th data-col="${i + 1}"><div class="th-inner">${mode === 'csv' ? `C${i + 1}` : 'Text'}<span class="resizer" data-col="${i + 1}"></span></div></th>`).join('')}
1876
+ </tr>
1877
+ </thead>
1878
+ <tbody id="tbody"></tbody>
1879
+ </table>
1880
+ </div>
1881
+ </div>
1882
+ </div>`
1883
+ : `
1884
+ ${hasPreview ? `<div class="md-preview">${previewHtml}</div>` : ''}
1885
+ <div class="toolbar">
1886
+ <button id="fit-width">Fit to width</button>
1887
+ <span>Drag header edge to resize columns</span>
1888
+ </div>
1889
+ <div class="table-box">
1890
+ <table id="csv-table">
1891
+ <colgroup id="colgroup"></colgroup>
1892
+ <thead>
1893
+ <tr>
1894
+ <th aria-label="row/col corner"></th>
1895
+ ${Array.from({ length: cols }).map((_, i) => `<th data-col="${i + 1}"><div class="th-inner">${mode === 'csv' ? `C${i + 1}` : 'Text'}<span class="resizer" data-col="${i + 1}"></span></div></th>`).join('')}
1896
+ </tr>
1897
+ </thead>
1898
+ <tbody id="tbody"></tbody>
1899
+ </table>
1900
+ </div>
1901
+ `}
1902
+ </div>
1903
+
1904
+ <div class="floating" id="comment-card">
1905
+ <header>
1906
+ <h2 id="card-title">Cell Comment</h2>
1907
+ <div style="display:flex; gap:6px;">
1908
+ <button id="close-card">Close</button>
1909
+ <button id="clear-comment">Delete</button>
1910
+ </div>
1911
+ </header>
1912
+ <div id="cell-preview" style="font-size:12px; color: var(--muted); margin-bottom:8px;"></div>
1913
+ <textarea id="comment-input" placeholder="Enter your comment or note"></textarea>
1914
+ <div class="actions">
1915
+ <button class="primary" id="save-comment">Save</button>
1916
+ </div>
1917
+ </div>
1918
+
1919
+ <aside class="comment-list">
1920
+ <h3>Comments</h3>
1921
+ <ol id="comment-list"></ol>
1922
+ <p class="hint">Close the tab or click "Submit & Exit" to send comments and stop the server.</p>
1923
+ </aside>
1924
+ <button class="comment-toggle" id="comment-toggle">Comments (0)</button>
1925
+ <div class="filter-menu" id="filter-menu">
1926
+ <label class="menu-check"><input type="checkbox" id="freeze-col-check" /> Freeze up to this column</label>
1927
+ <button data-action="not-empty">Rows where not empty</button>
1928
+ <button data-action="empty">Rows where empty</button>
1929
+ <button data-action="contains">Contains...</button>
1930
+ <button data-action="not-contains">Does not contain...</button>
1931
+ <button data-action="reset">Clear filter</button>
1932
+ </div>
1933
+ <div class="filter-menu" id="row-menu">
1934
+ <label class="menu-check"><input type="checkbox" id="freeze-row-check" /> Freeze up to this row</label>
1935
+ </div>
1936
+
1937
+ <div class="modal-overlay" id="recovery-modal">
1938
+ <div class="modal-dialog">
1939
+ <h3>Previous Comments Found</h3>
1940
+ <p class="modal-summary" id="recovery-summary"></p>
1941
+ <div class="modal-actions">
1942
+ <button id="recovery-discard">Discard</button>
1943
+ <button class="primary" id="recovery-restore">Restore</button>
1944
+ </div>
1945
+ </div>
1946
+ </div>
1947
+
1948
+ <div class="modal-overlay" id="submit-modal">
1949
+ <div class="modal-dialog">
1950
+ <h3>Submit Review</h3>
1951
+ <p class="modal-summary" id="modal-summary"></p>
1952
+ <label for="global-comment">Overall comment (optional)</label>
1953
+ <textarea id="global-comment" placeholder="Add a summary or overall feedback..."></textarea>
1954
+ <div class="modal-actions">
1955
+ <button id="modal-cancel">Cancel</button>
1956
+ <button class="primary" id="modal-submit">Submit</button>
1957
+ </div>
1958
+ </div>
1959
+ </div>
1960
+
1961
+ <script>
1962
+ const DATA = ${serialized};
1963
+ const MAX_COLS = ${cols};
1964
+ const FILE_NAME = ${titleJson};
1965
+ const MODE = ${modeJson};
1966
+
1967
+ // --- Theme Management ---
1968
+ (function initTheme() {
1969
+ const themeToggle = document.getElementById('theme-toggle');
1970
+ const themeIcon = document.getElementById('theme-icon');
1971
+
1972
+ function getSystemTheme() {
1973
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
1974
+ }
1975
+
1976
+ function getStoredTheme() {
1977
+ return localStorage.getItem('reviw-theme');
1978
+ }
1979
+
1980
+ function setTheme(theme) {
1981
+ if (theme === 'light') {
1982
+ document.documentElement.setAttribute('data-theme', 'light');
1983
+ themeIcon.textContent = '☀️';
1984
+ } else {
1985
+ document.documentElement.removeAttribute('data-theme');
1986
+ themeIcon.textContent = '🌙';
1987
+ }
1988
+ localStorage.setItem('reviw-theme', theme);
1989
+ }
1990
+
1991
+ function toggleTheme() {
1992
+ const currentTheme = document.documentElement.getAttribute('data-theme');
1993
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
1994
+ setTheme(newTheme);
1995
+ }
1996
+
1997
+ // Initialize theme: use stored preference, or fall back to system preference
1998
+ const storedTheme = getStoredTheme();
1999
+ const initialTheme = storedTheme || getSystemTheme();
2000
+ setTheme(initialTheme);
2001
+
2002
+ // Listen for system theme changes (only if no stored preference)
2003
+ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => {
2004
+ if (!getStoredTheme()) {
2005
+ setTheme(e.matches ? 'light' : 'dark');
2006
+ }
2007
+ });
2008
+
2009
+ themeToggle.addEventListener('click', toggleTheme);
2010
+ })();
2011
+
2012
+ const tbody = document.getElementById('tbody');
2013
+ const table = document.getElementById('csv-table');
2014
+ const colgroup = document.getElementById('colgroup');
2015
+ const card = document.getElementById('comment-card');
2016
+ const commentInput = document.getElementById('comment-input');
2017
+ const cardTitle = document.getElementById('card-title');
2018
+ const cellPreview = document.getElementById('cell-preview');
2019
+ const commentList = document.getElementById('comment-list');
2020
+ const commentCount = document.getElementById('comment-count');
2021
+ const fitBtn = document.getElementById('fit-width');
2022
+ const commentPanel = document.querySelector('.comment-list');
2023
+ const commentToggle = document.getElementById('comment-toggle');
2024
+ const filterMenu = document.getElementById('filter-menu');
2025
+ const rowMenu = document.getElementById('row-menu');
2026
+ const freezeColCheck = document.getElementById('freeze-col-check');
2027
+ const freezeRowCheck = document.getElementById('freeze-row-check');
2028
+
2029
+ const ROW_HEADER_WIDTH = 28;
2030
+ const MIN_COL_WIDTH = 80;
2031
+ const MAX_COL_WIDTH = 420;
2032
+ const DEFAULT_COL_WIDTH = 120;
2033
+
2034
+ let colWidths = Array.from({ length: MAX_COLS }, () => DEFAULT_COL_WIDTH);
2035
+ let panelOpen = false;
2036
+ let filters = {}; // colIndex -> predicate
2037
+ let filterTargetCol = null;
2038
+ let freezeCols = 0;
2039
+ let freezeRows = 0;
2040
+ // Explicitly reset state to prevent stale data on reload
2041
+ function resetState() {
2042
+ filters = {};
2043
+ filterTargetCol = null;
2044
+ freezeCols = 0;
2045
+ freezeRows = 0;
2046
+ panelOpen = false;
2047
+ commentPanel.classList.add('collapsed');
2048
+ updateFilterIndicators();
2049
+ updateStickyOffsets();
2050
+ applyFilters();
2051
+ }
2052
+
2053
+ // --- Hot reload (SSE) ---------------------------------------------------
2054
+ (() => {
2055
+ let es = null;
2056
+ const connect = () => {
2057
+ es = new EventSource('/sse');
2058
+ es.onmessage = (ev) => {
2059
+ if (ev.data === 'reload') {
2060
+ location.reload();
2061
+ }
2062
+ };
2063
+ es.onerror = () => {
2064
+ es.close();
2065
+ setTimeout(connect, 1500);
2066
+ };
2067
+ };
2068
+ connect();
2069
+ })();
2070
+ window.addEventListener('pageshow', (e) => {
2071
+ if (e.persisted) location.reload();
2072
+ });
2073
+
2074
+ const comments = {}; // key: "r-c" -> {row, col, text, value} or "r1-c1:r2-c2" for ranges
2075
+ let currentKey = null;
2076
+ // Drag selection state
2077
+ let isDragging = false;
2078
+ let dragStart = null; // {row, col}
2079
+ let dragEnd = null; // {row, col}
2080
+ let selection = null; // {startRow, endRow, startCol, endCol}
2081
+
2082
+ // --- localStorage Comment Persistence ---
2083
+ const STORAGE_KEY = 'reviw:comments:' + FILE_NAME;
2084
+ const STORAGE_TTL = 3 * 60 * 60 * 1000; // 3 hours in milliseconds
2085
+
2086
+ function saveCommentsToStorage() {
2087
+ const data = {
2088
+ comments: { ...comments },
2089
+ timestamp: Date.now()
2090
+ };
2091
+ try {
2092
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
2093
+ } catch (e) {
2094
+ console.warn('Failed to save comments to localStorage:', e);
2095
+ }
2096
+ }
2097
+
2098
+ function loadCommentsFromStorage() {
2099
+ try {
2100
+ const raw = localStorage.getItem(STORAGE_KEY);
2101
+ if (!raw) return null;
2102
+ const data = JSON.parse(raw);
2103
+ // Check TTL
2104
+ if (Date.now() - data.timestamp > STORAGE_TTL) {
2105
+ localStorage.removeItem(STORAGE_KEY);
2106
+ return null;
2107
+ }
2108
+ return data;
2109
+ } catch (e) {
2110
+ console.warn('Failed to load comments from localStorage:', e);
2111
+ return null;
2112
+ }
2113
+ }
2114
+
2115
+ function clearCommentsFromStorage() {
2116
+ try {
2117
+ localStorage.removeItem(STORAGE_KEY);
2118
+ } catch (e) {
2119
+ console.warn('Failed to clear comments from localStorage:', e);
2120
+ }
2121
+ }
2122
+
2123
+ function getTimeAgo(timestamp) {
2124
+ const diff = Date.now() - timestamp;
2125
+ const minutes = Math.floor(diff / 60000);
2126
+ if (minutes < 1) return 'just now';
2127
+ if (minutes < 60) return minutes + ' minute' + (minutes === 1 ? '' : 's') + ' ago';
2128
+ const hours = Math.floor(minutes / 60);
2129
+ return hours + ' hour' + (hours === 1 ? '' : 's') + ' ago';
2130
+ }
2131
+
2132
+ function escapeHtml(str) {
2133
+ return str.replace(/[&<>"]/g, (s) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[s] || s));
2134
+ }
2135
+
2136
+ function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); }
2137
+
2138
+ function syncColgroup() {
2139
+ colgroup.innerHTML = '';
2140
+ const corner = document.createElement('col');
2141
+ corner.style.width = ROW_HEADER_WIDTH + 'px';
2142
+ colgroup.appendChild(corner);
2143
+ colWidths.forEach((w) => {
2144
+ const c = document.createElement('col');
2145
+ c.style.width = w + 'px';
2146
+ colgroup.appendChild(c);
2147
+ });
2148
+ }
2149
+
2150
+ function clearSelection() {
2151
+ tbody.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
2152
+ selection = null;
2153
+ }
2154
+
2155
+ function updateSelectionVisual() {
2156
+ document.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
2157
+ if (!selection) return;
2158
+ const { startRow, endRow, startCol, endCol } = selection;
2159
+ for (let r = startRow; r <= endRow; r++) {
2160
+ for (let c = startCol; c <= endCol; c++) {
2161
+ const td = tbody.querySelector('td[data-row="' + r + '"][data-col="' + c + '"]');
2162
+ if (td) td.classList.add('selected');
2163
+ }
2164
+ // Also highlight row header
2165
+ const th = tbody.querySelector('tr:nth-child(' + r + ') th');
2166
+ if (th) th.classList.add('selected');
2167
+ }
2168
+ // Also highlight column headers
2169
+ for (let c = startCol; c <= endCol; c++) {
2170
+ const colHeader = document.querySelector('thead th[data-col="' + c + '"]');
2171
+ if (colHeader) colHeader.classList.add('selected');
2172
+ }
2173
+ }
2174
+
2175
+ function computeSelection(start, end) {
2176
+ return {
2177
+ startRow: Math.min(start.row, end.row),
2178
+ endRow: Math.max(start.row, end.row),
2179
+ startCol: Math.min(start.col, end.col),
2180
+ endCol: Math.max(start.col, end.col)
2181
+ };
2182
+ }
2183
+
2184
+ function renderTable() {
2185
+ const frag = document.createDocumentFragment();
2186
+ DATA.forEach((row, rIdx) => {
2187
+ const tr = document.createElement('tr');
2188
+ const th = document.createElement('th');
2189
+ th.textContent = rIdx + 1;
2190
+ tr.appendChild(th);
2191
+ for (let c = 0; c < MAX_COLS; c += 1) {
2192
+ const td = document.createElement('td');
2193
+ const val = row[c] || '';
2194
+ td.dataset.row = rIdx + 1;
2195
+ td.dataset.col = c + 1;
2196
+ td.textContent = val;
2197
+ tr.appendChild(td);
2198
+ }
2199
+ frag.appendChild(tr);
2200
+ });
2201
+ tbody.appendChild(frag);
2202
+ // Narrow column width for single-column text/Markdown
2203
+ if (MODE !== 'csv' && MAX_COLS === 1) {
2204
+ colWidths[0] = 240;
2205
+ syncColgroup();
2206
+ }
2207
+ attachDragHandlers();
2208
+ }
2209
+
2210
+ function attachDragHandlers() {
2211
+ tbody.addEventListener('mousedown', (e) => {
2212
+ const td = e.target.closest('td');
2213
+ const th = e.target.closest('tbody th');
2214
+
2215
+ if (td) {
2216
+ // Clicked on a cell
2217
+ e.preventDefault();
2218
+ isDragging = true;
2219
+ document.body.classList.add('dragging');
2220
+ dragStart = { row: Number(td.dataset.row), col: Number(td.dataset.col) };
2221
+ dragEnd = { ...dragStart };
2222
+ selection = computeSelection(dragStart, dragEnd);
2223
+ updateSelectionVisual();
2224
+ } else if (th) {
2225
+ // Clicked on row header - select entire row
2226
+ e.preventDefault();
2227
+ isDragging = true;
2228
+ document.body.classList.add('dragging');
2229
+ const row = Number(th.textContent);
2230
+ dragStart = { row, col: 1, isRowSelect: true };
2231
+ dragEnd = { row, col: MAX_COLS };
2232
+ selection = computeSelection(dragStart, dragEnd);
2233
+ updateSelectionVisual();
2234
+ }
2235
+ });
2236
+
2237
+ document.addEventListener('mousemove', (e) => {
2238
+ if (!isDragging) return;
2239
+ const el = document.elementFromPoint(e.clientX, e.clientY);
2240
+ const td = el?.closest('td');
2241
+ const th = el?.closest('tbody th');
2242
+
2243
+ if (td && td.dataset.row && td.dataset.col) {
2244
+ if (dragStart.isRowSelect) {
2245
+ // Started from row header, extend row selection
2246
+ dragEnd = { row: Number(td.dataset.row), col: MAX_COLS };
2247
+ } else {
2248
+ dragEnd = { row: Number(td.dataset.row), col: Number(td.dataset.col) };
2249
+ }
2250
+ selection = computeSelection(dragStart, dragEnd);
2251
+ updateSelectionVisual();
2252
+ } else if (th) {
2253
+ // Moving over row header
2254
+ const row = Number(th.textContent);
2255
+ if (dragStart.isRowSelect) {
2256
+ dragEnd = { row, col: MAX_COLS };
2257
+ } else {
2258
+ dragEnd = { row, col: dragEnd.col };
2259
+ }
2260
+ selection = computeSelection(dragStart, dragEnd);
2261
+ updateSelectionVisual();
2262
+ }
2263
+ });
2264
+
2265
+ document.addEventListener('mouseup', (e) => {
2266
+ if (!isDragging) return;
2267
+ isDragging = false;
2268
+ document.body.classList.remove('dragging');
2269
+ if (selection) {
2270
+ openCardForSelection();
2271
+ }
2272
+ });
2273
+ }
2274
+
2275
+ function openCardForSelection() {
2276
+ if (!selection) return;
2277
+ const { startRow, endRow, startCol, endCol } = selection;
2278
+ const isSingleCell = startRow === endRow && startCol === endCol;
2279
+
2280
+ if (isSingleCell) {
2281
+ // Single cell - use simple key
2282
+ currentKey = startRow + '-' + startCol;
2283
+ cardTitle.textContent = 'Comment on R' + startRow + ' C' + startCol;
2284
+ const td = tbody.querySelector('td[data-row="' + startRow + '"][data-col="' + startCol + '"]');
2285
+ cellPreview.textContent = td ? 'Cell value: ' + (td.textContent || '(empty)') : '';
2286
+ } else {
2287
+ // Range selection
2288
+ currentKey = startRow + '-' + startCol + ':' + endRow + '-' + endCol;
2289
+ const rowCount = endRow - startRow + 1;
2290
+ const colCount = endCol - startCol + 1;
2291
+ if (startCol === endCol) {
2292
+ cardTitle.textContent = 'Comment on R' + startRow + '-R' + endRow + ' C' + startCol;
2293
+ cellPreview.textContent = 'Selected ' + rowCount + ' rows';
2294
+ } else if (startRow === endRow) {
2295
+ cardTitle.textContent = 'Comment on R' + startRow + ' C' + startCol + '-C' + endCol;
2296
+ cellPreview.textContent = 'Selected ' + colCount + ' columns';
2297
+ } else {
2298
+ cardTitle.textContent = 'Comment on R' + startRow + '-R' + endRow + ' C' + startCol + '-C' + endCol;
2299
+ cellPreview.textContent = 'Selected ' + rowCount + ' x ' + colCount + ' cells';
2300
+ }
2301
+ }
2302
+
2303
+ const existingComment = comments[currentKey];
2304
+ commentInput.value = existingComment?.text || '';
2305
+
2306
+ card.style.display = 'block';
2307
+ positionCardForSelection(startRow, endRow, startCol, endCol);
2308
+ commentInput.focus();
2309
+ }
2310
+
2311
+ function positionCardForSelection(startRow, endRow, startCol, endCol) {
2312
+ const cardWidth = card.offsetWidth || 380;
2313
+ const cardHeight = card.offsetHeight || 220;
2314
+ // Calculate bounding rect for entire selection
2315
+ const topLeftTd = tbody.querySelector('td[data-row="' + startRow + '"][data-col="' + startCol + '"]');
2316
+ const bottomRightTd = tbody.querySelector('td[data-row="' + endRow + '"][data-col="' + endCol + '"]');
2317
+ if (!topLeftTd) return;
2318
+
2319
+ const topLeftRect = topLeftTd.getBoundingClientRect();
2320
+ const bottomRightRect = bottomRightTd ? bottomRightTd.getBoundingClientRect() : topLeftRect;
2321
+
2322
+ // Combined bounding rect for the selection
2323
+ const rect = {
2324
+ left: topLeftRect.left,
2325
+ top: topLeftRect.top,
2326
+ right: bottomRightRect.right,
2327
+ bottom: bottomRightRect.bottom
2328
+ };
2329
+ const margin = 12;
2330
+ const vw = window.innerWidth;
2331
+ const vh = window.innerHeight;
2332
+ const sx = window.scrollX;
2333
+ const sy = window.scrollY;
2334
+
2335
+ const spaceRight = vw - rect.right - margin;
2336
+ const spaceLeft = rect.left - margin - ROW_HEADER_WIDTH; // Account for row header
2337
+ const spaceBelow = vh - rect.bottom - margin;
2338
+ const spaceAbove = rect.top - margin;
2339
+
2340
+ // Minimum left position to avoid covering row header
2341
+ const minLeft = ROW_HEADER_WIDTH + margin;
2342
+
2343
+ let left = sx + rect.right + margin;
2344
+ let top = sy + rect.top;
2345
+
2346
+ // Priority: right > below > above > left > fallback right
2347
+ if (spaceRight >= cardWidth) {
2348
+ // Prefer right side of selection
2349
+ left = sx + rect.right + margin;
2350
+ top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
2351
+ } else if (spaceBelow >= cardHeight) {
2352
+ left = sx + clamp(rect.left, minLeft, vw - cardWidth - margin);
2353
+ top = sy + rect.bottom + margin;
2354
+ } else if (spaceAbove >= cardHeight) {
2355
+ left = sx + clamp(rect.left, minLeft, vw - cardWidth - margin);
2356
+ top = sy + rect.top - cardHeight - margin;
2357
+ } else if (spaceLeft >= cardWidth) {
2358
+ left = sx + rect.left - cardWidth - margin;
2359
+ top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
2360
+ } else {
2361
+ // Fallback: place to right side even if it means going off screen
2362
+ // Position card at right edge of selection, clamped to viewport
2363
+ left = sx + Math.max(rect.right + margin, minLeft);
2364
+ left = Math.min(left, sx + vw - cardWidth - margin);
2365
+ top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
2366
+ }
2367
+
2368
+ card.style.left = left + 'px';
2369
+ card.style.top = top + 'px';
2370
+ }
2371
+
2372
+ function closeCard() {
2373
+ card.style.display = 'none';
2374
+ currentKey = null;
2375
+ clearSelection();
2376
+ }
2377
+
2378
+ function setDot(row, col, on) {
2379
+ const td = tbody.querySelector('td[data-row="' + row + '"][data-col="' + col + '"]');
2380
+ if (!td) return;
2381
+ if (on) {
2382
+ td.classList.add('has-comment');
2383
+ if (!td.querySelector('.dot')) {
2384
+ const dot = document.createElement('span');
2385
+ dot.className = 'dot';
2386
+ td.appendChild(dot);
2387
+ }
2388
+ } else {
2389
+ td.classList.remove('has-comment');
2390
+ const dot = td.querySelector('.dot');
2391
+ if (dot) dot.remove();
2392
+ }
2393
+ }
2394
+
2395
+ function refreshList() {
2396
+ commentList.innerHTML = '';
2397
+ const items = Object.values(comments).sort((a, b) => {
2398
+ const aRow = a.isRange ? a.startRow : a.row;
2399
+ const bRow = b.isRange ? b.startRow : b.row;
2400
+ const aCol = a.isRange ? a.startCol : a.col;
2401
+ const bCol = b.isRange ? b.startCol : b.col;
2402
+ return aRow === bRow ? aCol - bCol : aRow - bRow;
2403
+ });
2404
+ commentCount.textContent = items.length;
2405
+ commentToggle.textContent = 'Comments (' + items.length + ')';
2406
+ if (items.length === 0) {
2407
+ panelOpen = false;
2408
+ }
2409
+ commentPanel.classList.toggle('collapsed', !panelOpen || items.length === 0);
2410
+ if (!items.length) {
2411
+ const li = document.createElement('li');
2412
+ li.className = 'hint';
2413
+ li.textContent = 'No comments yet';
2414
+ commentList.appendChild(li);
2415
+ return;
2416
+ }
2417
+ items.forEach((c) => {
2418
+ const li = document.createElement('li');
2419
+ if (c.isRange) {
2420
+ // Format range label
2421
+ let label;
2422
+ if (c.startCol === c.endCol) {
2423
+ label = 'R' + c.startRow + '-R' + c.endRow + ' C' + c.startCol;
2424
+ } else if (c.startRow === c.endRow) {
2425
+ label = 'R' + c.startRow + ' C' + c.startCol + '-C' + c.endCol;
2426
+ } else {
2427
+ label = 'R' + c.startRow + '-R' + c.endRow + ' C' + c.startCol + '-C' + c.endCol;
2428
+ }
2429
+ li.innerHTML = '<strong>' + label + '</strong> ' + escapeHtml(c.text);
2430
+ li.addEventListener('click', () => {
2431
+ selection = { startRow: c.startRow, endRow: c.endRow, startCol: c.startCol, endCol: c.endCol };
2432
+ updateSelectionVisual();
2433
+ openCardForSelection();
2434
+ });
2435
+ } else {
2436
+ li.innerHTML = '<strong>R' + c.row + ' C' + c.col + '</strong> ' + escapeHtml(c.text);
2437
+ li.addEventListener('click', () => {
2438
+ selection = { startRow: c.row, endRow: c.row, startCol: c.col, endCol: c.col };
2439
+ updateSelectionVisual();
2440
+ openCardForSelection();
2441
+ });
2442
+ }
2443
+ commentList.appendChild(li);
2444
+ });
2445
+ }
2446
+
2447
+ commentToggle.addEventListener('click', () => {
2448
+ panelOpen = !panelOpen;
2449
+ if (panelOpen && Object.keys(comments).length === 0) {
2450
+ panelOpen = false; // keep hidden if no comments
2451
+ }
2452
+ commentPanel.classList.toggle('collapsed', !panelOpen);
2453
+ });
2454
+
2455
+ function updateFilterIndicators() {
2456
+ const headCells = document.querySelectorAll('thead th[data-col]');
2457
+ headCells.forEach((th, idx) => {
2458
+ const active = !!filters[idx];
2459
+ th.classList.toggle('filtered', active);
2460
+ });
2461
+ }
2462
+
2463
+ // --- Filter -----------------------------------------------------------
2464
+ function closeFilterMenu() {
2465
+ filterMenu.style.display = 'none';
2466
+ filterTargetCol = null;
2467
+ }
2468
+ function openFilterMenu(col, anchorRect) {
2469
+ filterTargetCol = col;
2470
+ const margin = 8;
2471
+ const vw = window.innerWidth;
2472
+ const sx = window.scrollX;
2473
+ const sy = window.scrollY;
2474
+ const menuWidth = 200;
2475
+ const menuHeight = 260;
2476
+ let left = sx + clamp(anchorRect.left, margin, vw - menuWidth - margin);
2477
+ let top = sy + anchorRect.bottom + margin;
2478
+ filterMenu.style.left = left + 'px';
2479
+ filterMenu.style.top = top + 'px';
2480
+ filterMenu.style.display = 'block';
2481
+ freezeColCheck.checked = (freezeCols === col + 1);
2482
+ }
2483
+
2484
+ function applyFilters() {
2485
+ const rows = tbody.querySelectorAll('tr');
2486
+ rows.forEach((tr, rIdx) => {
2487
+ const visible = Object.entries(filters).every(([c, fn]) => {
2488
+ const val = DATA[rIdx]?.[Number(c)] || '';
2489
+ try { return fn(val); } catch (_) { return true; }
2490
+ });
2491
+ tr.style.display = visible ? '' : 'none';
2492
+ });
2493
+ updateStickyOffsets();
2494
+ updateFilterIndicators();
2495
+ }
2496
+
2497
+ filterMenu.addEventListener('click', (e) => {
2498
+ const action = e.target.dataset.action;
2499
+ if (!action || filterTargetCol == null) return;
2500
+ const col = filterTargetCol;
2501
+ if (action === 'not-empty') {
2502
+ filters[col] = (v) => (v ?? '').trim() !== '';
2503
+ } else if (action === 'empty') {
2504
+ filters[col] = (v) => (v ?? '').trim() === '';
2505
+ } else if (action === 'contains' || action === 'not-contains') {
2506
+ const keyword = prompt('Enter the text to filter by');
2507
+ if (keyword == null || keyword === '') { closeFilterMenu(); return; }
2508
+ const lower = keyword.toLowerCase();
2509
+ if (action === 'contains') {
2510
+ filters[col] = (v) => (v ?? '').toLowerCase().includes(lower);
2511
+ } else {
2512
+ filters[col] = (v) => !(v ?? '').toLowerCase().includes(lower);
2513
+ }
2514
+ } else if (action === 'reset') {
2515
+ delete filters[col];
2516
+ }
2517
+ closeFilterMenu();
2518
+ applyFilters();
2519
+ updateFilterIndicators();
2520
+ });
2521
+
2522
+ freezeColCheck.addEventListener('change', () => {
2523
+ if (filterTargetCol == null) return;
2524
+ freezeCols = freezeColCheck.checked ? filterTargetCol + 1 : 0;
2525
+ updateStickyOffsets();
2526
+ });
2527
+
2528
+ document.addEventListener('click', (e) => {
2529
+ if (filterMenu.style.display === 'block' && !filterMenu.contains(e.target)) {
2530
+ closeFilterMenu();
2531
+ }
2532
+ });
2533
+
2534
+ // --- Row Menu (Freeze Row) ---------------------------------------------
2535
+ function closeRowMenu() {
2536
+ rowMenu.style.display = 'none';
2537
+ rowMenu.dataset.row = '';
2538
+ }
2539
+ function openRowMenu(row, anchorRect) {
2540
+ rowMenu.dataset.row = String(row);
2541
+ freezeRowCheck.checked = (freezeRows === row);
2542
+ const margin = 8;
2543
+ const vw = window.innerWidth;
2544
+ const sx = window.scrollX;
2545
+ const sy = window.scrollY;
2546
+ const menuWidth = 180;
2547
+ const menuHeight = 80;
2548
+ let left = sx + clamp(anchorRect.left, margin, vw - menuWidth - margin);
2549
+ let top = sy + anchorRect.bottom + margin;
2550
+ rowMenu.style.left = left + 'px';
2551
+ rowMenu.style.top = top + 'px';
2552
+ rowMenu.style.display = 'block';
2553
+ }
2554
+ freezeRowCheck.addEventListener('change', () => {
2555
+ const r = Number(rowMenu.dataset.row || 0);
2556
+ freezeRows = freezeRowCheck.checked ? r : 0;
2557
+ updateStickyOffsets();
2558
+ });
2559
+ document.addEventListener('click', (e) => {
2560
+ if (rowMenu.style.display === 'block' && !rowMenu.contains(e.target) && !(e.target.dataset && e.target.dataset.rowHeader)) {
2561
+ closeRowMenu();
2562
+ }
2563
+ });
2564
+
2565
+ function isRangeKey(key) {
2566
+ return key && key.includes(':');
2567
+ }
2568
+
2569
+ function parseRangeKey(key) {
2570
+ // Format: "startRow-startCol:endRow-endCol"
2571
+ const [start, end] = key.split(':');
2572
+ const [startRow, startCol] = start.split('-').map(Number);
2573
+ const [endRow, endCol] = end.split('-').map(Number);
2574
+ return { startRow, startCol, endRow, endCol };
2575
+ }
2576
+
2577
+ function saveCurrent() {
2578
+ if (!currentKey) return;
2579
+ const text = commentInput.value.trim();
2580
+
2581
+ if (isRangeKey(currentKey)) {
2582
+ // Range (rectangular) comment
2583
+ const { startRow, startCol, endRow, endCol } = parseRangeKey(currentKey);
2584
+ if (text) {
2585
+ comments[currentKey] = { startRow, startCol, endRow, endCol, text, isRange: true };
2586
+ for (let r = startRow; r <= endRow; r++) {
2587
+ for (let c = startCol; c <= endCol; c++) {
2588
+ setDot(r, c, true);
2589
+ }
2590
+ }
2591
+ } else {
2592
+ delete comments[currentKey];
2593
+ for (let r = startRow; r <= endRow; r++) {
2594
+ for (let c = startCol; c <= endCol; c++) {
2595
+ const singleKey = r + '-' + c;
2596
+ if (!comments[singleKey]) {
2597
+ setDot(r, c, false);
2598
+ }
2599
+ }
2600
+ }
2601
+ }
2602
+ } else {
2603
+ // Single cell comment
2604
+ const [row, col] = currentKey.split('-').map(Number);
2605
+ const td = tbody.querySelector('td[data-row="' + row + '"][data-col="' + col + '"]');
2606
+ const value = td ? td.textContent : '';
2607
+ if (text) {
2608
+ comments[currentKey] = { row, col, text, value };
2609
+ setDot(row, col, true);
2610
+ } else {
2611
+ delete comments[currentKey];
2612
+ setDot(row, col, false);
2613
+ }
2614
+ }
2615
+ refreshList();
2616
+ closeCard();
2617
+ saveCommentsToStorage();
2618
+ }
2619
+
2620
+ function clearCurrent() {
2621
+ if (!currentKey) return;
2622
+
2623
+ if (isRangeKey(currentKey)) {
2624
+ const { startRow, startCol, endRow, endCol } = parseRangeKey(currentKey);
2625
+ delete comments[currentKey];
2626
+ for (let r = startRow; r <= endRow; r++) {
2627
+ for (let c = startCol; c <= endCol; c++) {
2628
+ const singleKey = r + '-' + c;
2629
+ if (!comments[singleKey]) {
2630
+ setDot(r, c, false);
2631
+ }
2632
+ }
2633
+ }
2634
+ } else {
2635
+ const [row, col] = currentKey.split('-').map(Number);
2636
+ delete comments[currentKey];
2637
+ setDot(row, col, false);
2638
+ }
2639
+ refreshList();
2640
+ closeCard();
2641
+ saveCommentsToStorage();
2642
+ }
2643
+
2644
+ document.getElementById('save-comment').addEventListener('click', saveCurrent);
2645
+ document.getElementById('clear-comment').addEventListener('click', clearCurrent);
2646
+ document.getElementById('close-card').addEventListener('click', closeCard);
2647
+ document.addEventListener('keydown', (e) => {
2648
+ if (e.key === 'Escape') closeCard();
2649
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') saveCurrent();
2650
+ });
2651
+
2652
+ // --- Column Resize -----------------------------------------------------
2653
+ function startResize(colIdx, event) {
2654
+ event.preventDefault();
2655
+ const startX = event.clientX;
2656
+ const startW = colWidths[colIdx - 1];
2657
+ function onMove(e) {
2658
+ const next = clamp(startW + (e.clientX - startX), MIN_COL_WIDTH, MAX_COL_WIDTH);
2659
+ colWidths[colIdx - 1] = next;
2660
+ syncColgroup();
2661
+ updateStickyOffsets();
2662
+ }
2663
+ function onUp() {
2664
+ window.removeEventListener('pointermove', onMove);
2665
+ window.removeEventListener('pointerup', onUp);
2666
+ }
2667
+ window.addEventListener('pointermove', onMove);
2668
+ window.addEventListener('pointerup', onUp);
2669
+ }
2670
+
2671
+ function attachResizers() {
2672
+ document.querySelectorAll('.resizer').forEach((r) => {
2673
+ r.addEventListener('pointerdown', (e) => {
2674
+ e.stopPropagation();
2675
+ startResize(Number(r.dataset.col), e);
2676
+ });
2677
+ });
2678
+ document.querySelectorAll('thead th .th-inner').forEach((h) => {
2679
+ h.addEventListener('click', (e) => {
2680
+ const col = Number(h.parentElement.dataset.col);
2681
+ const rect = h.getBoundingClientRect();
2682
+ openFilterMenu(col - 1, rect);
2683
+ e.stopPropagation();
2684
+ });
2685
+ });
2686
+ // Click on row header (row number) to open freeze row menu
2687
+ tbody.querySelectorAll('th').forEach((th) => {
2688
+ th.dataset.rowHeader = '1';
2689
+ th.addEventListener('click', (e) => {
2690
+ const row = Number(th.textContent);
2691
+ const rect = th.getBoundingClientRect();
2692
+ openRowMenu(row, rect);
2693
+ e.stopPropagation();
2694
+ });
2695
+ });
2696
+ }
2697
+
2698
+ // --- Freeze Columns/Rows -----------------------------------------------
2699
+ function updateStickyOffsets() {
2700
+ freezeCols = Number(freezeCols || 0);
2701
+ freezeRows = Number(freezeRows || 0);
2702
+
2703
+ // columns
2704
+ const headCells = document.querySelectorAll('thead th[data-col]');
2705
+ let hLeft = ROW_HEADER_WIDTH;
2706
+ headCells.forEach((th, idx) => {
2707
+ if (idx < freezeCols) {
2708
+ th.classList.add('freeze');
2709
+ th.style.left = hLeft + 'px';
2710
+ hLeft += colWidths[idx];
2711
+ } else {
2712
+ th.classList.remove('freeze');
2713
+ th.style.left = null;
2714
+ }
2715
+ });
2716
+
2717
+ // rows top offsets
2718
+ const rows = Array.from(tbody.querySelectorAll('tr'));
2719
+ const headHeight = document.querySelector('thead').offsetHeight || 0;
2720
+ let accumTop = headHeight;
2721
+ rows.forEach((tr, rIdx) => {
2722
+ const isFrozen = rIdx < freezeRows;
2723
+ const rowTop = accumTop;
2724
+ const cells = Array.from(tr.children);
2725
+ cells.forEach((cell, cIdx) => {
2726
+ if (isFrozen) {
2727
+ cell.classList.add('freeze-row');
2728
+ cell.style.top = rowTop + 'px';
2729
+ // z-index handling for intersections
2730
+ if (cell.classList.contains('freeze')) {
2731
+ cell.style.zIndex = 7;
2732
+ } else if (cell.tagName === 'TH') {
2733
+ cell.style.zIndex = 6;
2734
+ } else {
2735
+ cell.style.zIndex = 5;
2736
+ }
2737
+ } else {
2738
+ cell.classList.remove('freeze-row');
2739
+ cell.style.top = null;
2740
+ if (!cell.classList.contains('freeze')) {
2741
+ cell.style.zIndex = null;
2742
+ }
2743
+ }
2744
+ // left offsets for frozen cols inside rows
2745
+ if (cIdx > 0) {
2746
+ const colIdx = cIdx - 1;
2747
+ if (colIdx < freezeCols) {
2748
+ cell.classList.add('freeze');
2749
+ const left = ROW_HEADER_WIDTH + colWidths.slice(0, colIdx).reduce((a, b) => a + b, 0);
2750
+ cell.style.left = left + 'px';
2751
+ } else {
2752
+ cell.classList.remove('freeze');
2753
+ cell.style.left = null;
2754
+ }
2755
+ }
2756
+ });
2757
+ accumTop += tr.offsetHeight;
2758
+ });
2759
+ }
2760
+ // --- Fit to Width ------------------------------------------------------
2761
+ function fitToWidth() {
2762
+ const box = document.querySelector('.table-box');
2763
+ const available = box.clientWidth - ROW_HEADER_WIDTH - 24;
2764
+ const sum = colWidths.reduce((a, b) => a + b, 0);
2765
+ if (sum === 0 || available <= 0) return;
2766
+ const scale = clamp(available / sum, 0.4, 2);
2767
+ colWidths = colWidths.map((w) => clamp(Math.round(w * scale), MIN_COL_WIDTH, MAX_COL_WIDTH));
2768
+ syncColgroup();
2769
+ updateStickyOffsets();
2770
+ }
2771
+ if (fitBtn) fitBtn.addEventListener('click', fitToWidth);
2772
+
2773
+ // --- Submit & Exit -----------------------------------------------------
2774
+ let sent = false;
2775
+ let globalComment = '';
2776
+ const submitModal = document.getElementById('submit-modal');
2777
+ const modalSummary = document.getElementById('modal-summary');
2778
+ const globalCommentInput = document.getElementById('global-comment');
2779
+ const modalCancel = document.getElementById('modal-cancel');
2780
+ const modalSubmit = document.getElementById('modal-submit');
2781
+
2782
+ function payload(reason) {
2783
+ const data = {
2784
+ file: FILE_NAME,
2785
+ mode: MODE,
2786
+ reason,
2787
+ at: new Date().toISOString(),
2788
+ comments: Object.values(comments)
2789
+ };
2790
+ if (globalComment.trim()) {
2791
+ data.summary = globalComment.trim();
2792
+ }
2793
+ return data;
2794
+ }
2795
+ function sendAndExit(reason = 'pagehide') {
2796
+ if (sent) return;
2797
+ sent = true;
2798
+ clearCommentsFromStorage();
2799
+ const blob = new Blob([JSON.stringify(payload(reason))], { type: 'application/json' });
2800
+ navigator.sendBeacon('/exit', blob);
2801
+ }
2802
+ function showSubmitModal() {
2803
+ const count = Object.keys(comments).length;
2804
+ modalSummary.textContent = count === 0
2805
+ ? 'No comments added yet.'
2806
+ : count === 1 ? '1 comment will be submitted.' : count + ' comments will be submitted.';
2807
+ globalCommentInput.value = globalComment;
2808
+ submitModal.classList.add('visible');
2809
+ globalCommentInput.focus();
2810
+ }
2811
+ function hideSubmitModal() {
2812
+ submitModal.classList.remove('visible');
2813
+ }
2814
+ document.getElementById('send-and-exit').addEventListener('click', showSubmitModal);
2815
+ modalCancel.addEventListener('click', hideSubmitModal);
2816
+ function doSubmit() {
2817
+ globalComment = globalCommentInput.value;
2818
+ hideSubmitModal();
2819
+ sendAndExit('button');
2820
+ setTimeout(() => window.close(), 200);
2821
+ }
2822
+ modalSubmit.addEventListener('click', doSubmit);
2823
+ globalCommentInput.addEventListener('keydown', (e) => {
2824
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
2825
+ e.preventDefault();
2826
+ doSubmit();
2827
+ }
2828
+ });
2829
+ submitModal.addEventListener('click', (e) => {
2830
+ if (e.target === submitModal) hideSubmitModal();
2831
+ });
2832
+ // Note: We no longer auto-submit on page close/reload.
2833
+ // Users must explicitly click "Submit & Exit" to save comments.
2834
+ // This allows page refresh without losing the server connection.
2835
+
2836
+ syncColgroup();
2837
+ renderTable();
2838
+ attachResizers();
2839
+ updateStickyOffsets();
2840
+ updateFilterIndicators();
2841
+ refreshList();
2842
+ resetState();
2843
+
2844
+ // --- Comment Recovery from localStorage ---
2845
+ (function checkRecovery() {
2846
+ const stored = loadCommentsFromStorage();
2847
+ if (!stored || Object.keys(stored.comments).length === 0) return;
2848
+
2849
+ const recoveryModal = document.getElementById('recovery-modal');
2850
+ const recoverySummary = document.getElementById('recovery-summary');
2851
+ const recoveryDiscard = document.getElementById('recovery-discard');
2852
+ const recoveryRestore = document.getElementById('recovery-restore');
2853
+
2854
+ const count = Object.keys(stored.comments).length;
2855
+ const timeAgo = getTimeAgo(stored.timestamp);
2856
+ recoverySummary.textContent = count + ' comment' + (count === 1 ? '' : 's') + ' from ' + timeAgo;
2857
+
2858
+ recoveryModal.classList.add('visible');
2859
+
2860
+ function hideRecoveryModal() {
2861
+ recoveryModal.classList.remove('visible');
2862
+ }
2863
+
2864
+ recoveryDiscard.addEventListener('click', () => {
2865
+ clearCommentsFromStorage();
2866
+ hideRecoveryModal();
2867
+ });
2868
+
2869
+ recoveryRestore.addEventListener('click', () => {
2870
+ // Restore comments
2871
+ Object.assign(comments, stored.comments);
2872
+ // Update dots and list
2873
+ Object.values(stored.comments).forEach(c => {
2874
+ if (c.isRange) {
2875
+ for (let r = c.startRow; r <= c.endRow; r++) {
2876
+ for (let col = c.startCol; col <= c.endCol; col++) {
2877
+ setDot(r, col, true);
2878
+ }
2879
+ }
2880
+ } else {
2881
+ setDot(c.row, c.col, true);
2882
+ }
2883
+ });
2884
+ refreshList();
2885
+ hideRecoveryModal();
2886
+ });
2887
+
2888
+ recoveryModal.addEventListener('click', (e) => {
2889
+ if (e.target === recoveryModal) {
2890
+ clearCommentsFromStorage();
2891
+ hideRecoveryModal();
2892
+ }
2893
+ });
2894
+ })();
2895
+
2896
+ // --- Scroll Sync for Markdown Mode ---
2897
+ if (MODE === 'markdown') {
2898
+ const mdLeft = document.querySelector('.md-left');
2899
+ const mdRight = document.querySelector('.md-right');
2900
+ if (mdLeft && mdRight) {
2901
+ let activePane = null;
2902
+ let rafId = null;
2903
+
2904
+ function syncScroll(source, target, sourceName) {
2905
+ // Only sync if this pane initiated the scroll
2906
+ if (activePane && activePane !== sourceName) return;
2907
+ activePane = sourceName;
2908
+
2909
+ if (rafId) cancelAnimationFrame(rafId);
2910
+ rafId = requestAnimationFrame(() => {
2911
+ const sourceMax = source.scrollHeight - source.clientHeight;
2912
+ const targetMax = target.scrollHeight - target.clientHeight;
2913
+
2914
+ if (sourceMax <= 0 || targetMax <= 0) return;
2915
+
2916
+ // Snap to edges for precision
2917
+ if (source.scrollTop <= 1) {
2918
+ target.scrollTop = 0;
2919
+ } else if (source.scrollTop >= sourceMax - 1) {
2920
+ target.scrollTop = targetMax;
2921
+ } else {
2922
+ const ratio = source.scrollTop / sourceMax;
2923
+ target.scrollTop = Math.round(ratio * targetMax);
2924
+ }
2925
+
2926
+ // Release lock after scroll settles
2927
+ setTimeout(() => { activePane = null; }, 100);
2928
+ });
2929
+ }
2930
+
2931
+ mdLeft.addEventListener('scroll', () => syncScroll(mdLeft, mdRight, 'left'), { passive: true });
2932
+ mdRight.addEventListener('scroll', () => syncScroll(mdRight, mdLeft, 'right'), { passive: true });
2933
+ }
2934
+ }
2935
+ </script>
2936
+ </body>
2937
+ </html>`;
2938
+ }
2939
+
2940
+ function buildHtml(filePath) {
2941
+ const data = loadData(filePath);
2942
+ if (data.mode === 'diff') {
2943
+ return diffHtmlTemplate(data);
2944
+ }
2945
+ const { rows, cols, title, mode, preview } = data;
2946
+ return htmlTemplate(rows, cols, title, mode, preview);
2947
+ }
2948
+
2949
+ // --- HTTP Server -----------------------------------------------------------
2950
+ function readBody(req) {
2951
+ return new Promise((resolve, reject) => {
2952
+ let data = '';
2953
+ req.on('data', (chunk) => {
2954
+ data += chunk;
2955
+ if (data.length > 2 * 1024 * 1024) {
2956
+ reject(new Error('payload too large'));
2957
+ req.destroy();
2958
+ }
2959
+ });
2960
+ req.on('end', () => resolve(data));
2961
+ req.on('error', reject);
2962
+ });
2963
+ }
2964
+
2965
+ const MAX_PORT_ATTEMPTS = 100;
2966
+ const activeServers = new Map();
2967
+
2968
+ function outputAllResults() {
2969
+ console.log('=== All comments received ===');
2970
+ if (allResults.length === 1) {
2971
+ const yamlOut = yaml.dump(allResults[0], { noRefs: true, lineWidth: 120 });
2972
+ console.log(yamlOut.trim());
2973
+ } else {
2974
+ const combined = { files: allResults };
2975
+ const yamlOut = yaml.dump(combined, { noRefs: true, lineWidth: 120 });
2976
+ console.log(yamlOut.trim());
2977
+ }
2978
+ }
2979
+
2980
+ function checkAllDone() {
2981
+ if (serversRunning === 0) {
2982
+ outputAllResults();
2983
+ process.exit(0);
2984
+ }
2985
+ }
2986
+
2987
+ function shutdownAll() {
2988
+ for (const ctx of activeServers.values()) {
2989
+ if (ctx.watcher) ctx.watcher.close();
2990
+ if (ctx.heartbeat) clearInterval(ctx.heartbeat);
2991
+ ctx.sseClients.forEach((res) => { try { res.end(); } catch (_) {} });
2992
+ if (ctx.server) ctx.server.close();
2993
+ }
2994
+ outputAllResults();
2995
+ setTimeout(() => process.exit(0), 500).unref();
2996
+ }
2997
+
2998
+ process.on('SIGINT', shutdownAll);
2999
+ process.on('SIGTERM', shutdownAll);
3000
+
3001
+ function createFileServer(filePath) {
3002
+ return new Promise((resolve) => {
3003
+ const baseName = path.basename(filePath);
3004
+ const baseDir = path.dirname(filePath);
3005
+
3006
+ const ctx = {
3007
+ filePath,
3008
+ baseName,
3009
+ baseDir,
3010
+ sseClients: new Set(),
3011
+ watcher: null,
3012
+ heartbeat: null,
3013
+ reloadTimer: null,
3014
+ server: null,
3015
+ opened: false,
3016
+ port: 0
3017
+ };
3018
+
3019
+ function broadcast(data) {
3020
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
3021
+ ctx.sseClients.forEach((res) => {
3022
+ try { res.write(`data: ${payload}\n\n`); } catch (_) {}
3023
+ });
3024
+ }
3025
+
3026
+ function notifyReload() {
3027
+ clearTimeout(ctx.reloadTimer);
3028
+ ctx.reloadTimer = setTimeout(() => broadcast('reload'), 150);
3029
+ }
3030
+
3031
+ function startWatcher() {
3032
+ try {
3033
+ ctx.watcher = fs.watch(filePath, { persistent: true }, notifyReload);
3034
+ } catch (err) {
3035
+ console.warn(`Failed to start file watcher for ${baseName}:`, err);
3036
+ }
3037
+ ctx.heartbeat = setInterval(() => broadcast('ping'), 25000);
3038
+ }
3039
+
3040
+ function shutdownServer(result) {
3041
+ if (ctx.watcher) {
3042
+ ctx.watcher.close();
3043
+ ctx.watcher = null;
3044
+ }
3045
+ if (ctx.heartbeat) {
3046
+ clearInterval(ctx.heartbeat);
3047
+ ctx.heartbeat = null;
3048
+ }
3049
+ ctx.sseClients.forEach((res) => { try { res.end(); } catch (_) {} });
3050
+ if (ctx.server) {
3051
+ ctx.server.close();
3052
+ ctx.server = null;
3053
+ }
3054
+ activeServers.delete(filePath);
3055
+ if (result) allResults.push(result);
3056
+ serversRunning--;
3057
+ console.log(`Server for ${baseName} closed. (${serversRunning} remaining)`);
3058
+ checkAllDone();
3059
+ }
3060
+
3061
+ ctx.server = http.createServer(async (req, res) => {
3062
+ if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
3063
+ try {
3064
+ const html = buildHtml(filePath);
3065
+ res.writeHead(200, {
3066
+ 'Content-Type': 'text/html; charset=utf-8',
3067
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
3068
+ Pragma: 'no-cache',
3069
+ Expires: '0'
3070
+ });
3071
+ res.end(html);
3072
+ } catch (err) {
3073
+ console.error('File load error', err);
3074
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
3075
+ res.end('Failed to load file. Please check the file.');
3076
+ }
3077
+ return;
3078
+ }
3079
+
3080
+ if (req.method === 'GET' && req.url === '/healthz') {
3081
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
3082
+ res.end('ok');
3083
+ return;
3084
+ }
3085
+
3086
+ if (req.method === 'POST' && req.url === '/exit') {
3087
+ try {
3088
+ const raw = await readBody(req);
3089
+ let payload = {};
3090
+ if (raw && raw.trim()) {
3091
+ payload = JSON.parse(raw);
3092
+ }
3093
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
3094
+ res.end('bye');
3095
+ shutdownServer(payload);
3096
+ } catch (err) {
3097
+ console.error('payload parse error', err);
3098
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
3099
+ res.end('bad request');
3100
+ shutdownServer(null);
3101
+ }
3102
+ return;
3103
+ }
3104
+
3105
+ if (req.method === 'GET' && req.url === '/sse') {
3106
+ res.writeHead(200, {
3107
+ 'Content-Type': 'text/event-stream',
3108
+ 'Cache-Control': 'no-cache',
3109
+ Connection: 'keep-alive',
3110
+ 'X-Accel-Buffering': 'no'
3111
+ });
3112
+ res.write('retry: 3000\n\n');
3113
+ ctx.sseClients.add(res);
3114
+ req.on('close', () => ctx.sseClients.delete(res));
3115
+ return;
3116
+ }
3117
+
3118
+ // Static file serving for images and other assets
3119
+ if (req.method === 'GET') {
3120
+ const MIME_TYPES = {
3121
+ '.png': 'image/png',
3122
+ '.jpg': 'image/jpeg',
3123
+ '.jpeg': 'image/jpeg',
3124
+ '.gif': 'image/gif',
3125
+ '.webp': 'image/webp',
3126
+ '.svg': 'image/svg+xml',
3127
+ '.ico': 'image/x-icon',
3128
+ '.css': 'text/css',
3129
+ '.js': 'application/javascript',
3130
+ '.json': 'application/json',
3131
+ '.pdf': 'application/pdf',
3132
+ };
3133
+ try {
3134
+ const urlPath = decodeURIComponent(req.url.split('?')[0]);
3135
+ if (urlPath.includes('..')) {
3136
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
3137
+ res.end('forbidden');
3138
+ return;
3139
+ }
3140
+ const staticPath = path.join(baseDir, urlPath);
3141
+ if (!staticPath.startsWith(baseDir)) {
3142
+ res.writeHead(403, { 'Content-Type': 'text/plain' });
3143
+ res.end('forbidden');
3144
+ return;
3145
+ }
3146
+ if (fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) {
3147
+ const ext = path.extname(staticPath).toLowerCase();
3148
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
3149
+ const content = fs.readFileSync(staticPath);
3150
+ res.writeHead(200, { 'Content-Type': contentType });
3151
+ res.end(content);
3152
+ return;
3153
+ }
3154
+ } catch (err) {
3155
+ // fall through to 404
3156
+ }
3157
+ }
3158
+
3159
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
3160
+ res.end('not found');
3161
+ });
3162
+
3163
+ function tryListen(attemptPort, attempts = 0) {
3164
+ if (attempts >= MAX_PORT_ATTEMPTS) {
3165
+ console.error(`Could not find an available port for ${baseName} after ${MAX_PORT_ATTEMPTS} attempts.`);
3166
+ serversRunning--;
3167
+ checkAllDone();
3168
+ return;
3169
+ }
3170
+
3171
+ ctx.server.once('error', (err) => {
3172
+ if (err.code === 'EADDRINUSE') {
3173
+ tryListen(attemptPort + 1, attempts + 1);
3174
+ } else {
3175
+ console.error(`Server error for ${baseName}:`, err);
3176
+ serversRunning--;
3177
+ checkAllDone();
3178
+ }
3179
+ });
3180
+
3181
+ ctx.server.listen(attemptPort, () => {
3182
+ ctx.port = attemptPort;
3183
+ nextPort = attemptPort + 1;
3184
+ activeServers.set(filePath, ctx);
3185
+ console.log(`Viewer started: http://localhost:${attemptPort} (file: ${baseName})`);
3186
+ if (!noOpen) {
3187
+ const url = `http://localhost:${attemptPort}`;
3188
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
3189
+ try {
3190
+ spawn(opener, [url], { stdio: 'ignore', detached: true });
3191
+ } catch (err) {
3192
+ console.warn('Failed to open browser automatically. Please open this URL manually:', url);
3193
+ }
3194
+ }
3195
+ startWatcher();
3196
+ resolve(ctx);
3197
+ });
3198
+ }
3199
+
3200
+ tryListen(nextPort);
3201
+ });
3202
+ }
3203
+
3204
+ // Create server for diff mode
3205
+ function createDiffServer(diffContent) {
3206
+ return new Promise((resolve) => {
3207
+ const diffData = loadDiff(diffContent);
3208
+
3209
+ const ctx = {
3210
+ diffData,
3211
+ sseClients: new Set(),
3212
+ heartbeat: null,
3213
+ server: null,
3214
+ port: 0
3215
+ };
3216
+
3217
+ function broadcast(data) {
3218
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
3219
+ ctx.sseClients.forEach((res) => {
3220
+ try { res.write(`data: ${payload}\n\n`); } catch (_) {}
3221
+ });
3222
+ }
3223
+
3224
+ function shutdownServer(result) {
3225
+ if (ctx.heartbeat) {
3226
+ clearInterval(ctx.heartbeat);
3227
+ ctx.heartbeat = null;
3228
+ }
3229
+ ctx.sseClients.forEach((res) => { try { res.end(); } catch (_) {} });
3230
+ if (ctx.server) {
3231
+ ctx.server.close();
3232
+ ctx.server = null;
3233
+ }
3234
+ if (result) allResults.push(result);
3235
+ serversRunning--;
3236
+ console.log(`Diff server closed. (${serversRunning} remaining)`);
3237
+ checkAllDone();
3238
+ }
3239
+
3240
+ ctx.server = http.createServer(async (req, res) => {
3241
+ if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
3242
+ try {
3243
+ const html = diffHtmlTemplate(diffData);
3244
+ res.writeHead(200, {
3245
+ 'Content-Type': 'text/html; charset=utf-8',
3246
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
3247
+ Pragma: 'no-cache',
3248
+ Expires: '0'
3249
+ });
3250
+ res.end(html);
3251
+ } catch (err) {
3252
+ console.error('Diff render error', err);
3253
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
3254
+ res.end('Failed to render diff view.');
3255
+ }
3256
+ return;
3257
+ }
3258
+
3259
+ if (req.method === 'GET' && req.url === '/healthz') {
3260
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
3261
+ res.end('ok');
3262
+ return;
3263
+ }
3264
+
3265
+ if (req.method === 'POST' && req.url === '/exit') {
3266
+ try {
3267
+ const raw = await readBody(req);
3268
+ let payload = {};
3269
+ if (raw && raw.trim()) {
3270
+ payload = JSON.parse(raw);
3271
+ }
3272
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
3273
+ res.end('bye');
3274
+ shutdownServer(payload);
3275
+ } catch (err) {
3276
+ console.error('payload parse error', err);
3277
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
3278
+ res.end('bad request');
3279
+ shutdownServer(null);
3280
+ }
3281
+ return;
3282
+ }
3283
+
3284
+ if (req.method === 'GET' && req.url === '/sse') {
3285
+ res.writeHead(200, {
3286
+ 'Content-Type': 'text/event-stream',
3287
+ 'Cache-Control': 'no-cache',
3288
+ Connection: 'keep-alive',
3289
+ 'X-Accel-Buffering': 'no'
3290
+ });
3291
+ res.write('retry: 3000\n\n');
3292
+ ctx.sseClients.add(res);
3293
+ req.on('close', () => ctx.sseClients.delete(res));
3294
+ return;
3295
+ }
3296
+
3297
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
3298
+ res.end('not found');
3299
+ });
3300
+
3301
+ function tryListen(attemptPort, attempts = 0) {
3302
+ if (attempts >= MAX_PORT_ATTEMPTS) {
3303
+ console.error(`Could not find an available port for diff viewer after ${MAX_PORT_ATTEMPTS} attempts.`);
3304
+ serversRunning--;
3305
+ checkAllDone();
3306
+ return;
3307
+ }
3308
+
3309
+ ctx.server.once('error', (err) => {
3310
+ if (err.code === 'EADDRINUSE') {
3311
+ tryListen(attemptPort + 1, attempts + 1);
3312
+ } else {
3313
+ console.error('Diff server error:', err);
3314
+ serversRunning--;
3315
+ checkAllDone();
3316
+ }
3317
+ });
3318
+
3319
+ ctx.server.listen(attemptPort, () => {
3320
+ ctx.port = attemptPort;
3321
+ ctx.heartbeat = setInterval(() => broadcast('ping'), 25000);
3322
+ console.log(`Diff viewer started: http://localhost:${attemptPort}`);
3323
+ if (!noOpen) {
3324
+ const url = `http://localhost:${attemptPort}`;
3325
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
3326
+ try {
3327
+ spawn(opener, [url], { stdio: 'ignore', detached: true });
3328
+ } catch (err) {
3329
+ console.warn('Failed to open browser automatically. Please open this URL manually:', url);
3330
+ }
3331
+ }
3332
+ resolve(ctx);
3333
+ });
3334
+ }
3335
+
3336
+ tryListen(basePort);
3337
+ });
3338
+ }
3339
+
3340
+ // Main entry point
3341
+ (async () => {
3342
+ // Check for stdin input first
3343
+ const stdinData = await checkStdin();
3344
+
3345
+ if (stdinData) {
3346
+ // Pipe mode: stdin has data
3347
+ stdinMode = true;
3348
+ stdinContent = stdinData;
3349
+
3350
+ // Check if it looks like a diff
3351
+ if (stdinContent.startsWith('diff --git') || stdinContent.includes('\n+++ ') || stdinContent.includes('\n--- ')) {
3352
+ diffMode = true;
3353
+ console.log('Starting diff viewer from stdin...');
3354
+ serversRunning = 1;
3355
+ await createDiffServer(stdinContent);
3356
+ console.log('Close the browser tab or Submit & Exit to finish.');
3357
+ } else {
3358
+ // Treat as plain text
3359
+ console.log('Starting text viewer from stdin...');
3360
+ // For now, just show message - could enhance to support any text
3361
+ console.error('Non-diff stdin content is not supported yet. Use a file instead.');
3362
+ process.exit(1);
3363
+ }
3364
+ } else if (resolvedPaths.length > 0) {
3365
+ // File mode: files specified
3366
+ console.log(`Starting servers for ${resolvedPaths.length} file(s)...`);
3367
+ serversRunning = resolvedPaths.length;
3368
+ for (const filePath of resolvedPaths) {
3369
+ await createFileServer(filePath);
3370
+ }
3371
+ console.log('Close all browser tabs or Submit & Exit to finish.');
3372
+ } else {
3373
+ // No files and no stdin: try auto git diff
3374
+ console.log('No files specified. Running git diff HEAD...');
3375
+ try {
3376
+ const gitDiff = await runGitDiff();
3377
+ if (gitDiff.trim() === '') {
3378
+ console.log('No changes detected (working tree clean).');
3379
+ console.log('');
3380
+ console.log('Usage: reviw <file...> [options]');
3381
+ console.log(' git diff | reviw [options]');
3382
+ console.log(' reviw (auto runs git diff HEAD)');
3383
+ process.exit(0);
3384
+ }
3385
+ diffMode = true;
3386
+ stdinContent = gitDiff;
3387
+ console.log('Starting diff viewer...');
3388
+ serversRunning = 1;
3389
+ await createDiffServer(gitDiff);
3390
+ console.log('Close the browser tab or Submit & Exit to finish.');
3391
+ } catch (err) {
3392
+ console.error(err.message);
3393
+ console.log('');
3394
+ console.log('Usage: reviw <file...> [options]');
3395
+ console.log(' git diff | reviw [options]');
3396
+ process.exit(1);
3397
+ }
3398
+ }
3399
+ })();