reviw 0.16.2 → 0.16.3

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/README.ja.md CHANGED
@@ -286,8 +286,8 @@ npx reviw .artifacts/<feature>/REPORT.md
286
286
  Playwrightを使用したブラウザ自動化ツールキット。
287
287
 
288
288
  **機能:**
289
- - PythonとNode.js Playwrightスクリプト
290
- - サーバーライフサイクル管理(`scripts/with_server.py`)
289
+ - TypeScript Playwright Test (`@playwright/test`)
290
+ - webServerサポート付きPlaywright設定
291
291
  - スクリーンショットと動画キャプチャ
292
292
  - コンソールログとネットワークリクエスト監視
293
293
  - 高度なデバッグ用CDP統合
package/README.md CHANGED
@@ -318,8 +318,8 @@ npx reviw .artifacts/<feature>/REPORT.md
318
318
  Browser automation toolkit using Playwright.
319
319
 
320
320
  **Features:**
321
- - Python and Node.js Playwright scripts
322
- - Server lifecycle management (`scripts/with_server.py`)
321
+ - TypeScript Playwright Test (`@playwright/test`)
322
+ - Playwright configuration with webServer support
323
323
  - Screenshot and video capture
324
324
  - Console log and network request monitoring
325
325
  - CDP integration for advanced debugging
