cursor-guard 4.5.5 → 4.5.7

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/ROADMAP.md CHANGED
@@ -3,8 +3,8 @@
3
3
  > 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
4
4
  > 每一代向下兼容,低版本功能永远不废弃。
5
5
  >
6
- > **当前版本**:`V4.5.5`(V4 最终版)
7
- > **文档状态**:`V2` ~ `V4.5.5` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.5.7`(V4 最终版)
7
+ > **文档状态**:`V2` ~ `V4.5.7` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -462,6 +462,8 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
462
462
  | V4.5.2 | **告警结构化文件列表**:见下方详细说明 | ✅ |
463
463
  | V4.5.3 | **告警历史 UX 优化 + 备份结构化文件表格**:见下方详细说明 | ✅ |
464
464
  | V4.5.4 | **Shadow 硬链接增量优化 + always_watch 强保护模式**:见下方详细说明 | ✅ |
465
+ | V4.5.6 | **Bug 修复 + 告警 UX + init 优化**:见下方详细说明 | ✅ |
466
+ | V4.5.7 | **文件详情 Modal 修复 + Dashboard 端口复用**:见下方详细说明 | ✅ |
465
467
 
466
468
  #### V4.4.1 详细内容
467
469
 
@@ -619,6 +621,56 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
619
621
 
620
622
  > 这个特性直接填补了 V4 最大的架构缺口——"Watcher 停止 = 裸奔"。详见下方"V4 遗留的架构缺口"中该条目已标记为 **已解决**。
621
623
 
624
+ #### V4.5.6 详细内容
625
+
626
+ **Bug 修复**:
627
+
628
+ | 问题 | 严重度 | 根因 | 修复 |
629
+ |------|--------|------|------|
630
+ | Shadow hard-link 永远为 0 | 中 | `createShadowCopy` 中 `fs.mkdirSync(snapDir)` 在 `findPreviousSnapshot(backupDir)` 之前执行。新创建的空目录时间戳最新,被当作"上一个快照"返回,导致硬链接永远找不到文件 | 调换两行顺序:先 `findPreviousSnapshot`,再 `mkdirSync` |
631
+ | changedFiles 包含 ignore 路径 | 低 | `createGitSnapshot` 的 `diff-tree` 解析未按 `cfg.ignore` 过滤,`.cursor/` 等被忽略路径的文件混入告警的 files 数组和 changedCount | 在 diff-tree 解析循环中增加 `matchesAny(cfg.ignore, fileName)` 过滤;initial snapshot 的 `ls-tree` 列表同样过滤 |
632
+
633
+ **告警 UX 优化**:
634
+
635
+ | 改进 | 说明 |
636
+ |------|------|
637
+ | 告警详情增加文件类型摘要 | 活跃告警和历史告警均显示 "新增 N · 修改 N · 删除 N" 分类统计(`alertFileBreakdown` 辅助函数) |
638
+ | 活跃告警增加建议操作 | 告警卡片底部显示 "建议检查近期变更,并考虑手动创建快照" 提示文字 |
639
+ | i18n 补全 | 新增 `alert.breakdown`、`alert.suggestion` 双语 key |
640
+
641
+ **Dashboard UX 优化**:
642
+
643
+ | 改进 | 说明 |
644
+ |------|------|
645
+ | 文件详情弹出框(Modal) | 告警的"展开文件详情"改为全屏居中 Modal 弹窗,替代原本卡片内联展开(太小看不清)。Modal 支持排序、可滚动、720px 宽 |
646
+ | 每个文件可复制恢复命令 | Modal 和 Drawer 的文件表格中,每行增加"复制命令"按钮,一键生成 `restore_file({ path, file, source })` MCP 命令 |
647
+ | 备份表补全操作意图 | 备份表 summary 列:有 intent 时显示 "操作意图: xxx"(蓝色标签),无 intent 时显示 trigger 类型(自动/手动/恢复前)灰色文字 |
648
+
649
+ **工具链优化**:
650
+
651
+ | 改进 | 说明 |
652
+ |------|------|
653
+ | `cursor-guard-init` 自动创建配置 | init 流程新增 Step 4/5:若 `.cursor-guard.json` 不存在,自动从 `cursor-guard.example.json` 复制为项目根默认配置。升级场景下保留现有配置 |
654
+ | `backup_interval_seconds` 兼容别名 | `loadConfig` 支持 `backup_interval_seconds` 作为 `auto_backup_interval_seconds` 的别名(带 deprecation 警告) |
655
+
656
+ #### V4.5.7 详细内容
657
+
658
+ **Bug 修复**:
659
+
660
+ | 问题 | 根因 | 修复 |
661
+ |------|------|------|
662
+ | 告警"查看文件详情"Modal 点不开 | 事件处理中 `state.pageData?.alerts` 路径错误,告警数据实际存储在 `state.pageData.dashboard.alerts` | 修正为 `state.pageData?.dashboard?.alerts`,同时修正 projectPath 取值路径 |
663
+
664
+ **Dashboard 服务端口复用(单例模式)**:
665
+
666
+ | 改进 | 说明 |
667
+ |------|------|
668
+ | 模块级单例 `_instance` | `startDashboardServer` 首次调用时创建 HTTP 服务并缓存到 `_instance`(含 server/port/registry/token) |
669
+ | 热加载新项目 | 后续调用检测到 `_instance` 已存在时,不创建新服务,而是调用 `_mergeProjects` 将新路径合并到已有 registry 中 |
670
+ | 去重机制 | `_mergeProjects` 按 `_path.toLowerCase()` 去重,相同项目不重复注册 |
671
+ | 透明生效 | registry 是引用传递,已运行的 HTTP handler 闭包自动读取最新 registry,无需重启服务 |
672
+ | 导出 `getInstance()` | 外部可通过 `getInstance()` 获取当前运行实例的 server/port/registry 信息 |
673
+
622
674
  #### V4.5.x 新增配置参考
623
675
 
624
676
  | 字段 | 类型 | 默认值 | 引入版本 | 说明 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.5.5",
3
+ "version": "4.5.7",
4
4
  "description": "Protects code from accidental AI overwrite or deletion in Cursor IDE — mandatory pre-write snapshots, review-before-apply, local Git safety net, and deterministic recovery. | 保护代码免受 Cursor AI 代理意外覆写或删除——强制写前快照、预览再执行、本地 Git 安全网、确定性恢复。",
5
5
  "keywords": [
6
6
  "cursor",
@@ -64,7 +64,7 @@ if (fs.existsSync(configPath)) {
64
64
  }
65
65
 
66
66
  // Step 1: Copy skill files (excluding node_modules and .git)
67
- console.log(' [1/4] Copying skill files...');
67
+ console.log(' [1/5] Copying skill files...');
68
68
  if (fs.existsSync(skillTarget)) {
69
69
  fs.rmSync(skillTarget, { recursive: true, force: true });
70
70
  }
@@ -72,7 +72,7 @@ copyRecursive(skillSource, skillTarget);
72
72
  console.log(' Done.');
73
73
 
74
74
  // Step 2: Install MCP dependencies in skill directory
75
- console.log(' [2/4] Installing MCP dependencies...');
75
+ console.log(' [2/5] Installing MCP dependencies...');
76
76
  try {
77
77
  const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
78
78
  execFileSync(npmCmd, ['install', '--omit=dev', '--ignore-scripts'], {
@@ -86,7 +86,7 @@ try {
86
86
  }
87
87
 
88
88
  // Step 3: Add .gitignore entries for skill node_modules
89
- console.log(' [3/4] Updating .gitignore...');
89
+ console.log(' [3/5] Updating .gitignore...');
90
90
  const gitignorePath = path.join(projectDir, '.gitignore');
91
91
  const entries = ['node_modules/', '.cursor/skills/**/node_modules/'];
92
92
  let gitignoreUpdated = false;
@@ -106,8 +106,22 @@ if (!isGlobal) {
106
106
  console.log(' Skipped (global install, not inside a project).');
107
107
  }
108
108
 
109
- // Step 4: Summary
110
- console.log(' [4/4] Verifying...');
109
+ // Step 4: Create default config if missing
110
+ console.log(' [4/5] Checking .cursor-guard.json...');
111
+ if (!fs.existsSync(configPath)) {
112
+ const examplePath = path.join(skillTarget, 'references', 'cursor-guard.example.json');
113
+ if (fs.existsSync(examplePath)) {
114
+ fs.copyFileSync(examplePath, configPath);
115
+ console.log(' Created .cursor-guard.json with default settings.');
116
+ } else {
117
+ console.log(' Warning: example config not found, skipping.');
118
+ }
119
+ } else {
120
+ console.log(' Already exists — preserved.');
121
+ }
122
+
123
+ // Step 5: Summary
124
+ console.log(' [5/5] Verifying...');
111
125
  const serverExists = fs.existsSync(path.join(skillTarget, 'references', 'mcp', 'server.js'));
112
126
  const sdkExists = fs.existsSync(path.join(skillTarget, 'node_modules', '@modelcontextprotocol', 'sdk'));
113
127
  const skillMdExists = fs.existsSync(path.join(skillTarget, 'SKILL.md'));
@@ -130,8 +144,7 @@ console.log(' If MCP was already configured, restart Cursor (or Ctrl+Shift+P ->
130
144
  console.log(' "Developer: Reload Window") to load the updated MCP server.\n');
131
145
  console.log(' Next steps:');
132
146
  console.log(' 1. The skill activates automatically in Cursor Agent conversations.');
133
- console.log(' 2. (Optional) Copy example config to project root:');
134
- console.log(` cp "${path.join(skillTarget, 'references', 'cursor-guard.example.json')}" .cursor-guard.json`);
147
+ console.log(' 2. (Optional) Edit .cursor-guard.json to customize protect/ignore patterns.');
135
148
  console.log(' 3. (Optional) Enable MCP — add to .cursor/mcp.json:');
136
149
  console.log(` { "mcpServers": { "cursor-guard": { "command": "node", "args": ["${path.join(skillTarget, 'references', 'mcp', 'server.js').replace(/\\/g, '/')}"] } } }`);
137
150
  console.log(' 4. (Optional) Start auto-backup:');
@@ -56,6 +56,13 @@ const I18N = {
56
56
  'alert.action.added': 'Added',
57
57
  'alert.action.deleted': 'Deleted',
58
58
  'alert.action.renamed': 'Renamed',
59
+ 'alert.breakdown': '{added} added, {modified} modified, {deleted} deleted',
60
+ 'alert.suggestion': 'Check recent changes and consider creating a manual snapshot',
61
+ 'alert.viewFiles': 'View file details ({n} files)',
62
+ 'modal.alertFiles': 'Alert File Details',
63
+ 'modal.col.restore': 'Restore',
64
+ 'modal.copyRestore': 'Copy cmd',
65
+ 'modal.copied': 'Copied!',
59
66
 
60
67
  'backups.gitCommits': 'Git Commits',
61
68
  'backups.shadowSnapshots': 'Shadow Snapshots',
@@ -264,6 +271,13 @@ const I18N = {
264
271
  'alert.action.added': '新增',
265
272
  'alert.action.deleted': '删除',
266
273
  'alert.action.renamed': '重命名',
274
+ 'alert.breakdown': '新增 {added} · 修改 {modified} · 删除 {deleted}',
275
+ 'alert.suggestion': '建议检查近期变更,并考虑手动创建快照',
276
+ 'alert.viewFiles': '查看文件详情({n} 个文件)',
277
+ 'modal.alertFiles': '告警文件详情',
278
+ 'modal.col.restore': '恢复',
279
+ 'modal.copyRestore': '复制命令',
280
+ 'modal.copied': '已复制!',
267
281
 
268
282
  'backups.gitCommits': 'Git 提交数',
269
283
  'backups.shadowSnapshots': '影子快照',
@@ -846,19 +860,32 @@ function renderWatcherCard(watcher) {
846
860
  `;
