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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/cli.cjs +3399 -0
- 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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) => ({'&':'&','<':'<','>':'>','"':'"'}[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
|
+
})();
|