package/cli.cjs CHANGED
@@ -825,9 +825,10 @@ function serializeForScript(value) {
825
825
  .replace(/\$\{/g, "\\${");
826
826
  }
827
827
 
828
- function diffHtmlTemplate(diffData) {
828
+ function diffHtmlTemplate(diffData, history = []) {
829
829
  const { rows, projectRoot, relativePath } = diffData;
830
830
  const serialized = serializeForScript(rows);
831
+ const historyJson = serializeForScript(history);
831
832
  const fileCount = rows.filter((r) => r.type === "file").length;
832
833
 
833
834
  return `<!doctype html>
@@ -1235,6 +1236,221 @@ function diffHtmlTemplate(diffData) {
1235
1236
  }
1236
1237
  .no-diff h2 { font-size: 20px; margin: 0 0 8px; color: var(--text); }
1237
1238
  .no-diff p { font-size: 14px; margin: 0; }
1239
+
1240
+ /* History Panel - Push layout */
1241
+ body { transition: margin-right 0.25s ease; }
1242
+ body.history-open { margin-right: 320px; }
1243
+ body.history-open header { right: 320px; }
1244
+ header { transition: right 0.25s ease; right: 0; }
1245
+
1246
+ .history-toggle {
1247
+ background: var(--selected-bg);
1248
+ color: var(--text);
1249
+ border: 1px solid var(--border);
1250
+ border-radius: 6px;
1251
+ padding: 6px 8px;
1252
+ font-size: 14px;
1253
+ cursor: pointer;
1254
+ width: 34px;
1255
+ height: 34px;
1256
+ display: flex;
1257
+ align-items: center;
1258
+ justify-content: center;
1259
+ }
1260
+ .history-toggle:hover { background: var(--border); }
1261
+ .history-panel {
1262
+ position: fixed;
1263
+ top: 0;
1264
+ right: 0;
1265
+ width: 320px;
1266
+ height: 100vh;
1267
+ background: var(--panel);
1268
+ border-left: 1px solid var(--border);
1269
+ z-index: 90;
1270
+ transform: translateX(100%);
1271
+ transition: transform 0.25s ease;
1272
+ display: flex;
1273
+ flex-direction: column;
1274
+ }
1275
+ .history-panel.open { transform: translateX(0); }
1276
+ .history-panel-header {
1277
+ padding: 16px;
1278
+ border-bottom: 1px solid var(--border);
1279
+ display: flex;
1280
+ justify-content: space-between;
1281
+ align-items: center;
1282
+ }
1283
+ .history-panel-header h3 { margin: 0; font-size: 14px; font-weight: 600; }
1284
+ .history-panel-close {
1285
+ background: transparent;
1286
+ border: none;
1287
+ color: var(--muted);
1288
+ cursor: pointer;
1289
+ font-size: 18px;
1290
+ padding: 4px;
1291
+ }
1292
+ .history-panel-close:hover { color: var(--text); }
1293
+ .history-panel-body {
1294
+ flex: 1;
1295
+ overflow-y: auto;
1296
+ padding: 12px;
1297
+ }
1298
+ .history-empty {
1299
+ color: var(--muted);
1300
+ font-size: 13px;
1301
+ text-align: center;
1302
+ padding: 40px 20px;
1303
+ }
1304
+ .history-date-group {
1305
+ margin-bottom: 16px;
1306
+ }
1307
+ .history-date {
1308
+ font-size: 11px;
1309
+ font-weight: 600;
1310
+ color: var(--muted);
1311
+ margin-bottom: 8px;
1312
+ text-transform: uppercase;
1313
+ }
1314
+ .history-item {
1315
+ background: var(--bg);
1316
+ border: 1px solid var(--border);
1317
+ border-radius: 6px;
1318
+ margin-bottom: 8px;
1319
+ overflow: hidden;
1320
+ }
1321
+ .history-item-header {
1322
+ display: flex;
1323
+ justify-content: space-between;
1324
+ align-items: center;
1325
+ padding: 8px 10px;
1326
+ background: var(--selected-bg);
1327
+ cursor: pointer;
1328
+ }
1329
+ .history-item-header:hover { background: var(--border); }
1330
+ .history-item-file {
1331
+ font-size: 12px;
1332
+ font-weight: 600;
1333
+ color: var(--text);
1334
+ white-space: nowrap;
1335
+ overflow: hidden;
1336
+ text-overflow: ellipsis;
1337
+ max-width: 180px;
1338
+ }
1339
+ .history-item-time {
1340
+ font-size: 10px;
1341
+ color: var(--muted);
1342
+ }
1343
+ .history-item-body {
1344
+ display: none;
1345
+ padding: 10px;
1346
+ font-size: 12px;
1347
+ border-top: 1px solid var(--border);
1348
+ }
1349
+ .history-item.expanded .history-item-body { display: block; }
1350
+ .history-summary {
1351
+ color: var(--text);
1352
+ margin-bottom: 8px;
1353
+ padding-bottom: 8px;
1354
+ border-bottom: 1px solid var(--border);
1355
+ }
1356
+ .history-summary-label {
1357
+ font-size: 10px;
1358
+ font-weight: 600;
1359
+ color: var(--muted);
1360
+ margin-bottom: 4px;
1361
+ }
1362
+ .history-summary-text {
1363
+ white-space: pre-wrap;
1364
+ line-height: 1.4;
1365
+ }
1366
+ .history-comments-label {
1367
+ font-size: 10px;
1368
+ font-weight: 600;
1369
+ color: var(--muted);
1370
+ margin-bottom: 6px;
1371
+ }
1372
+ .history-comment {
1373
+ padding: 6px 0;
1374
+ border-bottom: 1px solid var(--border);
1375
+ }
1376
+ .history-comment:last-child { border-bottom: none; }
1377
+ .history-comment-line {
1378
+ font-size: 10px;
1379
+ color: var(--accent);
1380
+ font-weight: 600;
1381
+ margin-bottom: 2px;
1382
+ }
1383
+ .history-comment-quote {
1384
+ background: rgba(0, 0, 0, 0.3);
1385
+ border-left: 2px solid var(--accent);
1386
+ padding: 4px 8px;
1387
+ margin: 4px 0;
1388
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
1389
+ font-size: 11px;
1390
+ color: var(--muted);
1391
+ white-space: pre-wrap;
1392
+ word-break: break-all;
1393
+ max-height: 80px;
1394
+ overflow-y: auto;
1395
+ }
1396
+ .history-comment-text {
1397
+ color: var(--text);
1398
+ line-height: 1.4;
1399
+ white-space: pre-wrap;
1400
+ }
1401
+ .history-badge {
1402
+ display: inline-block;
1403
+ background: var(--accent);
1404
+ color: var(--text-inverse);
1405
+ font-size: 10px;
1406
+ padding: 2px 6px;
1407
+ border-radius: 10px;
1408
+ margin-left: 6px;
1409
+ }
1410
+
1411
+ /* Past comment indicator on lines */
1412
+ .diff-line[data-has-history]::before {
1413
+ content: '💬';
1414
+ position: absolute;
1415
+ left: 4px;
1416
+ font-size: 10px;
1417
+ opacity: 0.6;
1418
+ }
1419
+ .diff-line { position: relative; }
1420
+ .past-comment-overlay {
1421
+ background: var(--selected-bg);
1422
+ border-left: 3px solid var(--muted);
1423
+ margin: 0;
1424
+ padding: 8px 12px;
1425
+ font-size: 11px;
1426
+ color: var(--muted);
1427
+ display: none;
1428
+ }
1429
+ .past-comment-overlay.visible { display: block; }
1430
+ .past-comment-header {
1431
+ display: flex;
1432
+ justify-content: space-between;
1433
+ align-items: center;
1434
+ margin-bottom: 4px;
1435
+ }
1436
+ .past-comment-date {
1437
+ font-size: 10px;
1438
+ color: var(--muted);
1439
+ }
1440
+ .past-comment-toggle {
1441
+ background: transparent;
1442
+ border: none;
1443
+ color: var(--muted);
1444
+ cursor: pointer;
1445
+ font-size: 10px;
1446
+ padding: 2px 4px;
1447
+ }
1448
+ .past-comment-toggle:hover { color: var(--text); }
1449
+ .past-comment-text {
1450
+ color: var(--text);
1451
+ white-space: pre-wrap;
1452
+ line-height: 1.4;
1453
+ }
1238
1454
  </style>
1239
1455
  </head>
1240
1456
  <body>
@@ -1245,11 +1461,23 @@ function diffHtmlTemplate(diffData) {
1245
1461
  <span class="pill">Comments <strong id="comment-count">0</strong></span>
1246
1462
  </div>
1247
1463
  <div class="actions">
1464
+ <button class="history-toggle" id="history-toggle" title="Review History">☰</button>
1248
1465
  <button class="theme-toggle" id="theme-toggle" title="Toggle theme"><span id="theme-icon">🌙</span></button>
1249
1466
  <button id="send-and-exit">Submit & Exit</button>
1250
1467
  </div>
1251
1468
  </header>
1252
1469
 
1470
+ <!-- History Panel -->
1471
+ <aside class="history-panel" id="history-panel">
1472
+ <div class="history-panel-header">
1473
+ <h3>📜 Review History</h3>
1474
+ <button class="history-panel-close" id="history-panel-close">✕</button>
1475
+ </div>
1476
+ <div class="history-panel-body" id="history-panel-body">
1477
+ <div class="history-empty">No review history yet.</div>
1478
+ </div>
1479
+ </aside>
1480
+
1253
1481
  <div class="wrap">
1254
1482
  ${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>'}
1255
1483
  </div>
@@ -1298,8 +1526,9 @@ function diffHtmlTemplate(diffData) {
1298
1526
 
1299
1527
  <script>
1300
1528
  const DATA = ${serialized};
1301
- const FILE_NAME = ${serializeForScript(title)};
1529
+ const FILE_NAME = ${serializeForScript(relativePath)};
1302
1530
  const MODE = 'diff';
1531
+ const HISTORY_DATA = ${historyJson};
1303
1532
 
1304
1533
  // Theme
1305
1534
  (function initTheme() {
@@ -1319,6 +1548,166 @@ function diffHtmlTemplate(diffData) {
1319
1548
  });
1320
1549
  })();
1321
1550
 
1551
+ // --- History Management ---
1552
+ // History is now server-side (file-based), HISTORY_DATA is provided by server
1553
+
1554
+ function loadHistory() {
1555
+ // Return server-provided history data
1556
+ return Array.isArray(HISTORY_DATA) ? HISTORY_DATA : [];
1557
+ }
1558
+
1559
+ // saveToHistory is handled server-side via /exit endpoint
1560
+ function saveToHistory(payload) {
1561
+ // No-op on client - server saves history when receiving /exit
1562
+ }
1563
+
1564
+ function formatDate(isoString) {
1565
+ const d = new Date(isoString);
1566
+ return d.toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit' });
1567
+ }
1568
+
1569
+ function formatTime(isoString) {
1570
+ const d = new Date(isoString);
1571
+ return d.toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
1572
+ }
1573
+
1574
+ function getBasename(filepath) {
1575
+ return filepath.split('/').pop() || filepath;
1576
+ }
1577
+
1578
+ function renderHistoryPanel() {
1579
+ const body = document.getElementById('history-panel-body');
1580
+ const history = loadHistory();
1581
+ if (history.length === 0) {
1582
+ body.innerHTML = '<div class="history-empty">No review history yet.</div>';
1583
+ return;
1584
+ }
1585
+
1586
+ const grouped = {};
1587
+ history.forEach((item, idx) => {
1588
+ const date = formatDate(item.submittedAt);
1589
+ if (!grouped[date]) grouped[date] = [];
1590
+ grouped[date].push({ ...item, _idx: idx });
1591
+ });
1592
+
1593
+ let html = '';
1594
+ for (const date of Object.keys(grouped)) {
1595
+ html += \`<div class="history-date-group">
1596
+ <div class="history-date">\${date}</div>\`;
1597
+ for (const item of grouped[date]) {
1598
+ const commentCount = item.comments?.length || 0;
1599
+ html += \`<div class="history-item" data-idx="\${item._idx}">
1600
+ <div class="history-item-header">
1601
+ <span class="history-item-file">\${getBasename(item.file)}</span>
1602
+ <span class="history-item-time">\${formatTime(item.submittedAt)}<span class="history-badge">\${commentCount}</span></span>
1603
+ </div>
1604
+ <div class="history-item-body">\`;
1605
+ if (item.summary) {
1606
+ html += \`<div class="history-summary">
1607
+ <div class="history-summary-label">Summary</div>
1608
+ <div class="history-summary-text">\${escapeHtmlForHistory(item.summary)}</div>
1609
+ </div>\`;
1610
+ }
1611
+ if (commentCount > 0) {
1612
+ html += \`<div class="history-comments-label">Line Comments (\${commentCount})</div>\`;
1613
+ for (const c of item.comments) {
1614
+ const lineLabel = c.line ? \`L\${c.line}\${c.lineEnd ? '-' + c.lineEnd : ''}\` : (c.row != null ? \`L\${c.row}\` : '');
1615
+ const text = c.comment || c.text || '';
1616
+ // Support both direct content and context.content structures
1617
+ const content = c.content || c.context?.content || '';
1618
+ html += \`<div class="history-comment">
1619
+ <div class="history-comment-line">\${lineLabel}</div>\`;
1620
+ if (content) {
1621
+ html += \`<div class="history-comment-quote">\${escapeHtmlForHistory(content)}</div>\`;
1622
+ }
1623
+ html += \`<div class="history-comment-text">\${escapeHtmlForHistory(text)}</div>
1624
+ </div>\`;
1625
+ }
1626
+ }
1627
+ html += \`</div></div>\`;
1628
+ }
1629
+ html += \`</div>\`;
1630
+ }
1631
+ body.innerHTML = html;
1632
+
1633
+ body.querySelectorAll('.history-item-header').forEach(header => {
1634
+ header.addEventListener('click', () => {
1635
+ header.parentElement.classList.toggle('expanded');
1636
+ });
1637
+ });
1638
+ }
1639
+
1640
+ function escapeHtmlForHistory(s) {
1641
+ if (!s) return '';
1642
+ return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c] || c));
1643
+ }
1644
+
1645
+ function getHistoryForFile(filename) {
1646
+ const history = loadHistory();
1647
+ return history.filter(h => h.file === filename || getBasename(h.file) === getBasename(filename));
1648
+ }
1649
+
1650
+ function renderPastCommentsOnLines() {
1651
+ const fileHistory = getHistoryForFile(FILE_NAME);
1652
+ if (fileHistory.length === 0) return;
1653
+
1654
+ const lineComments = {};
1655
+ fileHistory.forEach(h => {
1656
+ if (!h.comments) return;
1657
+ h.comments.forEach(c => {
1658
+ const line = c.line || c.row;
1659
+ if (!line) return;
1660
+ if (!lineComments[line]) lineComments[line] = [];
1661
+ lineComments[line].push({
1662
+ date: formatDate(h.submittedAt),
1663
+ text: c.comment || c.text || ''
1664
+ });
1665
+ });
1666
+ });
1667
+
1668
+ Object.entries(lineComments).forEach(([line, comments]) => {
1669
+ const lineEl = document.querySelector('[data-row="' + line + '"]');
1670
+ if (lineEl && !lineEl.dataset.hasHistory) {
1671
+ lineEl.dataset.hasHistory = 'true';
1672
+ lineEl.title = comments.length + ' past comment(s) - click to view in History panel';
1673
+ }
1674
+ });
1675
+ }
1676
+
1677
+ // History Panel Toggle
1678
+ (function initHistoryPanel() {
1679
+ const toggle = document.getElementById('history-toggle');
1680
+ const panel = document.getElementById('history-panel');
1681
+ const closeBtn = document.getElementById('history-panel-close');
1682
+
1683
+ function openPanel() {
1684
+ panel.classList.add('open');
1685
+ document.body.classList.add('history-open');
1686
+ renderHistoryPanel();
1687
+ }
1688
+
1689
+ function closePanel() {
1690
+ panel.classList.remove('open');
1691
+ document.body.classList.remove('history-open');
1692
+ }
1693
+
1694
+ toggle?.addEventListener('click', () => {
1695
+ if (panel.classList.contains('open')) {
1696
+ closePanel();
1697
+ } else {
1698
+ openPanel();
1699
+ }
1700
+ });
1701
+
1702
+ closeBtn?.addEventListener('click', closePanel);
1703
+
1704
+ document.addEventListener('keydown', (e) => {
1705
+ if (e.key === 'Escape' && panel.classList.contains('open')) {
1706
+ closePanel();
1707
+ }
1708
+ });
1709
+ })();
1710
+
1322
1711
  const container = document.getElementById('diff-container');
1323
1712
  const card = document.getElementById('comment-card');
1324
1713
  const commentInput = document.getElementById('comment-input');
@@ -1628,7 +2017,18 @@ function diffHtmlTemplate(diffData) {
1628
2017
  document.getElementById('clear-comment').addEventListener('click', clearCurrent);
1629
2018
  document.getElementById('close-card').addEventListener('click', closeCard);
1630
2019
  document.addEventListener('keydown', e => {
1631
- if (e.key === 'Escape') closeCard();
2020
+ if (e.key === 'Escape') {
2021
+ // Don't close card if any fullscreen overlay is open
2022
+ const imageOverlay = document.getElementById('image-fullscreen');
2023
+ const videoOverlay = document.getElementById('video-fullscreen');
2024
+ const mermaidOverlay = document.getElementById('mermaid-fullscreen');
2025
+ if (imageOverlay?.classList.contains('visible') ||
2026
+ videoOverlay?.classList.contains('visible') ||
2027
+ mermaidOverlay?.classList.contains('visible')) {
2028
+ return; // Let the fullscreen handlers handle ESC
2029
+ }
2030
+ closeCard();
2031
+ }
1632
2032
  if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') saveCurrent();
1633
2033
  });
1634
2034
 
@@ -1720,7 +2120,9 @@ function diffHtmlTemplate(diffData) {
1720
2120
  if (sent) return;
1721
2121
  sent = true;
1722
2122
  clearStorage();
1723
- navigator.sendBeacon('/exit', new Blob([JSON.stringify(payload(reason))], { type: 'application/json' }));
2123
+ const p = payload(reason);
2124
+ saveToHistory(p);
2125
+ navigator.sendBeacon('/exit', new Blob([JSON.stringify(p)], { type: 'application/json' }));
1724
2126
  }
1725
2127
  function showSubmitModal() {
1726
2128
  const count = Object.keys(comments).length;
@@ -1783,6 +2185,7 @@ function diffHtmlTemplate(diffData) {
1783
2185
 
1784
2186
  renderDiff();
1785
2187
  refreshList();
2188
+ renderPastCommentsOnLines();
1786
2189
 
1787
2190
  // Recovery
1788
2191
  (function checkRecovery() {
@@ -1802,11 +2205,12 @@ function diffHtmlTemplate(diffData) {
1802
2205
  }
1803
2206
 
1804
2207
  // --- HTML template ---------------------------------------------------------
1805
- function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHtml, reviwQuestions = []) {
2208
+ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHtml, reviwQuestions = [], history = []) {
1806
2209
  const serialized = serializeForScript(dataRows);
1807
2210
  const modeJson = serializeForScript(mode);
1808
2211
  const titleJson = serializeForScript(relativePath); // Use relativePath as file identifier
1809
2212
  const questionsJson = serializeForScript(reviwQuestions || []);
2213
+ const historyJson = serializeForScript(history);
1810
2214
  const hasPreview = !!previewHtml;
1811
2215
  return `<!doctype html>
1812
2216
  <html lang="ja">
@@ -1979,7 +2383,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
1979
2383
  position: sticky;
1980
2384
  top: 0;
1981
2385
  z-index: 3;
1982
- background: var(--panel-solid);
2386
+ background: var(--panel-solid) !important;
1983
2387
  color: var(--muted);
1984
2388
  font-size: 12px;
1985
2389
  text-align: center;
@@ -1989,6 +2393,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
1989
2393
  white-space: nowrap;
1990
2394
  transition: background 200ms ease;
1991
2395
  }
2396
+ thead th:not(.selected) {
2397
+ background: var(--panel-solid) !important;
2398
+ }
1992
2399
  thead th:first-child,
1993
2400
  tbody th {
1994
2401
  width: 28px;
@@ -2093,7 +2500,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2093
2500
  tr:nth-child(even) td:not(.selected):not(.has-comment) { background: var(--row-even); }
2094
2501
  td:hover:not(.selected) { background: var(--hover-bg); box-shadow: inset 0 0 0 1px rgba(96,165,250,0.25); }
2095
2502
  td.has-comment { background: rgba(34,197,94,0.12); box-shadow: inset 0 0 0 1px rgba(34,197,94,0.35); }
2096
- 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); }
2503
+ td.selected, tbody th.selected { background: rgba(99,102,241,0.22) !important; box-shadow: inset 0 0 0 1px rgba(99,102,241,0.45); }
2504
+ thead th.selected { background: #c7d2fe !important; box-shadow: inset 0 0 0 1px rgba(99,102,241,0.45); }
2505
+ [data-theme="dark"] thead th.selected { background: #3730a3 !important; }
2097
2506
  body.dragging { user-select: none; cursor: crosshair; }
2098
2507
  body.dragging td, body.dragging tbody th { cursor: crosshair; }
2099
2508
  tbody th { cursor: pointer; }
@@ -2255,6 +2664,19 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2255
2664
  max-height: none;
2256
2665
  overflow: visible;
2257
2666
  }
2667
+ /* Ensure thead is opaque in md-right to prevent content showing through */
2668
+ .md-right thead th {
2669
+ background: var(--panel-solid) !important;
2670
+ }
2671
+ .md-right thead th.selected {
2672
+ background: #c7d2fe !important;
2673
+ }
2674
+ [data-theme="dark"] .md-right thead th {
2675
+ background: var(--panel-solid) !important;
2676
+ }
2677
+ [data-theme="dark"] .md-right thead th.selected {
2678
+ background: #3730a3 !important;
2679
+ }
2258
2680
  .md-preview h1, .md-preview h2, .md-preview h3, .md-preview h4 {
2259
2681
  margin: 0.4em 0 0.2em;
2260
2682
  }
@@ -3090,6 +3512,169 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3090
3512
  font-family: monospace;
3091
3513
  }
3092
3514
  .mermaid-error-toast.visible { display: block; }
3515
+
3516
+ /* History Panel - Push layout */
3517
+ body { transition: margin-right 0.25s ease; }
3518
+ body.history-open { margin-right: 320px; }
3519
+ body.history-open header { right: 320px; }
3520
+ header { transition: right 0.25s ease; right: 0; }
3521
+
3522
+ .history-toggle {
3523
+ background: var(--selected-bg);
3524
+ color: var(--text);
3525
+ border: 1px solid var(--border);
3526
+ border-radius: 6px;
3527
+ padding: 6px 8px;
3528
+ font-size: 14px;
3529
+ cursor: pointer;
3530
+ width: 34px;
3531
+ height: 34px;
3532
+ display: flex;
3533
+ align-items: center;
3534
+ justify-content: center;
3535
+ }
3536
+ .history-toggle:hover { background: var(--border); }
3537
+ .history-panel {
3538
+ position: fixed;
3539
+ top: 0;
3540
+ right: 0;
3541
+ width: 320px;
3542
+ height: 100vh;
3543
+ background: var(--panel-solid);
3544
+ border-left: 1px solid var(--border);
3545
+ z-index: 90;
3546
+ transform: translateX(100%);
3547
+ transition: transform 0.25s ease;
3548
+ display: flex;
3549
+ flex-direction: column;
3550
+ }
3551
+ .history-panel.open { transform: translateX(0); }
3552
+ .history-panel-header {
3553
+ padding: 16px;
3554
+ border-bottom: 1px solid var(--border);
3555
+ display: flex;
3556
+ justify-content: space-between;
3557
+ align-items: center;
3558
+ }
3559
+ .history-panel-header h3 { margin: 0; font-size: 14px; font-weight: 600; }
3560
+ .history-panel-close {
3561
+ background: transparent;
3562
+ border: none;
3563
+ color: var(--muted);
3564
+ cursor: pointer;
3565
+ font-size: 18px;
3566
+ padding: 4px;
3567
+ }
3568
+ .history-panel-close:hover { color: var(--text); }
3569
+ .history-panel-body {
3570
+ flex: 1;
3571
+ overflow-y: auto;
3572
+ padding: 12px;
3573
+ }
3574
+ .history-empty {
3575
+ color: var(--muted);
3576
+ font-size: 13px;
3577
+ text-align: center;
3578
+ padding: 40px 20px;
3579
+ }
3580
+ .history-date-group { margin-bottom: 16px; }
3581
+ .history-date {
3582
+ font-size: 11px;
3583
+ font-weight: 600;
3584
+ color: var(--muted);
3585
+ margin-bottom: 8px;
3586
+ text-transform: uppercase;
3587
+ }
3588
+ .history-item {
3589
+ background: var(--bg);
3590
+ border: 1px solid var(--border);
3591
+ border-radius: 6px;
3592
+ margin-bottom: 8px;
3593
+ overflow: hidden;
3594
+ }
3595
+ .history-item-header {
3596
+ display: flex;
3597
+ justify-content: space-between;
3598
+ align-items: center;
3599
+ padding: 8px 10px;
3600
+ background: var(--selected-bg);
3601
+ cursor: pointer;
3602
+ }
3603
+ .history-item-header:hover { background: var(--hover-bg); }
3604
+ .history-item-file {
3605
+ font-size: 12px;
3606
+ font-weight: 600;
3607
+ color: var(--text);
3608
+ white-space: nowrap;
3609
+ overflow: hidden;
3610
+ text-overflow: ellipsis;
3611
+ max-width: 180px;
3612
+ }
3613
+ .history-item-time { font-size: 10px; color: var(--muted); }
3614
+ .history-item-body {
3615
+ display: none;
3616
+ padding: 10px;
3617
+ font-size: 12px;
3618
+ border-top: 1px solid var(--border);
3619
+ }
3620
+ .history-item.expanded .history-item-body { display: block; }
3621
+ .history-summary {
3622
+ color: var(--text);
3623
+ margin-bottom: 8px;
3624
+ padding-bottom: 8px;
3625
+ border-bottom: 1px solid var(--border);
3626
+ }
3627
+ .history-summary-label {
3628
+ font-size: 10px;
3629
+ font-weight: 600;
3630
+ color: var(--muted);
3631
+ margin-bottom: 4px;
3632
+ }
3633
+ .history-summary-text { white-space: pre-wrap; line-height: 1.4; }
3634
+ .history-comments-label {
3635
+ font-size: 10px;
3636
+ font-weight: 600;
3637
+ color: var(--muted);
3638
+ margin-bottom: 6px;
3639
+ }
3640
+ .history-comment {
3641
+ padding: 6px 0;
3642
+ border-bottom: 1px solid var(--border);
3643
+ }
3644
+ .history-comment:last-child { border-bottom: none; }
3645
+ .history-comment-line {
3646
+ font-size: 10px;
3647
+ color: var(--accent);
3648
+ font-weight: 600;
3649
+ margin-bottom: 2px;
3650
+ }
3651
+ .history-comment-quote {
3652
+ background: rgba(0, 0, 0, 0.3);
3653
+ border-left: 2px solid var(--accent);
3654
+ padding: 4px 8px;
3655
+ margin: 4px 0;
3656
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
3657
+ font-size: 11px;
3658
+ color: var(--muted);
3659
+ white-space: pre-wrap;
3660
+ word-break: break-all;
3661
+ max-height: 80px;
3662
+ overflow-y: auto;
3663
+ }
3664
+ .history-comment-text {
3665
+ color: var(--text);
3666
+ line-height: 1.4;
3667
+ white-space: pre-wrap;
3668
+ }
3669
+ .history-badge {
3670
+ display: inline-block;
3671
+ background: var(--accent);
3672
+ color: var(--text-inverse);
3673
+ font-size: 10px;
3674
+ padding: 2px 6px;
3675
+ border-radius: 10px;
3676
+ margin-left: 6px;
3677
+ }
3093
3678
  </style>
3094
3679
  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
3095
3680
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css" id="hljs-theme-dark">
@@ -3104,6 +3689,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3104
3689
  <span class="pill">Comments <strong id="comment-count">0</strong></span>
3105
3690
  </div>
3106
3691
  <div class="actions">
3692
+ <button class="history-toggle" id="history-toggle" title="Review History">☰</button>
3107
3693
  <button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
3108
3694
  <span id="theme-icon">🌙</span>
3109
3695
  </button>
@@ -3111,6 +3697,17 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3111
3697
  </div>
3112
3698
  </header>
3113
3699
 
3700
+ <!-- History Panel -->
3701
+ <aside class="history-panel" id="history-panel">
3702
+ <div class="history-panel-header">
3703
+ <h3>📜 Review History</h3>
3704
+ <button class="history-panel-close" id="history-panel-close">✕</button>
3705
+ </div>
3706
+ <div class="history-panel-body" id="history-panel-body">
3707
+ <div class="history-empty">No review history yet.</div>
3708
+ </div>
3709
+ </aside>
3710
+
3114
3711
  <div class="wrap">
3115
3712
  ${
3116
3713
  hasPreview && mode === "markdown"
@@ -3285,6 +3882,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3285
3882
  const FILE_NAME = ${titleJson};
3286
3883
  const MODE = ${modeJson};
3287
3884
  const REVIW_QUESTIONS = ${questionsJson};
3885
+ const HISTORY_DATA = ${historyJson};
3288
3886
 
3289
3887
  // --- Theme Management ---
3290
3888
  (function initTheme() {
@@ -3337,6 +3935,134 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3337
3935
  themeToggle.addEventListener('click', toggleTheme);
3338
3936
  })();
3339
3937
 
3938
+ // --- History Management ---
3939
+ // History is now server-side (file-based), HISTORY_DATA is provided by server
3940
+
3941
+ function loadHistory() {
3942
+ // Return server-provided history data
3943
+ return Array.isArray(HISTORY_DATA) ? HISTORY_DATA : [];
3944
+ }
3945
+
3946
+ // saveToHistory is handled server-side via /exit endpoint
3947
+ function saveToHistory(payload) {
3948
+ // No-op on client - server saves history when receiving /exit
3949
+ }
3950
+
3951
+ function formatDate(isoString) {
3952
+ const d = new Date(isoString);
3953
+ return d.toLocaleDateString('ja-JP', { year: 'numeric', month: '2-digit', day: '2-digit' });
3954
+ }
3955
+
3956
+ function formatTime(isoString) {
3957
+ const d = new Date(isoString);
3958
+ return d.toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit' });
3959
+ }
3960
+
3961
+ function getBasename(filepath) {
3962
+ return filepath.split('/').pop() || filepath;
3963
+ }
3964
+
3965
+ function escapeHtmlForHistory(s) {
3966
+ if (!s) return '';
3967
+ return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c] || c));
3968
+ }
3969
+
3970
+ function renderHistoryPanel() {
3971
+ const body = document.getElementById('history-panel-body');
3972
+ const history = loadHistory();
3973
+ if (history.length === 0) {
3974
+ body.innerHTML = '<div class="history-empty">No review history yet.</div>';
3975
+ return;
3976
+ }
3977
+
3978
+ const grouped = {};
3979
+ history.forEach((item, idx) => {
3980
+ const date = formatDate(item.submittedAt);
3981
+ if (!grouped[date]) grouped[date] = [];
3982
+ grouped[date].push({ ...item, _idx: idx });
3983
+ });
3984
+
3985
+ let html = '';
3986
+ for (const date of Object.keys(grouped)) {
3987
+ html += \`<div class="history-date-group">
3988
+ <div class="history-date">\${date}</div>\`;
3989
+ for (const item of grouped[date]) {
3990
+ const commentCount = item.comments?.length || 0;
3991
+ html += \`<div class="history-item" data-idx="\${item._idx}">
3992
+ <div class="history-item-header">
3993
+ <span class="history-item-file">\${escapeHtmlForHistory(getBasename(item.file))}</span>
3994
+ <span class="history-item-time">\${formatTime(item.submittedAt)}<span class="history-badge">\${commentCount}</span></span>
3995
+ </div>
3996
+ <div class="history-item-body">\`;
3997
+ if (item.summary) {
3998
+ html += \`<div class="history-summary">
3999
+ <div class="history-summary-label">Summary</div>
4000
+ <div class="history-summary-text">\${escapeHtmlForHistory(item.summary)}</div>
4001
+ </div>\`;
4002
+ }
4003
+ if (commentCount > 0) {
4004
+ html += \`<div class="history-comments-label">Line Comments (\${commentCount})</div>\`;
4005
+ for (const c of item.comments) {
4006
+ const lineLabel = c.line ? \`L\${c.line}\${c.lineEnd ? '-' + c.lineEnd : ''}\` : (c.row != null ? \`L\${c.row}\` : '');
4007
+ const text = c.comment || c.text || '';
4008
+ // Support both direct content and context.content structures
4009
+ const content = c.content || c.context?.content || c.value || '';
4010
+ html += \`<div class="history-comment">
4011
+ <div class="history-comment-line">\${lineLabel}</div>\`;
4012
+ if (content) {
4013
+ html += \`<div class="history-comment-quote">\${escapeHtmlForHistory(content)}</div>\`;
4014
+ }
4015
+ html += \`<div class="history-comment-text">\${escapeHtmlForHistory(text)}</div>
4016
+ </div>\`;
4017
+ }
4018
+ }
4019
+ html += \`</div></div>\`;
4020
+ }
4021
+ html += \`</div>\`;
4022
+ }
4023
+ body.innerHTML = html;
4024
+
4025
+ body.querySelectorAll('.history-item-header').forEach(header => {
4026
+ header.addEventListener('click', () => {
4027
+ header.parentElement.classList.toggle('expanded');
4028
+ });
4029
+ });
4030
+ }
4031
+
4032
+ // History Panel Toggle
4033
+ (function initHistoryPanel() {
4034
+ const toggle = document.getElementById('history-toggle');
4035
+ const panel = document.getElementById('history-panel');
4036
+ const closeBtn = document.getElementById('history-panel-close');
4037
+
4038
+ function openPanel() {
4039
+ panel.classList.add('open');
4040
+ document.body.classList.add('history-open');
4041
+ renderHistoryPanel();
4042
+ }
4043
+
4044
+ function closePanel() {
4045
+ panel.classList.remove('open');
4046
+ document.body.classList.remove('history-open');
4047
+ }
4048
+
4049
+ toggle?.addEventListener('click', () => {
4050
+ if (panel.classList.contains('open')) {
4051
+ closePanel();
4052
+ } else {
4053
+ openPanel();
4054
+ }
4055
+ });
4056
+
4057
+ closeBtn?.addEventListener('click', closePanel);
4058
+
4059
+ document.addEventListener('keydown', (e) => {
4060
+ if (e.key === 'Escape' && panel.classList.contains('open')) {
4061
+ closePanel();
4062
+ }
4063
+ });
4064
+ })();
4065
+
3340
4066
  const tbody = document.getElementById('tbody');
3341
4067
  const table = document.getElementById('csv-table');
3342
4068
  const colgroup = document.getElementById('colgroup');
@@ -4083,7 +4809,18 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4083
4809
  document.getElementById('clear-comment').addEventListener('click', clearCurrent);
4084
4810
  document.getElementById('close-card').addEventListener('click', closeCard);
4085
4811
  document.addEventListener('keydown', (e) => {
4086
- if (e.key === 'Escape') closeCard();
4812
+ if (e.key === 'Escape') {
4813
+ // Don't close card if any fullscreen overlay is open
4814
+ const imageOverlay = document.getElementById('image-fullscreen');
4815
+ const videoOverlay = document.getElementById('video-fullscreen');
4816
+ const mermaidOverlay = document.getElementById('mermaid-fullscreen');
4817
+ if (imageOverlay?.classList.contains('visible') ||
4818
+ videoOverlay?.classList.contains('visible') ||
4819
+ mermaidOverlay?.classList.contains('visible')) {
4820
+ return; // Let the fullscreen handlers handle ESC
4821
+ }
4822
+ closeCard();
4823
+ }
4087
4824
  if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') saveCurrent();
4088
4825
  });
4089
4826
 
@@ -4366,7 +5103,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4366
5103
  if (sent) return;
4367
5104
  sent = true;
4368
5105
  clearCommentsFromStorage();
4369
- const blob = new Blob([JSON.stringify(payload(reason))], { type: 'application/json' });
5106
+ const p = payload(reason);
5107
+ saveToHistory(p);
5108
+ const blob = new Blob([JSON.stringify(p)], { type: 'application/json' });
4370
5109
  navigator.sendBeacon('/exit', blob);
4371
5110
  }
4372
5111
  function showSubmitModal() {
@@ -5684,11 +6423,12 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5684
6423
 
5685
6424
  function buildHtml(filePath) {
5686
6425
  const data = loadData(filePath);
6426
+ const history = loadHistoryFromFile(filePath);
5687
6427
  if (data.mode === "diff") {
5688
- return diffHtmlTemplate(data);
6428
+ return diffHtmlTemplate(data, history);
5689
6429
  }
5690
6430
  const { rows, cols, projectRoot, relativePath, mode, preview, reviwQuestions } = data;
5691
- return htmlTemplate(rows, cols, projectRoot, relativePath, mode, preview, reviwQuestions);
6431
+ return htmlTemplate(rows, cols, projectRoot, relativePath, mode, preview, reviwQuestions, history);
5692
6432
  }
5693
6433
 
5694
6434
  // --- HTTP Server -----------------------------------------------------------
@@ -5808,6 +6548,54 @@ function checkExistingServer(filePath) {
5808
6548
  }
5809
6549
  }
5810
6550
 
6551
+ // --- History File Management ---
6552
+ const HISTORY_DIR = path.join(os.homedir(), '.reviw', 'history');
6553
+ const HISTORY_MAX = 50;
6554
+
6555
+ function getHistoryFilePath(filePath) {
6556
+ // Use SHA256 hash of absolute path (same as lock files)
6557
+ const hash = crypto.createHash('sha256').update(path.resolve(filePath)).digest('hex').slice(0, 16);
6558
+ return path.join(HISTORY_DIR, hash + '.json');
6559
+ }
6560
+
6561
+ function ensureHistoryDir() {
6562
+ try {
6563
+ if (!fs.existsSync(HISTORY_DIR)) {
6564
+ fs.mkdirSync(HISTORY_DIR, { recursive: true, mode: 0o700 });
6565
+ }
6566
+ } catch (err) {
6567
+ // Ignore errors
6568
+ }
6569
+ }
6570
+
6571
+ function loadHistoryFromFile(filePath) {
6572
+ try {
6573
+ const historyPath = getHistoryFilePath(filePath);
6574
+ if (!fs.existsSync(historyPath)) {
6575
+ return [];
6576
+ }
6577
+ const data = JSON.parse(fs.readFileSync(historyPath, 'utf8'));
6578
+ return Array.isArray(data) ? data : [];
6579
+ } catch (err) {
6580
+ return [];
6581
+ }
6582
+ }
6583
+
6584
+ function saveHistoryToFile(filePath, historyEntry) {
6585
+ try {
6586
+ ensureHistoryDir();
6587
+ const historyPath = getHistoryFilePath(filePath);
6588
+ let history = loadHistoryFromFile(filePath);
6589
+ history.unshift(historyEntry);
6590
+ if (history.length > HISTORY_MAX) {
6591
+ history = history.slice(0, HISTORY_MAX);
6592
+ }
6593
+ fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), { mode: 0o600 });
6594
+ } catch (err) {
6595
+ // Ignore errors - history is optional
6596
+ }
6597
+ }
6598
+
5811
6599
  // Try to activate an existing browser tab with the given URL (macOS only)
5812
6600
  // Returns true if a tab was activated, false otherwise
5813
6601
  function tryActivateExistingTab(url) {
@@ -6083,6 +6871,10 @@ function createFileServer(filePath, fileIndex = 0) {
6083
6871
  if (raw && raw.trim()) {
6084
6872
  payload = JSON.parse(raw);
6085
6873
  }
6874
+ // Save to file-based history (only if there are comments)
6875
+ if (payload && (payload.comments?.length > 0 || payload.submitComment)) {
6876
+ saveHistoryToFile(ctx.filePath, payload);
6877
+ }
6086
6878
  res.writeHead(200, { "Content-Type": "text/plain" });
6087
6879
  res.end("bye");
6088
6880
  // Notify all tabs to close before shutting down
@@ -6345,6 +7137,12 @@ function createDiffServer(diffContent) {
6345
7137
  if (raw && raw.trim()) {
6346
7138
  payload = JSON.parse(raw);
6347
7139
  }
7140
+ // Save to file-based history (only if there are comments)
7141
+ // For diff mode, use relativePath as identifier
7142
+ if (payload && (payload.comments?.length > 0 || payload.submitComment)) {
7143
+ const filePath = ctx.diffData?.relativePath || 'stdin-diff';
7144
+ saveHistoryToFile(filePath, payload);
7145
+ }
6348
7146
  res.writeHead(200, { "Content-Type": "text/plain" });
6349
7147
  res.end("bye");
6350
7148
  // Notify all tabs to close before shutting down
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "description": "Lightweight file reviewer with in-browser comments for CSV, TSV, Markdown, and Git diffs.",
5
5
  "type": "module",
6
6
  "bin": {