847
861
  }
848
862
 
863
+ function alertFileBreakdown(files) {
864
+ if (!Array.isArray(files) || files.length === 0) return '';
865
+ let added = 0, modified = 0, deleted = 0;
866
+ for (const f of files) {
867
+ if (f.action === 'added') added++;
868
+ else if (f.action === 'deleted') deleted++;
869
+ else modified++;
870
+ }
871
+ return t('alert.breakdown', { added, modified, deleted });
872
+ }
873
+
849
874
  function renderAlertCard(alerts) {
850
875
  const el = $('#card-alert');
851
876
  if (!alerts?.active) {
852
877
  let historyHtml = '';
853
878
  if (state.alertHistory.length > 0) {
854
879
  const count = state.alertHistory.length;
855
- const rows = state.alertHistory.slice(-5).reverse().map(h =>
856
- `<div class="alert-history-row text-sm text-muted">
857
- <span>${esc(formatTime(h.timestamp))}</span>
880
+ const rows = state.alertHistory.slice(-5).reverse().map(h => {
881
+ const breakdown = alertFileBreakdown(h.files);
882
+ return `<div class="alert-history-row text-sm text-muted">
883
+ <span class="alert-history-time">${esc(formatTime(h.timestamp))}</span>
858
884
  <span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
885
+ ${breakdown ? `<span class="alert-history-breakdown">${esc(breakdown)}</span>` : ''}
859
886
  <span class="badge badge-expired">${t('alert.expired')}</span>
860
- </div>`
861
- ).join('');
887
+ </div>`;
888
+ }).join('');
862
889
  historyHtml = `
863
890
  <div class="alert-history-toggle-wrap">
864
891
  <button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
@@ -893,25 +920,9 @@ function renderAlertCard(alerts) {
893
920
  const files = Array.isArray(a.files) ? a.files : [];
894
921
  let filesHtml = '';
895
922
  if (files.length > 0) {
896
- const actionBadge = (action) => {
897
- const cls = action === 'deleted' ? 'alert-action-deleted'
898
- : action === 'added' ? 'alert-action-added'
899
- : action === 'renamed' ? 'alert-action-renamed'
900
- : 'alert-action-modified';
901
- return `<span class="alert-action-badge ${cls}">${t('alert.action.' + action)}</span>`;
902
- };
903
- const rows = files.map(f =>
904
- `<tr><td class="text-mono alert-file-path">${esc(f.path)}</td><td>${actionBadge(f.action)}</td><td class="text-mono alert-file-changes">+${f.added || 0} -${f.deleted || 0}</td></tr>`
905
- ).join('');
906
923
  filesHtml = `
907
924
  <div class="alert-files-section">
908
- <button class="alert-files-toggle" data-alert-files-toggle>${t('alert.showFiles')}</button>
909
- <div class="alert-files-table-wrap alert-files-hidden">
910
- <table class="alert-files-table">
911
- <thead><tr><th>${t('alert.col.file')}</th><th>${t('alert.col.action')}</th><th>${t('alert.col.changes')}</th></tr></thead>
912
- <tbody>${rows}</tbody>
913
- </table>
914
- </div>
925
+ <button class="alert-files-toggle" data-alert-files-modal>${t('alert.viewFiles', { n: files.length })}</button>
915
926
  </div>
916
927
  `;
917
928
  }
@@ -923,6 +934,8 @@ function renderAlertCard(alerts) {
923
934
  <div class="alert-detail-row"><span class="alert-detail-label">${t('alert.triggered')}</span><span>${esc(triggeredAt)}</span></div>
924
935
  <div class="alert-detail-row"><span class="alert-detail-label">${t('alert.expires')}</span><span class="alert-countdown">${esc(remainDisplay)}</span></div>
925
936
  <div class="alert-detail-row alert-numbers">${esc(detailText)}</div>
937
+ ${alertFileBreakdown(files) ? `<div class="alert-detail-row alert-breakdown text-sm">${esc(alertFileBreakdown(files))}</div>` : ''}
938
+ <div class="alert-detail-row alert-suggestion text-sm text-muted">${t('alert.suggestion')}</div>
926
939
  </div>
927
940
  ${filesHtml}
928
941
  `;
@@ -1046,11 +1059,14 @@ function formatSummaryCell(b) {
1046
1059
  line2 = `<div class="summary-restore-ctx">${label}<span class="text-mono">${esc(b.from)}</span> → <span class="text-mono">${esc(b.restoreTo)}</span></div>`;
1047
1060
  } else if (b.intent) {
1048
1061
  const intentShort = b.intent.length > 70 ? b.intent.substring(0, 67) + '...' : b.intent;
1049
- line2 = `<div class="summary-intent">${esc(intentShort)}</div>`;
1062
+ line2 = `<div class="summary-intent"><span class="summary-intent-label">${t('drawer.field.intent')}:</span> ${esc(intentShort)}</div>`;
1050
1063
  } else if (b.message && !b.message.startsWith('guard:')) {
1051
1064
  const msgShort = b.message.length > 70 ? b.message.substring(0, 67) + '...' : b.message;
1052
1065
  line2 = `<div class="summary-message">${esc(msgShort)}</div>`;
1053
1066
  }
1067
+ if (b.trigger && !line2) {
1068
+ line2 = `<div class="summary-trigger text-sm text-muted">${t('trigger.' + b.trigger)}</div>`;
1069
+ }
1054
1070
 
1055
1071
  let line3 = '';
1056
1072
  if (b.summary) {
@@ -1182,6 +1198,67 @@ function renderSectionError(elementId, msg) {
1182
1198
  el.innerHTML = `<div class="error-panel"><div class="error-icon">⚠</div><p>${esc(msg || t('error.sectionFailed'))}</p></div>`;
1183
1199
  }
1184
1200
 
1201
+ /* ── File Detail Modal ────────────────────────────────────── */
1202
+
1203
+ function openFileModal(title, files, projectPath, commitHash) {
1204
+ $('#file-modal-title').textContent = title;
1205
+ const body = $('#file-modal-body');
1206
+ let sortKey = 'changes';
1207
+ const render = () => {
1208
+ const sorted = [...files];
1209
+ if (sortKey === 'path') sorted.sort((a, b) => a.path.localeCompare(b.path));
1210
+ else if (sortKey === 'action') sorted.sort((a, b) => a.action.localeCompare(b.action));
1211
+ else sorted.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
1212
+
1213
+ const rows = sorted.map(f => {
1214
+ const restoreCmd = commitHash
1215
+ ? `restore_file({ path: "${projectPath || ''}", file: "${f.path}", source: "${commitHash}" })`
1216
+ : '';
1217
+ return `<tr>
1218
+ <td class="text-mono modal-file-path" title="${esc(f.path)}">${esc(f.path)}</td>
1219
+ <td>${formatFileActionBadge(f.action)}</td>
1220
+ <td class="text-mono modal-file-changes">+${f.added || 0} -${f.deleted || 0}</td>
1221
+ ${commitHash ? `<td><button class="modal-restore-btn" data-restore-cmd="${esc(restoreCmd)}">${t('modal.copyRestore')}</button></td>` : ''}
1222
+ </tr>`;
1223
+ }).join('');
1224
+
1225
+ body.innerHTML = `<table>
1226
+ <thead><tr>
1227
+ <th data-msort="path">${t('alert.col.file')} ↕</th>
1228
+ <th data-msort="action">${t('alert.col.action')} ↕</th>
1229
+ <th data-msort="changes">${t('alert.col.changes')} ↕</th>
1230
+ ${commitHash ? `<th>${t('modal.col.restore')}</th>` : ''}
1231
+ </tr></thead>
1232
+ <tbody>${rows}</tbody>
1233
+ </table>`;
1234
+ };
1235
+ render();
1236
+
1237
+ body.addEventListener('click', (e) => {
1238
+ const th = e.target.closest('[data-msort]');
1239
+ if (th) {
1240
+ sortKey = th.dataset.msort;
1241
+ render();
1242
+ return;
1243
+ }
1244
+ const btn = e.target.closest('[data-restore-cmd]');
1245
+ if (btn) {
1246
+ copyText(btn.dataset.restoreCmd);
1247
+ btn.textContent = t('modal.copied');
1248
+ btn.classList.add('copied');
1249
+ setTimeout(() => { btn.textContent = t('modal.copyRestore'); btn.classList.remove('copied'); }, 1500);
1250
+ }
1251
+ });
1252
+
1253
+ $('#file-modal-overlay').classList.add('active');
1254
+ document.body.style.overflow = 'hidden';
1255
+ }
1256
+
1257
+ function closeFileModal() {
1258
+ $('#file-modal-overlay').classList.remove('active');
1259
+ document.body.style.overflow = state.drawerOpen ? 'hidden' : '';
1260
+ }
1261
+
1185
1262
  /* ── Drawers ──────────────────────────────────────────────── */
1186
1263
 
1187
1264
  function openDrawer(name) {
@@ -1199,19 +1276,27 @@ function closeDrawer() {
1199
1276
  state.drawerOpen = null;
1200
1277
  }
1201
1278
 
1202
- function renderDrawerFilesTable(files, sortKey) {
1279
+ function renderDrawerFilesTable(files, sortKey, commitHash, projectPath) {
1203
1280
  const sorted = [...files];
1204
1281
  if (sortKey === 'path') sorted.sort((a, b) => a.path.localeCompare(b.path));
1205
1282
  else if (sortKey === 'action') sorted.sort((a, b) => a.action.localeCompare(b.action));
1206
1283
  else sorted.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
1207
- const rows = sorted.map(f =>
1208
- `<tr><td class="text-mono drawer-file-path">${esc(f.path)}</td><td>${formatFileActionBadge(f.action)}</td><td class="text-mono drawer-file-changes">+${f.added} -${f.deleted}</td></tr>`
1209
- ).join('');
1284
+ const hasRestore = !!commitHash;
1285
+ const rows = sorted.map(f => {
1286
+ const cmd = hasRestore ? `restore_file({ path: "${projectPath || ''}", file: "${f.path}", source: "${commitHash}" })` : '';
1287
+ return `<tr>
1288
+ <td class="text-mono drawer-file-path">${esc(f.path)}</td>
1289
+ <td>${formatFileActionBadge(f.action)}</td>
1290
+ <td class="text-mono drawer-file-changes">+${f.added} -${f.deleted}</td>
1291
+ ${hasRestore ? `<td><button class="modal-restore-btn" data-restore-cmd="${esc(cmd)}">${t('modal.copyRestore')}</button></td>` : ''}
1292
+ </tr>`;
1293
+ }).join('');
1210
1294
  return `<table class="drawer-files-table">
1211
1295
  <thead><tr>
1212
1296
  <th data-sort="path" class="drawer-sort-header">${t('alert.col.file')} ↕</th>
1213
1297
  <th data-sort="action" class="drawer-sort-header">${t('alert.col.action')} ↕</th>
1214
1298
  <th data-sort="changes" class="drawer-sort-header">${t('alert.col.changes')} ↕</th>
1299
+ ${hasRestore ? `<th>${t('modal.col.restore')}</th>` : ''}
1215
1300
  </tr></thead>
1216
1301
  <tbody>${rows}</tbody>
1217
1302
  </table>`;
@@ -1292,28 +1377,40 @@ function openRestoreDrawer(backup) {
1292
1377
  wrap.classList.toggle('hidden');
1293
1378
  });
1294
1379
 
1380
+ const projPath = backup.path || state.pageData?.status?.config?.path || '';
1381
+
1295
1382
  // Lazy-load full file list for summary section
1296
1383
  if (backup.summary && isGit && hash) {
1297
1384
  let currentFiles = [];
1298
1385
  let currentSort = 'changes';
1386
+ const setupContainer = (container) => {
1387
+ container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort, hash, projPath);
1388
+ container.addEventListener('click', (e) => {
1389
+ const th = e.target.closest('[data-sort]');
1390
+ if (th) {
1391
+ currentSort = th.dataset.sort;
1392
+ container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort, hash, projPath);
1393
+ return;
1394
+ }
1395
+ const restoreBtn = e.target.closest('[data-restore-cmd]');
1396
+ if (restoreBtn) {
1397
+ copyText(restoreBtn.dataset.restoreCmd);
1398
+ restoreBtn.textContent = t('modal.copied');
1399
+ restoreBtn.classList.add('copied');
1400
+ setTimeout(() => { restoreBtn.textContent = t('modal.copyRestore'); restoreBtn.classList.remove('copied'); }, 1500);
1401
+ }
1402
+ });
1403
+ };
1299
1404
  fetchBackupFiles(hash).then(files => {
1300
1405
  currentFiles = files.length > 0 ? files : parseSummaryToFiles(backup.summary);
1301
1406
  const container = body.querySelector('#drawer-files-container');
1302
- if (container) {
1303
- container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort);
1304
- container.addEventListener('click', (e) => {
1305
- const th = e.target.closest('[data-sort]');
1306
- if (!th) return;
1307
- currentSort = th.dataset.sort;
1308
- container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort);
1309
- });
1310
- }
1407
+ if (container) setupContainer(container);
1311
1408
  });
1312
1409
  } else if (backup.summary) {
1313
1410
  const fallback = parseSummaryToFiles(backup.summary);
1314
1411
  const container = body.querySelector('#drawer-files-container');
1315
1412
  if (container && fallback.length > 0) {
1316
- container.innerHTML = renderDrawerFilesTable(fallback, 'changes');
1413
+ container.innerHTML = renderDrawerFilesTable(fallback, 'changes', hash, projPath);
1317
1414
  } else if (container) {
1318
1415
  const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
1319
1416
  container.innerHTML = `<pre class="restore-field-value text-mono summary-pre">${esc(translated)}</pre>`;
@@ -1432,7 +1529,7 @@ function setupEvents() {
1432
1529
  if (backup) openRestoreDrawer(backup);
1433
1530
  });
1434
1531
 
1435
- // Alert history + files toggle (event delegation)
1532
+ // Alert history toggle + file modal (event delegation)
1436
1533
  $('#card-alert').addEventListener('click', (e) => {
1437
1534
  const historyToggle = e.target.closest('[data-alert-history-toggle]');
1438
1535
  if (historyToggle) {
@@ -1441,14 +1538,16 @@ function setupEvents() {
1441
1538
  if (history) history.classList.toggle('alert-history-collapsed');
1442
1539
  return;
1443
1540
  }
1444
- const toggleBtn = e.target.closest('[data-alert-files-toggle]');
1445
- if (!toggleBtn) return;
1446
- const section = toggleBtn.closest('.alert-files-section');
1447
- if (!section) return;
1448
- const wrap = section.querySelector('.alert-files-table-wrap');
1449
- if (!wrap) return;
1450
- const hidden = wrap.classList.toggle('alert-files-hidden');
1451
- toggleBtn.textContent = hidden ? t('alert.showFiles') : t('alert.hideFiles');
1541
+ const modalBtn = e.target.closest('[data-alert-files-modal]');
1542
+ if (modalBtn) {
1543
+ const alerts = state.pageData?.dashboard?.alerts;
1544
+ const files = alerts?.latest?.files || [];
1545
+ if (files.length > 0) {
1546
+ const proj = state.pageData?.dashboard?.watcher?.path || '';
1547
+ openFileModal(t('modal.alertFiles'), files, proj, '');
1548
+ }
1549
+ return;
1550
+ }
1452
1551
  });
1453
1552
 
1454
1553
  // Diagnostics summary click
@@ -1467,8 +1566,17 @@ function setupEvents() {
1467
1566
  document.querySelectorAll('[data-action="close-drawer"]').forEach(btn => {
1468
1567
  btn.addEventListener('click', closeDrawer);
1469
1568
  });
1569
+
1570
+ // Close modal
1571
+ $('#file-modal-overlay').addEventListener('click', (e) => {
1572
+ if (e.target === $('#file-modal-overlay')) closeFileModal();
1573
+ });
1574
+ document.querySelectorAll('[data-action="close-modal"]').forEach(btn => {
1575
+ btn.addEventListener('click', closeFileModal);
1576
+ });
1577
+
1470
1578
  document.addEventListener('keydown', (e) => {
1471
- if (e.key === 'Escape') closeDrawer();
1579
+ if (e.key === 'Escape') { closeFileModal(); closeDrawer(); }
1472
1580
  });
1473
1581
  }
1474
1582
 
@@ -75,6 +75,17 @@
75
75
 
76
76
  </main>
77
77
 
78
+ <!-- ── File Detail Modal ──────────────────────────────────── -->
79
+ <div id="file-modal-overlay" class="modal-overlay">
80
+ <div id="file-modal" class="file-modal">
81
+ <div class="file-modal-header">
82
+ <h3 id="file-modal-title"></h3>
83
+ <button class="drawer-close" data-action="close-modal">&times;</button>
84
+ </div>
85
+ <div id="file-modal-body" class="file-modal-body"></div>
86
+ </div>
87
+ </div>
88
+
78
89
  <!-- ── Drawer Overlay ───────────────────────────────────── -->
79
90
  <div id="drawer-overlay" class="drawer-overlay"></div>
80
91
 
@@ -349,6 +349,14 @@ main {
349
349
  max-width: 400px;
350
350
  line-height: 1.4;
351
351
  }
352
+ .summary-intent-label {
353
+ font-weight: 600;
354
+ opacity: 0.7;
355
+ }
356
+ .summary-trigger {
357
+ font-size: 11px;
358
+ padding: 2px 0;
359
+ }
352
360
  .summary-restore-ctx {
353
361
  font-size: 12px;
354
362
  color: var(--amber);
@@ -997,6 +1005,22 @@ main {
997
1005
  border-left: 3px solid var(--yellow);
998
1006
  }
999
1007
 
1008
+ .alert-breakdown {
1009
+ color: var(--text-secondary);
1010
+ font-weight: 500;
1011
+ padding: 2px 12px;
1012
+ }
1013
+ .alert-suggestion {
1014
+ padding: 4px 12px;
1015
+ font-style: italic;
1016
+ }
1017
+ .alert-history-breakdown {
1018
+ display: block;
1019
+ font-size: 10px;
1020
+ color: var(--text-tertiary);
1021
+ margin-top: 1px;
1022
+ }
1023
+
1000
1024
  .alert-history-toggle-wrap {
1001
1025
  margin-top: 8px;
1002
1026
  }
@@ -1236,6 +1260,103 @@ main {
1236
1260
  color: var(--text-heading);
1237
1261
  }
1238
1262
 
1263
+ /* ── File Detail Modal ────────────────────────────────────── */
1264
+
1265
+ .modal-overlay {
1266
+ position: fixed;
1267
+ inset: 0;
1268
+ background: rgba(0, 0, 0, 0.55);
1269
+ z-index: 2000;
1270
+ display: none;
1271
+ align-items: center;
1272
+ justify-content: center;
1273
+ backdrop-filter: blur(2px);
1274
+ }
1275
+ .modal-overlay.active {
1276
+ display: flex;
1277
+ }
1278
+ .file-modal {
1279
+ background: var(--card-bg);
1280
+ border: 1px solid var(--border);
1281
+ border-radius: var(--radius);
1282
+ width: 90vw;
1283
+ max-width: 720px;
1284
+ max-height: 80vh;
1285
+ display: flex;
1286
+ flex-direction: column;
1287
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
1288
+ }
1289
+ .file-modal-header {
1290
+ display: flex;
1291
+ justify-content: space-between;
1292
+ align-items: center;
1293
+ padding: 14px 20px;
1294
+ border-bottom: 1px solid var(--border);
1295
+ }
1296
+ .file-modal-header h3 {
1297
+ margin: 0;
1298
+ font-size: 15px;
1299
+ font-weight: 600;
1300
+ }
1301
+ .file-modal-body {
1302
+ padding: 16px 20px;
1303
+ overflow-y: auto;
1304
+ flex: 1;
1305
+ }
1306
+ .file-modal-body table {
1307
+ width: 100%;
1308
+ border-collapse: collapse;
1309
+ }
1310
+ .file-modal-body th,
1311
+ .file-modal-body td {
1312
+ padding: 6px 10px;
1313
+ text-align: left;
1314
+ border-bottom: 1px solid var(--border);
1315
+ font-size: 12px;
1316
+ }
1317
+ .file-modal-body th {
1318
+ font-weight: 600;
1319
+ font-size: 11px;
1320
+ text-transform: uppercase;
1321
+ letter-spacing: 0.3px;
1322
+ color: var(--text-secondary);
1323
+ cursor: pointer;
1324
+ user-select: none;
1325
+ }
1326
+ .file-modal-body th:hover {
1327
+ color: var(--accent);
1328
+ }
1329
+ .modal-file-path {
1330
+ max-width: 340px;
1331
+ overflow: hidden;
1332
+ text-overflow: ellipsis;
1333
+ white-space: nowrap;
1334
+ }
1335
+ .modal-file-changes {
1336
+ white-space: nowrap;
1337
+ }
1338
+ .modal-restore-btn {
1339
+ padding: 2px 8px;
1340
+ font-size: 10px;
1341
+ border: 1px solid var(--border);
1342
+ border-radius: var(--radius-sm);
1343
+ background: var(--bg);
1344
+ color: var(--text-secondary);
1345
+ cursor: pointer;
1346
+ white-space: nowrap;
1347
+ transition: all 0.15s;
1348
+ }
1349
+ .modal-restore-btn:hover {
1350
+ background: var(--accent);
1351
+ color: #fff;
1352
+ border-color: var(--accent);
1353
+ }
1354
+ .modal-restore-btn.copied {
1355
+ background: var(--green);
1356
+ color: #fff;
1357
+ border-color: var(--green);
1358
+ }
1359
+
1239
1360
  /* ── Responsive ───────────────────────────────────────────── */
1240
1361
 
1241
1362
  @media (max-width: 768px) {
@@ -186,19 +186,47 @@ function handleApi(pathname, query, registry, res) {
186
186
  return notFound(res);
187
187
  }
188
188
 
189
- /* ── Server ─────────────────────────────────────────────────── */
189
+ /* ── Server (singleton) ─────────────────────────────────────── */
190
+
191
+ let _instance = null;
192
+
193
+ function _mergeProjects(registry, paths) {
194
+ const seen = new Set([...registry.values()].map(p => p._path.toLowerCase()));
195
+ let maxIdx = registry.size;
196
+ let added = 0;
197
+ for (const raw of paths) {
198
+ const resolved = path.resolve(raw);
199
+ if (seen.has(resolved.toLowerCase())) continue;
200
+ seen.add(resolved.toLowerCase());
201
+ const id = `p${maxIdx++}`;
202
+ const name = path.basename(resolved) || resolved;
203
+ const label = resolved.length > 50 ? '...' + resolved.slice(-47) : resolved;
204
+ registry.set(id, { id, name, pathLabel: label, _path: resolved });
205
+ added++;
206
+ }
207
+ return added;
208
+ }
190
209
 
191
210
  /**
192
- * Start the dashboard HTTP server.
193
- * Can be called standalone (CLI) or embedded (from watcher).
211
+ * Start the dashboard HTTP server, or hot-add projects to an existing instance.
212
+ * Uses a module-level singleton: subsequent calls reuse the same port and server,
213
+ * merging new project paths into the live registry.
194
214
  *
195
215
  * @param {string[]} paths - Project directories to serve
196
216
  * @param {object} [opts]
197
- * @param {number} [opts.port=3120] - Starting port
217
+ * @param {number} [opts.port=3120] - Starting port (ignored if server already running)
198
218
  * @param {boolean} [opts.silent=false] - Suppress banner output
199
219
  * @returns {Promise<{server: http.Server, port: number, registry: Map}>}
200
220
  */
201
221
  function startDashboardServer(paths, opts = {}) {
222
+ if (_instance) {
223
+ const added = _mergeProjects(_instance.registry, paths);
224
+ if (added > 0 && !opts.silent) {
225
+ console.log(` [dashboard] Hot-added ${added} project(s) — total: ${_instance.registry.size} on port ${_instance.port}`);
226
+ }
227
+ return Promise.resolve(_instance);
228
+ }
229
+
202
230
  const port = opts.port || DEFAULT_PORT;
203
231
  const silent = opts.silent || false;
204
232
  const registry = buildRegistry(paths);
@@ -209,7 +237,6 @@ function startDashboardServer(paths, opts = {}) {
209
237
  let retries = 0;
210
238
 
211
239
  const server = http.createServer((req, res) => {
212
- // DNS rebinding protection: reject unexpected Host headers
213
240
  const host = req.headers.host || '';
214
241
  if (!ALLOWED_HOSTS.test(host)) {
215
242
  res.writeHead(403);
@@ -224,7 +251,6 @@ function startDashboardServer(paths, opts = {}) {
224
251
  try { parsed = new URL(req.url, `http://${host}`); }
225
252
  catch { return notFound(res); }
226
253
 
227
- // API endpoints require per-process token
228
254
  if (parsed.pathname.startsWith('/api/')) {
229
255
  const reqToken = parsed.searchParams.get('token');
230
256
  if (reqToken !== token) {
@@ -249,6 +275,7 @@ function startDashboardServer(paths, opts = {}) {
249
275
 
250
276
  server.on('listening', () => {
251
277
  const addr = server.address();
278
+ _instance = { server, port: addr.port, registry, token };
252
279
  if (!silent) {
253
280
  console.log('');
254
281
  console.log(' Cursor Guard Dashboard');
@@ -260,7 +287,7 @@ function startDashboardServer(paths, opts = {}) {
260
287
  }
261
288
  console.log('');
262
289
  }
263
- resolve({ server, port: addr.port, registry });
290
+ resolve(_instance);
264
291
  });
265
292
 
266
293
  server.listen(currentPort, '127.0.0.1');
@@ -277,4 +304,4 @@ if (require.main === module) {
277
304
  });
278
305
  }
279
306
 
280
- module.exports = { startDashboardServer };
307
+ module.exports = { startDashboardServer, getInstance: () => _instance };
@@ -146,7 +146,6 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
146
146
  const diffOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', parentTree, newTree], { cwd, allowFail: true });
147
147
  if (diffOut) {
148
148
  const diffLines = diffOut.split('\n').filter(Boolean);
149
- changedCount = diffLines.length;
150
149
  const groups = { M: [], A: [], D: [], R: [] };
151
150
  for (const line of diffLines) {
152
151
  const tab = line.indexOf('\t');
@@ -158,8 +157,10 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
158
157
  : code === 'A' ? 'A'
159
158
  : 'M';
160
159
  const fileName = filePart.split('\t').pop();
160
+ if (matchesAny(cfg.ignore, fileName) || matchesAny(cfg.ignore, path.basename(fileName))) continue;
161
161
  groups[key].push(fileName);
162
162
  }
163
+ changedCount = Object.values(groups).reduce((sum, arr) => sum + arr.length, 0);
163
164
 
164
165
  const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', parentTree, newTree], { cwd, allowFail: true });
165
166
  const stats = {};
@@ -199,7 +200,8 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
199
200
  } else {
200
201
  const lsInitial = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
201
202
  if (lsInitial) {
202
- const files = lsInitial.split('\n').filter(Boolean);
203
+ const files = lsInitial.split('\n').filter(Boolean)
204
+ .filter(f => !matchesAny(cfg.ignore, f) && !matchesAny(cfg.ignore, path.basename(f)));
203
205
  changedCount = files.length;
204
206
  const sample = files.slice(0, 5).join(', ');
205
207
  incrementalSummary = `Added ${files.length}: ${sample}${files.length > 5 ? ', ...' : ''}`;
@@ -290,10 +292,10 @@ function createShadowCopy(projectDir, cfg, opts = {}) {
290
292
  snapDir = path.join(backupDir, ts);
291
293
  }
292
294
  }
293
- fs.mkdirSync(snapDir, { recursive: true });
294
-
295
295
  const prevSnapDir = findPreviousSnapshot(backupDir);
296
296
 
297
+ fs.mkdirSync(snapDir, { recursive: true });
298
+
297
299
  const allFiles = walkDir(projectDir, projectDir);
298
300
  const files = filterFiles(allFiles, cfg);
299
301
 
@@ -177,7 +177,12 @@ function loadConfig(projectDir) {
177
177
  warnings.push(`Unknown backup_strategy "${raw.backup_strategy}", using default "${cfg.backup_strategy}"`);
178
178
  }
179
179
  }
180
- if (typeof raw.auto_backup_interval_seconds === 'number') cfg.auto_backup_interval_seconds = raw.auto_backup_interval_seconds;
180
+ if (typeof raw.auto_backup_interval_seconds === 'number') {
181
+ cfg.auto_backup_interval_seconds = raw.auto_backup_interval_seconds;
182
+ } else if (typeof raw.backup_interval_seconds === 'number') {
183
+ cfg.auto_backup_interval_seconds = raw.backup_interval_seconds;
184
+ warnings.push('backup_interval_seconds is a deprecated alias — please use auto_backup_interval_seconds');
185
+ }
181
186
  if (typeof raw.pre_restore_backup === 'string') {
182
187
  if (VALID_PRE_RESTORE.includes(raw.pre_restore_backup)) {
183
188
  cfg.pre_restore_backup = raw.pre_restore_backup;