cursor-guard 4.9.8 → 4.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +10 -1
  2. package/README.zh-CN.md +10 -1
  3. package/ROADMAP.md +50 -5
  4. package/docs/RELEASE.md +196 -0
  5. package/package.json +69 -68
  6. package/references/dashboard/public/app.js +313 -95
  7. package/references/dashboard/public/style.css +320 -160
  8. package/references/dashboard/server.js +197 -4
  9. package/references/lib/core/backups.js +36 -21
  10. package/references/lib/core/core.test.js +1629 -1484
  11. package/references/lib/core/snapshot.js +59 -8
  12. package/references/mcp/server.js +73 -72
  13. package/references/vscode-extension/{dist/cursor-guard-ide-4.9.8.vsix → cursor-guard-ide-4.9.12.vsix} +0 -0
  14. package/references/vscode-extension/dist/cursor-guard-ide-4.9.12.vsix +0 -0
  15. package/references/vscode-extension/dist/dashboard/public/app.js +313 -95
  16. package/references/vscode-extension/dist/dashboard/public/style.css +320 -160
  17. package/references/vscode-extension/dist/dashboard/server.js +197 -4
  18. package/references/vscode-extension/dist/extension.js +9 -2
  19. package/references/vscode-extension/dist/guard-version.json +1 -1
  20. package/references/vscode-extension/dist/lib/core/backups.js +36 -21
  21. package/references/vscode-extension/dist/lib/core/snapshot.js +59 -8
  22. package/references/vscode-extension/dist/lib/dashboard-manager.js +110 -103
  23. package/references/vscode-extension/dist/lib/poller.js +161 -21
  24. package/references/vscode-extension/dist/lib/sidebar-webview.js +469 -156
  25. package/references/vscode-extension/dist/mcp/server.js +85 -31
  26. package/references/vscode-extension/dist/package.json +1 -1
  27. package/references/vscode-extension/dist/skill/ROADMAP.md +50 -5
  28. package/references/vscode-extension/extension.js +9 -2
  29. package/references/vscode-extension/lib/dashboard-manager.js +110 -103
  30. package/references/vscode-extension/lib/poller.js +161 -21
  31. package/references/vscode-extension/lib/sidebar-webview.js +469 -156
  32. package/references/vscode-extension/package.json +140 -140
@@ -60,16 +60,18 @@ const I18N = {
60
60
  'alert.action.deleted': 'Deleted',
61
61
  'alert.action.renamed': 'Renamed',
62
62
  'alert.breakdown': '{added} added, {modified} modified, {deleted} deleted',
63
- 'alert.suggestion': 'Check recent changes and consider creating a manual snapshot',
64
- 'alert.viewFiles': 'View file details ({n} files)',
65
- 'preWarning.title': 'Pre-Warning',
66
- 'preWarning.none': 'No destructive edit risk detected',
67
- 'preWarning.active': 'Delete Risk',
68
- 'preWarning.file': 'File',
69
- 'preWarning.risk': 'Risk',
70
- 'preWarning.methods': 'Methods removed',
71
- 'preWarning.suggestion': 'Review this deletion before applying or restore from the latest snapshot',
72
- 'modal.alertFiles': 'Alert File Details',
63
+ 'alert.suggestion': 'Check recent changes and consider creating a manual snapshot',
64
+ 'alert.dismiss': 'Dismiss alert',
65
+ 'alert.dismissBusy': 'Dismissing…',
66
+ 'alert.viewFiles': 'View file details ({n} files)',
67
+ 'preWarning.title': 'Pre-Warning',
68
+ 'preWarning.none': 'No destructive edit risk detected',
69
+ 'preWarning.active': 'Delete Risk',
70
+ 'preWarning.file': 'File',
71
+ 'preWarning.risk': 'Risk',
72
+ 'preWarning.methods': 'Methods removed',
73
+ 'preWarning.suggestion': 'Review this deletion before applying or restore from the latest snapshot',
74
+ 'modal.alertFiles': 'Alert File Details',
73
75
  'modal.col.restore': 'Restore',
74
76
  'modal.copyRestore': 'Copy cmd',
75
77
  'modal.restorePreDelete':'Restore pre-delete',
@@ -86,7 +88,19 @@ const I18N = {
86
88
  'backups.noBackups': 'No restore points found',
87
89
  'backups.col.time': 'Time',
88
90
  'backups.col.type': 'Type',
91
+ 'backups.col.meta': 'Scope / baseline',
89
92
  'backups.col.ref': 'Ref / Hash',
93
+ 'backups.meta.legacyHint': 'Commit predates Guard scope/baseline trailers',
94
+
95
+ 'backups.scope.full': 'Full workspace',
96
+ 'backups.scope.narrow': 'Protect patterns only',
97
+ 'backups.scope.unknown': 'Unknown',
98
+
99
+ 'backups.baseline.autoBackup': 'Δ vs last auto-backup tip',
100
+ 'backups.baseline.snapshot': 'Δ vs last manual snapshot tip',
101
+ 'backups.baseline.initial': 'First Guard commit (no parent)',
102
+ 'backups.baseline.other': 'Δ vs custom ref',
103
+ 'backups.baseline.unknown': 'Unknown baseline',
90
104
 
91
105
  'type.git-auto-backup': 'Git Auto-Backup',
92
106
  'type.git-pre-restore': 'Git Pre-Restore',
@@ -125,6 +139,8 @@ const I18N = {
125
139
  'drawer.field.filesChanged': 'Files Changed',
126
140
  'drawer.field.summary': 'Change Summary',
127
141
  'drawer.field.trigger': 'Trigger',
142
+ 'drawer.field.guardScope': 'Snapshot scope',
143
+ 'drawer.field.guardDiffBase': 'Diff baseline',
128
144
  'trigger.auto': 'Auto (scheduled)',
129
145
  'trigger.manual': 'Manual (agent)',
130
146
  'trigger.pre-restore': 'Pre-Restore',
@@ -135,6 +151,7 @@ const I18N = {
135
151
  'summary.deleted': 'Deleted',
136
152
  'summary.renamed': 'Renamed',
137
153
  'summary.files': 'files',
154
+ 'summary.linesHint': 'Total lines added / deleted in this backup (from Summary)',
138
155
  'summary.andMore': 'and {n} more…',
139
156
  'drawer.field.intent': 'Intent',
140
157
  'drawer.field.agent': 'Agent',
@@ -177,9 +194,9 @@ const I18N = {
177
194
  'issue.disk_critically_low': 'Disk space critically low ({gb} GB free)',
178
195
  'issue.disk_low': 'Disk space low ({gb} GB free)',
179
196
  'issue.git_backup_stale': 'Last git backup is stale ({rel})',
180
- 'issue.active_alert': 'Active alert: {type} — {count} files in {window}s',
181
- 'issue.pre_warning_active': 'Pre-warning active: {summary}',
182
- 'issue.alert_high_velocity': 'High volume of file changes detected. Consider reviewing recent modifications and creating a manual snapshot.',
197
+ 'issue.active_alert': 'Active alert: {type} — {count} files in {window}s',
198
+ 'issue.pre_warning_active': 'Pre-warning active: {summary}',
199
+ 'issue.alert_high_velocity': 'High volume of file changes detected. Consider reviewing recent modifications and creating a manual snapshot.',
183
200
 
184
201
  'check.Git installed': 'Git installed',
185
202
  'check.Git repository': 'Git repository',
@@ -296,16 +313,18 @@ const I18N = {
296
313
  'alert.action.deleted': '删除',
297
314
  'alert.action.renamed': '重命名',
298
315
  'alert.breakdown': '新增 {added} · 修改 {modified} · 删除 {deleted}',
299
- 'alert.suggestion': '建议检查近期变更,并考虑手动创建快照',
300
- 'alert.viewFiles': '查看文件详情({n} 个文件)',
301
- 'preWarning.title': '事先预警',
302
- 'preWarning.none': '未检测到破坏性编辑风险',
303
- 'preWarning.active': '删除风险',
304
- 'preWarning.file': '文件',
305
- 'preWarning.risk': '风险',
306
- 'preWarning.methods': '移除的方法数',
307
- 'preWarning.suggestion': '建议在应用前检查这次删除,或直接从最新快照恢复',
308
- 'modal.alertFiles': '告警文件详情',
316
+ 'alert.suggestion': '建议检查近期变更,并考虑手动创建快照',
317
+ 'alert.dismiss': '忽略此告警',
318
+ 'alert.dismissBusy': '正在忽略…',
319
+ 'alert.viewFiles': '查看文件详情({n} 个文件)',
320
+ 'preWarning.title': '事先预警',
321
+ 'preWarning.none': '未检测到破坏性编辑风险',
322
+ 'preWarning.active': '删除风险',
323
+ 'preWarning.file': '文件',
324
+ 'preWarning.risk': '风险',
325
+ 'preWarning.methods': '移除的方法数',
326
+ 'preWarning.suggestion': '建议在应用前检查这次删除,或直接从最新快照恢复',
327
+ 'modal.alertFiles': '告警文件详情',
309
328
  'modal.col.restore': '恢复',
310
329
  'modal.copyRestore': '复制命令',
311
330
  'modal.restorePreDelete':'恢复删除前',
@@ -322,7 +341,19 @@ const I18N = {
322
341
  'backups.noBackups': '暂无恢复点',
323
342
  'backups.col.time': '时间',
324
343
  'backups.col.type': '类型',
344
+ 'backups.col.meta': '范围 / 基线',
325
345
  'backups.col.ref': '引用 / Hash',
346
+ 'backups.meta.legacyHint': '该提交早于 Guard 范围/基线 trailer',
347
+
348
+ 'backups.scope.full': '全工作区',
349
+ 'backups.scope.narrow': '仅 protect 规则内',
350
+ 'backups.scope.unknown': '未知',
351
+
352
+ 'backups.baseline.autoBackup': '相对上次自动备份 tip',
353
+ 'backups.baseline.snapshot': '相对上次手动快照 tip',
354
+ 'backups.baseline.initial': '首条 Guard 提交(无父提交)',
355
+ 'backups.baseline.other': '相对自定义引用',
356
+ 'backups.baseline.unknown': '基线未知',
326
357
 
327
358
  'type.git-auto-backup': 'Git 自动备份',
328
359
  'type.git-pre-restore': 'Git 恢复前快照',
@@ -361,6 +392,8 @@ const I18N = {
361
392
  'drawer.field.filesChanged': '变更文件数',
362
393
  'drawer.field.summary': '变更摘要',
363
394
  'drawer.field.trigger': '触发方式',
395
+ 'drawer.field.guardScope': '快照范围',
396
+ 'drawer.field.guardDiffBase': '差异基线',
364
397
  'trigger.auto': '自动(定时)',
365
398
  'trigger.manual': '手动(Agent)',
366
399
  'trigger.pre-restore': '恢复前快照',
@@ -371,6 +404,7 @@ const I18N = {
371
404
  'summary.deleted': '删除',
372
405
  'summary.renamed': '重命名',
373
406
  'summary.files': '个文件',
407
+ 'summary.linesHint': '本条备份新增/删除行数合计(来自 Summary)',
374
408
  'summary.andMore': '等 {n} 个文件…',
375
409
  'drawer.field.intent': '操作意图',
376
410
  'drawer.field.agent': 'AI 模型',
@@ -414,8 +448,8 @@ const I18N = {
414
448
  'issue.disk_low': '磁盘空间不足({gb} GB 可用)',
415
449
  'issue.git_backup_stale': '最近 Git 备份已过时({rel})',
416
450
  'issue.active_alert': '活跃告警:{type}——{count} 个文件在 {window} 秒内变更',
417
- 'issue.pre_warning_active': '事先预警生效:{summary}',
418
- 'issue.alert_high_velocity': '检测到大量文件变更,建议检查最近修改并手动创建快照。',
451
+ 'issue.pre_warning_active': '事先预警生效:{summary}',
452
+ 'issue.alert_high_velocity': '检测到大量文件变更,建议检查最近修改并手动创建快照。',
419
453
 
420
454
  'check.Git installed': 'Git 安装状态',
421
455
  'check.Git repository': 'Git 仓库',
@@ -496,9 +530,13 @@ const state = {
496
530
  drawerOpen: null,
497
531
  alertHistory: loadAlertHistory(),
498
532
  alertExpiresAt: null,
533
+ /** Server-Sent Events for push refresh (see /api/events); closed on project switch */
534
+ eventSource: null,
499
535
  };
500
536
 
501
- const REFRESH_MS = 15000;
537
+ /** Rare fallback poll if SSE misses (OS watcher gaps); primary updates come from EventSource. */
538
+ const FALLBACK_POLL_MS_VISIBLE = 180000;
539
+ const FALLBACK_POLL_MS_HIDDEN = 600000;
502
540
  const ALERT_HISTORY_KEY = 'cursorGuard_alertHistory';
503
541
 
504
542
  function loadAlertHistory() {
@@ -573,9 +611,9 @@ const ISSUE_PATTERNS = [
573
611
  { re: /^Disk space low \((.+?) GB free\)$/, key: 'issue.disk_low', extract: ['gb'] },
574
612
  { re: /^Last git backup is stale \((.+?)\)$/, key: 'issue.git_backup_stale', extract: ['rel'] },
575
613
  { re: /^Active alert: (.+?) — (\d+) files in (\d+)s$/, key: 'issue.active_alert', extract: ['type', 'count', 'window'] },
576
- { re: /^Pre-warning active: (.+)$/, key: 'issue.pre_warning_active', extract: ['summary'] },
577
- { re: /^High volume of file changes/, key: 'issue.alert_high_velocity' },
578
- ];
614
+ { re: /^Pre-warning active: (.+)$/, key: 'issue.pre_warning_active', extract: ['summary'] },
615
+ { re: /^High volume of file changes/, key: 'issue.alert_high_velocity' },
616
+ ];
579
617
 
580
618
  function translateIssue(text) {
581
619
  for (const p of ISSUE_PATTERNS) {
@@ -714,6 +752,38 @@ async function fetchJson(url) {
714
752
  }
715
753
  }
716
754
 
755
+ /** POST JSON API (same token query as GET). */
756
+ async function postJson(url) {
757
+ const base = window.__GUARD_BASE_URL__ || '';
758
+ const sep = url.includes('?') ? '&' : '?';
759
+ const tokenParam = window.__GUARD_TOKEN__ ? `${sep}token=${encodeURIComponent(window.__GUARD_TOKEN__)}` : '';
760
+ const r = await fetch(base + url + tokenParam, { method: 'POST' });
761
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
762
+ _fetchFailCount = 0;
763
+ return r.json();
764
+ }
765
+
766
+ async function dismissActiveAlert() {
767
+ if (!state.currentProjectId) return;
768
+ const btn = document.querySelector('[data-dismiss-active-alert]');
769
+ if (btn) {
770
+ btn.disabled = true;
771
+ btn.textContent = t('alert.dismissBusy');
772
+ }
773
+ try {
774
+ await postJson(`/api/dismiss-alert?id=${encodeURIComponent(state.currentProjectId)}`);
775
+ await loadPageData();
776
+ renderAll();
777
+ } catch (e) {
778
+ showGlobalError(e.message || String(e));
779
+ } finally {
780
+ if (btn) {
781
+ btn.disabled = false;
782
+ btn.textContent = t('alert.dismiss');
783
+ }
784
+ }
785
+ }
786
+
717
787
  async function loadProjects() {
718
788
  state.projects = await fetchJson('/api/projects');
719
789
  if (state.projects.length > 0 && !state.currentProjectId) {
@@ -764,11 +834,15 @@ async function loadPageData(opts = {}) {
764
834
 
765
835
  /* ── Refresh ──────────────────────────────────────────────── */
766
836
 
837
+ function fallbackPollIntervalMs() {
838
+ return (typeof document !== 'undefined' && document.hidden) ? FALLBACK_POLL_MS_HIDDEN : FALLBACK_POLL_MS_VISIBLE;
839
+ }
840
+
767
841
  function startRefresh() {
768
842
  stopRefresh();
769
843
  state.refreshTimer = setInterval(async () => {
770
844
  try { await loadPageData(); renderAll(); } catch { /* keep existing */ }
771
- }, REFRESH_MS);
845
+ }, fallbackPollIntervalMs());
772
846
  state.tickTimer = setInterval(updateRefreshDisplay, 1000);
773
847
  }
774
848
 
@@ -777,6 +851,41 @@ function stopRefresh() {
777
851
  if (state.tickTimer) { clearInterval(state.tickTimer); state.tickTimer = null; }
778
852
  }
779
853
 
854
+ function disconnectLivePush() {
855
+ if (state.eventSource) {
856
+ try { state.eventSource.close(); } catch { /* ignore */ }
857
+ state.eventSource = null;
858
+ }
859
+ }
860
+
861
+ /** Subscribe to server push (fs.watch → SSE). No token in page = skip (dev mode). */
862
+ function connectLivePush() {
863
+ disconnectLivePush();
864
+ if (!state.currentProjectId || typeof EventSource === 'undefined') return;
865
+ if (!window.__GUARD_TOKEN__) return;
866
+ const base = window.__GUARD_BASE_URL__ || '';
867
+ const id = encodeURIComponent(state.currentProjectId);
868
+ const tk = encodeURIComponent(window.__GUARD_TOKEN__);
869
+ const url = `${base}/api/events?id=${id}&token=${tk}`;
870
+ try {
871
+ const es = new EventSource(url);
872
+ state.eventSource = es;
873
+ es.onmessage = (ev) => {
874
+ try {
875
+ const msg = JSON.parse(ev.data);
876
+ if (msg.type === 'guard-changed' && msg.projectId === state.currentProjectId) {
877
+ void (async () => {
878
+ try {
879
+ await loadPageData();
880
+ renderAll();
881
+ } catch { /* keep existing UI */ }
882
+ })();
883
+ }
884
+ } catch { /* ignore non-json */ }
885
+ };
886
+ } catch { /* ignore */ }
887
+ }
888
+
780
889
  async function manualRefresh() {
781
890
  const icon = $('#refresh-btn .icon-spin');
782
891
  if (icon) icon.classList.add('icon-spin-active');
@@ -785,6 +894,7 @@ async function manualRefresh() {
785
894
  catch (e) { showGlobalError(e.message); }
786
895
  if (icon) icon.classList.remove('icon-spin-active');
787
896
  startRefresh();
897
+ connectLivePush();
788
898
  }
789
899
 
790
900
  function updateRefreshDisplay() {
@@ -867,14 +977,14 @@ function renderAll() {
867
977
 
868
978
  /* ── Rendering: Overview ──────────────────────────────────── */
869
979
 
870
- function renderOverview(d) {
871
- renderHealthCard(d.health);
872
- renderGitBackupCard(d.lastBackup);
873
- renderShadowBackupCard(d.lastBackup);
874
- renderPreWarningCard(d.preWarnings);
875
- renderWatcherCard(d.watcher);
876
- renderAlertCard(d.alerts);
877
- }
980
+ function renderOverview(d) {
981
+ renderHealthCard(d.health);
982
+ renderGitBackupCard(d.lastBackup);
983
+ renderShadowBackupCard(d.lastBackup);
984
+ renderPreWarningCard(d.preWarnings);
985
+ renderWatcherCard(d.watcher);
986
+ renderAlertCard(d.alerts);
987
+ }
878
988
 
879
989
  function renderHealthCard(health) {
880
990
  const el = $('#card-health');
@@ -907,9 +1017,9 @@ function renderGitBackupCard(lastBackup) {
907
1017
  `;
908
1018
  }
909
1019
 
910
- function renderShadowBackupCard(lastBackup) {
911
- const el = $('#card-shadow-backup');
912
- const s = lastBackup?.shadow;
1020
+ function renderShadowBackupCard(lastBackup) {
1021
+ const el = $('#card-shadow-backup');
1022
+ const s = lastBackup?.shadow;
913
1023
  if (!s) {
914
1024
  el.innerHTML = `<div class="card-label">${t('shadowBackup.title')}</div><div class="card-empty">${t('shadowBackup.none')}</div>`;
915
1025
  return;
@@ -917,40 +1027,40 @@ function renderShadowBackupCard(lastBackup) {
917
1027
  el.innerHTML = `
918
1028
  <div class="card-label">${t('shadowBackup.title')}</div>
919
1029
  <div class="card-value">${esc(relativeTime(s.timestamp))}</div>
920
- <div class="card-detail text-muted text-sm">${esc(formatTime(s.timestamp))}</div>
921
- `;
922
- }
923
-
924
- function renderPreWarningCard(preWarnings) {
925
- const el = $('#card-prewarning');
926
- if (!preWarnings?.active) {
927
- el.innerHTML = `
928
- <div class="card-label">${t('preWarning.title')}</div>
929
- <div class="card-status"><span class="status-dot status-healthy"></span><span>${t('preWarning.none')}</span></div>
930
- `;
931
- return;
932
- }
933
-
934
- const warning = preWarnings.latest || {};
935
- const file = warning.file || '-';
936
- const risk = warning.riskPercent !== undefined ? `${warning.riskPercent}%` : '-';
937
- const methods = warning.removedMethodCount || 0;
938
-
939
- el.innerHTML = `
940
- <div class="card-label">${t('preWarning.title')}</div>
941
- <div class="card-status"><span class="status-dot status-warning"></span><span class="status-text status-warning">${t('preWarning.active')}</span></div>
942
- <div class="alert-details">
943
- <div class="alert-detail-row"><span class="alert-detail-label">${t('preWarning.file')}</span><span class="text-mono">${esc(file)}</span></div>
944
- <div class="alert-detail-row"><span class="alert-detail-label">${t('preWarning.risk')}</span><span>${esc(risk)}</span></div>
945
- ${methods > 0 ? `<div class="alert-detail-row"><span class="alert-detail-label">${t('preWarning.methods')}</span><span>${methods}</span></div>` : ''}
946
- <div class="alert-detail-row alert-breakdown text-sm">${esc(warning.summary || t('preWarning.suggestion'))}</div>
947
- <div class="alert-detail-row alert-suggestion text-sm text-muted">${t('preWarning.suggestion')}</div>
948
- </div>
949
- `;
950
- }
951
-
952
- function renderWatcherCard(watcher) {
953
- const el = $('#card-watcher');
1030
+ <div class="card-detail text-muted text-sm">${esc(formatTime(s.timestamp))}</div>
1031
+ `;
1032
+ }
1033
+
1034
+ function renderPreWarningCard(preWarnings) {
1035
+ const el = $('#card-prewarning');
1036
+ if (!preWarnings?.active) {
1037
+ el.innerHTML = `
1038
+ <div class="card-label">${t('preWarning.title')}</div>
1039
+ <div class="card-status"><span class="status-dot status-healthy"></span><span>${t('preWarning.none')}</span></div>
1040
+ `;
1041
+ return;
1042
+ }
1043
+
1044
+ const warning = preWarnings.latest || {};
1045
+ const file = warning.file || '-';
1046
+ const risk = warning.riskPercent !== undefined ? `${warning.riskPercent}%` : '-';
1047
+ const methods = warning.removedMethodCount || 0;
1048
+
1049
+ el.innerHTML = `
1050
+ <div class="card-label">${t('preWarning.title')}</div>
1051
+ <div class="card-status"><span class="status-dot status-warning"></span><span class="status-text status-warning">${t('preWarning.active')}</span></div>
1052
+ <div class="alert-details">
1053
+ <div class="alert-detail-row"><span class="alert-detail-label">${t('preWarning.file')}</span><span class="text-mono">${esc(file)}</span></div>
1054
+ <div class="alert-detail-row"><span class="alert-detail-label">${t('preWarning.risk')}</span><span>${esc(risk)}</span></div>
1055
+ ${methods > 0 ? `<div class="alert-detail-row"><span class="alert-detail-label">${t('preWarning.methods')}</span><span>${methods}</span></div>` : ''}
1056
+ <div class="alert-detail-row alert-breakdown text-sm">${esc(warning.summary || t('preWarning.suggestion'))}</div>
1057
+ <div class="alert-detail-row alert-suggestion text-sm text-muted">${t('preWarning.suggestion')}</div>
1058
+ </div>
1059
+ `;
1060
+ }
1061
+
1062
+ function renderWatcherCard(watcher) {
1063
+ const el = $('#card-watcher');
954
1064
  let st = 'stopped';
955
1065
  if (watcher?.running) st = 'running';
956
1066
  else if (watcher?.stale) st = 'stale';
@@ -1079,7 +1189,10 @@ function renderAlertCard(alerts) {
1079
1189
  ${alertFileBreakdown(files) ? `<div class="alert-detail-row alert-breakdown text-sm">${esc(alertFileBreakdown(files))}</div>` : ''}
1080
1190
  <div class="alert-detail-row alert-suggestion text-sm text-muted">${t('alert.suggestion')}</div>
1081
1191
  </div>
1082
- ${filesHtml}
1192
+ <div class="alert-card-actions">
1193
+ ${filesHtml}
1194
+ <button type="button" class="btn-alert-dismiss" data-dismiss-active-alert>${t('alert.dismiss')}</button>
1195
+ </div>
1083
1196
  ${buildAlertHistoryHtml()}
1084
1197
  `;
1085
1198
  }
@@ -1147,26 +1260,56 @@ function translateSummary(raw) {
1147
1260
  .replace(/\bRenamed (\d+)/g, (_, n) => `${t('summary.renamed')} ${n}`);
1148
1261
  }
1149
1262
 
1263
+ /** Strip CR / normalize newlines so per-line trailers and Summary parse reliably on Windows. */
1264
+ function normalizeSummaryText(summary) {
1265
+ if (!summary) return '';
1266
+ return String(summary).replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
1267
+ }
1268
+
1269
+ /**
1270
+ * Sum all (+added -deleted) hunks in a Guard Summary line (same semantics as inline file rows).
1271
+ * @returns {{ added: number, deleted: number, matches: number }|null}
1272
+ */
1273
+ function sumDeltaFromSummary(summary) {
1274
+ const s = normalizeSummaryText(summary);
1275
+ if (!s) return null;
1276
+ let added = 0;
1277
+ let deleted = 0;
1278
+ let matches = 0;
1279
+ const re = /\(\+(\d+)\s*-\s*(\d+)\)/g;
1280
+ let m;
1281
+ while ((m = re.exec(s)) !== null) {
1282
+ added += parseInt(m[1], 10) || 0;
1283
+ deleted += parseInt(m[2], 10) || 0;
1284
+ matches++;
1285
+ }
1286
+ if (matches === 0) return null;
1287
+ return { added, deleted, matches };
1288
+ }
1289
+
1150
1290
  /**
1151
1291
  * Parse summary text into structured file array for inline preview.
1152
1292
  * Format: "Modified 3: a.js (+2 -1), b.js (+0 -5), ...; Added 2: c.js (+10 -0), d.js (+3 -0)"
1153
1293
  */
1154
1294
  function parseSummaryToFiles(summary) {
1155
- if (!summary) return [];
1295
+ const normalized = normalizeSummaryText(summary);
1296
+ if (!normalized) return [];
1156
1297
  const ACTION_MAP = { Modified: 'modified', Added: 'added', Deleted: 'deleted', Renamed: 'renamed' };
1157
1298
  const files = [];
1158
- for (const segment of summary.split('; ')) {
1159
- const headerMatch = segment.match(/^(Modified|Added|Deleted|Renamed)\s+\d+:\s*/);
1299
+ for (const segment of normalized.split('; ')) {
1300
+ const seg = segment.trim();
1301
+ const headerMatch = seg.match(/^(Modified|Added|Deleted|Renamed)\s+\d+:\s*/);
1160
1302
  if (!headerMatch) continue;
1161
1303
  const action = ACTION_MAP[headerMatch[1]] || 'modified';
1162
- const rest = segment.slice(headerMatch[0].length);
1304
+ const rest = seg.slice(headerMatch[0].length);
1163
1305
  for (const part of rest.split(/,\s*/)) {
1164
- if (part === '...') continue;
1165
- const fileMatch = part.match(/^(.+?)\s*\(\+(\d+)\s+-(\d+)\)$/);
1306
+ const p = part.trim();
1307
+ if (!p || p === '...') continue;
1308
+ const fileMatch = p.match(/^(.+?)\s*\(\+(\d+)\s*-\s*(\d+)\)\s*$/);
1166
1309
  if (fileMatch) {
1167
- files.push({ path: fileMatch[1], action, added: parseInt(fileMatch[2], 10), deleted: parseInt(fileMatch[3], 10) });
1168
- } else if (part.trim()) {
1169
- files.push({ path: part.trim(), action, added: 0, deleted: 0 });
1310
+ files.push({ path: fileMatch[1].trim(), action, added: parseInt(fileMatch[2], 10), deleted: parseInt(fileMatch[3], 10) });
1311
+ } else {
1312
+ files.push({ path: p, action, added: 0, deleted: 0 });
1170
1313
  }
1171
1314
  }
1172
1315
  }
@@ -1191,13 +1334,22 @@ function formatFileActionBadge(action) {
1191
1334
  }
1192
1335
 
1193
1336
  function formatSummaryCell(b) {
1337
+ const deltaTotals = sumDeltaFromSummary(b.summary);
1338
+ const deltaHtml = deltaTotals
1339
+ ? `<span class="summary-line-delta text-mono" title="${esc(t('summary.linesHint'))}">+${deltaTotals.added} -${deltaTotals.deleted}</span>`
1340
+ : '';
1341
+
1194
1342
  let line1 = '';
1195
1343
  if (b.filesChanged != null) {
1196
- line1 = `<div class="summary-meta"><span class="summary-files">${b.filesChanged} ${t('summary.files')}</span></div>`;
1344
+ line1 = `<div class="summary-meta"><span class="summary-files">${b.filesChanged} ${t('summary.files')}</span>${deltaHtml}</div>`;
1345
+ } else if (deltaHtml) {
1346
+ line1 = `<div class="summary-meta">${deltaHtml}</div>`;
1197
1347
  }
1198
1348
 
1199
1349
  let line2 = '';
1200
- if (b.from && b.restoreTo) {
1350
+ // From / Restore-To 仅用于「恢复前快照」语义;自动备份/手动快照若误带同名 trailer(如 Summary 换行),不应抢走 Intent 展示。
1351
+ const isPreRestoreType = b.type === 'git-pre-restore' || b.type === 'shadow-pre-restore';
1352
+ if (isPreRestoreType && b.from && b.restoreTo) {
1201
1353
  const label = b.restoreFile ? `${esc(b.restoreFile)}: ` : '';
1202
1354
  line2 = `<div class="summary-restore-ctx">${label}<span class="text-mono">${esc(b.from)}</span> → <span class="text-mono">${esc(b.restoreTo)}</span></div>`;
1203
1355
  } else if (b.intent) {
@@ -1225,7 +1377,7 @@ function formatSummaryCell(b) {
1225
1377
  const moreHtml = (remaining > 0 || truncated) ? `<div class="summary-file-more text-sm text-muted">${t('summary.andMore', { n: moreCount > 0 ? moreCount : '…' })}</div>` : '';
1226
1378
  line3 = visible + moreHtml;
1227
1379
  } else {
1228
- const categories = b.summary.split('; ').map(s => translateSummary(s));
1380
+ const categories = normalizeSummaryText(b.summary).split('; ').map(s => translateSummary(s.trim())).filter(Boolean);
1229
1381
  line3 = categories.slice(0, 2).map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
1230
1382
  }
1231
1383
  }
@@ -1234,6 +1386,36 @@ function formatSummaryCell(b) {
1234
1386
  return `<div class="summary-stack">${line1}${line2}${line3}</div>`;
1235
1387
  }
1236
1388
 
1389
+ function formatGuardScopeLabel(scope) {
1390
+ if (scope === 'full') return t('backups.scope.full');
1391
+ if (scope === 'narrow') return t('backups.scope.narrow');
1392
+ return t('backups.scope.unknown');
1393
+ }
1394
+
1395
+ function formatGuardBaselineLabel(base) {
1396
+ const map = {
1397
+ 'auto-backup': 'backups.baseline.autoBackup',
1398
+ snapshot: 'backups.baseline.snapshot',
1399
+ initial: 'backups.baseline.initial',
1400
+ other: 'backups.baseline.other',
1401
+ };
1402
+ const k = map[base];
1403
+ return k ? t(k) : t('backups.baseline.unknown');
1404
+ }
1405
+
1406
+ /** Git backup rows only: scope + shared-baseline hint from commit trailers */
1407
+ function formatBackupHumanMeta(b) {
1408
+ const show = b.type === 'git-auto-backup' || b.type === 'git-snapshot';
1409
+ if (!show) return '<span class="text-muted text-sm">-</span>';
1410
+ if (b.guardScope == null && b.guardDiffBase == null) {
1411
+ return `<span class="text-muted text-sm" title="${esc(t('backups.meta.legacyHint'))}">-</span>`;
1412
+ }
1413
+ const lines = [];
1414
+ if (b.guardScope != null) lines.push(`<span class="backup-meta-line">${esc(formatGuardScopeLabel(b.guardScope))}</span>`);
1415
+ if (b.guardDiffBase != null) lines.push(`<span class="backup-meta-line">${esc(formatGuardBaselineLabel(b.guardDiffBase))}</span>`);
1416
+ return `<div class="backup-human-meta">${lines.join('')}</div>`;
1417
+ }
1418
+
1237
1419
  function renderBackupTable(backups) {
1238
1420
  if (!Array.isArray(backups)) {
1239
1421
  $('#backup-table-wrap').innerHTML = `<div class="error-panel">${t('error.sectionFailed')}</div>`;
@@ -1249,7 +1431,9 @@ function renderBackupTable(backups) {
1249
1431
  (b.summary && b.summary.toLowerCase().includes(q)) ||
1250
1432
  (b.message && b.message.toLowerCase().includes(q)) ||
1251
1433
  (b.intent && b.intent.toLowerCase().includes(q)) ||
1252
- (b.restoreFile && b.restoreFile.toLowerCase().includes(q))
1434
+ (b.restoreFile && b.restoreFile.toLowerCase().includes(q)) ||
1435
+ (b.guardScope && String(b.guardScope).toLowerCase().includes(q)) ||
1436
+ (b.guardDiffBase && String(b.guardDiffBase).toLowerCase().includes(q))
1253
1437
  );
1254
1438
  }
1255
1439
  state.filteredBackups = filtered;
@@ -1262,9 +1446,11 @@ function renderBackupTable(backups) {
1262
1446
  const rows = state.filteredBackups.map((b, i) => {
1263
1447
  const badgeClass = b.type.startsWith('git') ? (b.type.includes('pre') ? 'badge-pre' : 'badge-git') : (b.type.includes('pre') ? 'badge-pre' : 'badge-shadow');
1264
1448
  const summaryCell = formatSummaryCell(b);
1449
+ const metaCell = formatBackupHumanMeta(b);
1265
1450
  return `<tr data-bi="${i}">
1266
1451
  <td><div>${esc(formatTime(b.timestamp))}</div><div class="text-muted text-sm">${esc(relativeTime(b.timestamp))}</div></td>
1267
1452
  <td><span class="badge ${badgeClass}">${t('type.' + b.type)}</span></td>
1453
+ <td class="backup-meta-cell">${metaCell}</td>
1268
1454
  <td class="text-mono">${esc(b.shortHash || b.timestamp || '-')}</td>
1269
1455
  <td class="backup-summary-cell">${summaryCell}</td>
1270
1456
  </tr>`;
@@ -1275,6 +1461,7 @@ function renderBackupTable(backups) {
1275
1461
  <thead><tr>
1276
1462
  <th>${t('backups.col.time')}</th>
1277
1463
  <th>${t('backups.col.type')}</th>
1464
+ <th>${t('backups.col.meta')}</th>
1278
1465
  <th>${t('backups.col.ref')}</th>
1279
1466
  <th>${t('backups.col.summary')}</th>
1280
1467
  </tr></thead>
@@ -1295,18 +1482,18 @@ function renderProtection(scope) {
1295
1482
 
1296
1483
  el.innerHTML = `
1297
1484
  <div class="protection-grid">
1298
- <div class="pattern-card">
1485
+ <div class="pattern-card pattern-card--protect">
1299
1486
  <h4>${t('protection.protect')}</h4>
1300
1487
  ${isAll
1301
- ? `<p class="text-muted text-sm">${t('protection.allFiles')}</p>`
1302
- : `<ul class="pattern-list">${protectList.map(p => `<li class="pattern-item">${esc(p)}</li>`).join('')}</ul>`
1488
+ ? `<p class="pattern-empty pattern-empty--protect text-sm">${t('protection.allFiles')}</p>`
1489
+ : `<ul class="pattern-list">${protectList.map(p => `<li class="pattern-item pattern-item--protect">${esc(p)}</li>`).join('')}</ul>`
1303
1490
  }
1304
1491
  </div>
1305
- <div class="pattern-card">
1492
+ <div class="pattern-card pattern-card--ignore">
1306
1493
  <h4>${t('protection.ignore')}</h4>
1307
1494
  ${ignoreList.length === 0
1308
- ? `<p class="text-muted text-sm">${t('protection.noIgnore')}</p>`
1309
- : `<ul class="pattern-list">${ignoreList.map(p => `<li class="pattern-item">${esc(p)}</li>`).join('')}</ul>`
1495
+ ? `<p class="pattern-empty pattern-empty--ignore text-sm">${t('protection.noIgnore')}</p>`
1496
+ : `<ul class="pattern-list">${ignoreList.map(p => `<li class="pattern-item pattern-item--ignore">${esc(p)}</li>`).join('')}</ul>`
1310
1497
  }
1311
1498
  </div>
1312
1499
  </div>
@@ -1459,6 +1646,12 @@ function openRestoreDrawer(backup) {
1459
1646
  { key: 'drawer.field.type', val: t('type.' + backup.type) },
1460
1647
  ];
1461
1648
  if (backup.trigger) fields.push({ key: 'drawer.field.trigger', val: t('trigger.' + backup.trigger) });
1649
+ if (backup.guardScope != null) {
1650
+ fields.push({ key: 'drawer.field.guardScope', val: formatGuardScopeLabel(backup.guardScope) });
1651
+ }
1652
+ if (backup.guardDiffBase != null) {
1653
+ fields.push({ key: 'drawer.field.guardDiffBase', val: formatGuardBaselineLabel(backup.guardDiffBase) });
1654
+ }
1462
1655
  if (backup.filesChanged != null) fields.push({ key: 'drawer.field.filesChanged', val: String(backup.filesChanged) });
1463
1656
  if (backup.ref) fields.push({ key: 'drawer.field.ref', val: backup.ref });
1464
1657
  if (backup.commitHash) fields.push({ key: 'drawer.field.hash', val: backup.commitHash });
@@ -1634,16 +1827,36 @@ function setupEvents() {
1634
1827
  $('#project-select').addEventListener('change', async (e) => {
1635
1828
  state.currentProjectId = e.target.value;
1636
1829
  state.backupFilter = 'all';
1830
+ disconnectLivePush();
1637
1831
  stopRefresh();
1638
1832
  showSkeleton();
1639
1833
  try { await loadPageData(); renderAll(); }
1640
1834
  catch (err) { showGlobalError(err.message); }
1641
1835
  startRefresh();
1836
+ connectLivePush();
1642
1837
  });
1643
1838
 
1644
1839
  $('#refresh-btn').addEventListener('click', manualRefresh);
1645
1840
  $('#error-retry').addEventListener('click', manualRefresh);
1646
1841
 
1842
+ document.addEventListener('visibilitychange', () => {
1843
+ if (!state.currentProjectId) return;
1844
+ if (document.hidden) {
1845
+ stopRefresh();
1846
+ startRefresh();
1847
+ return;
1848
+ }
1849
+ void (async () => {
1850
+ try {
1851
+ await loadPageData();
1852
+ renderAll();
1853
+ } catch { /* keep existing UI */ }
1854
+ })();
1855
+ stopRefresh();
1856
+ startRefresh();
1857
+ connectLivePush();
1858
+ });
1859
+
1647
1860
  $('#lang-toggle').addEventListener('click', () => {
1648
1861
  setLocale(state.locale === 'zh-CN' ? 'en-US' : 'zh-CN');
1649
1862
  });
@@ -1703,6 +1916,10 @@ function setupEvents() {
1703
1916
  }
1704
1917
  return;
1705
1918
  }
1919
+ if (e.target.closest('[data-dismiss-active-alert]')) {
1920
+ e.preventDefault();
1921
+ dismissActiveAlert();
1922
+ }
1706
1923
  });
1707
1924
 
1708
1925
  // Diagnostics summary click
@@ -1820,6 +2037,7 @@ async function init() {
1820
2037
  await loadPageData({ progressive: true });
1821
2038
  renderAll();
1822
2039
  startRefresh();
2040
+ connectLivePush();
1823
2041
  checkServerVersion();
1824
2042
  } catch (e) {
1825
2043
  showGlobalError(e.message);