cursor-guard 4.5.9 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ROADMAP.md CHANGED
@@ -3,8 +3,8 @@
3
3
  > 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
4
4
  > 每一代向下兼容,低版本功能永远不废弃。
5
5
  >
6
- > **当前版本**:`V4.5.9`(V4 最终版)
7
- > **文档状态**:`V2` ~ `V4.5.9` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.6.0`
7
+ > **文档状态**:`V2` ~ `V4.6.0` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -465,6 +465,7 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
465
465
  | V4.5.6 | **Bug 修复 + 告警 UX + init 优化**:见下方详细说明 | ✅ |
466
466
  | V4.5.7 | **文件详情 Modal 修复 + Dashboard 端口复用**:见下方详细说明 | ✅ |
467
467
  | V4.5.8 | **Dashboard 版本更新检测 + 一键重启**:见下方详细说明 | ✅ |
468
+ | V4.6.0 | **告警 UX 全面升级**:见下方详细说明 | ✅ |
468
469
 
469
470
  #### V4.4.1 详细内容
470
471
 
@@ -685,6 +686,18 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
685
686
  | 重启流程 | 前端发 POST `/api/restart` → 显示"正在重启..." → 每 500ms 轮询 `/api/version`(最多 10s)→ 服务就绪后 `location.reload()` 自动刷新页面 |
686
687
  | 双语支持 | 新增 `upgrade.*` 共 8 组 i18n key(banner/dismiss/restartNow/restart/hint/restarting/waiting/failed) |
687
688
 
689
+ #### V4.6.0 详细内容
690
+
691
+ **告警 UX 全面升级**:
692
+
693
+ | 修复项 | 说明 |
694
+ |------|------|
695
+ | 倒计时秒级实时更新 | `state.alertExpiresAt` 在 `renderAlertCard` 时记录过期时间;已有的 1 秒定时器 `updateRefreshDisplay` 中实时更新 `.alert-countdown` 元素,无需额外 `setInterval` |
696
+ | 告警文件详情复制/恢复操作 | 告警详情弹窗现在传入 `commitHash`(从 `alerts.latest.commitHash` 或 `'HEAD'` 回退),使每行文件的"复制恢复命令"按钮正常显示和工作 |
697
+ | 备份过时阈值修正 | 阈值从 `interval * 5 / 60` 分钟改为 `max(interval * 10, 300)` 秒(至少 5 分钟);仅在 watcher 正在运行时才检查过时状态;文案从"stale"改为中性的"Last git backup: X ago" |
698
+ | 告警历史常驻入口 | `buildAlertHistoryHtml()` 提取为独立函数,在活跃告警和无告警两种状态下都渲染"历史 N 条"折叠按钮,告警激活时也能查看历史 |
699
+ | 告警历史 localStorage 持久化 | 新增 `loadAlertHistory()` / `saveAlertHistory()`,使用 `localStorage` key `cursorGuard_alertHistory` 持久化最近 20 条告警历史,刷新页面不丢失 |
700
+
688
701
  #### V4.5.x 新增配置参考
689
702
 
690
703
  | 字段 | 类型 | 默认值 | 引入版本 | 说明 |
@@ -709,6 +722,15 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
709
722
  }
710
723
  ```
711
724
 
725
+ ### V4.7 规划:IDE 集成(VSCode/Cursor Extension)
726
+
727
+ | 项目 | 说明 |
728
+ |------|------|
729
+ | 定位 | 将 Dashboard 从浏览器迁移到 IDE 内置 WebView,减少上下文切换 |
730
+ | 实现方式 | VSCode Extension + WebView Panel,复用现有 `dashboard/public/` 前端代码 |
731
+ | 核心能力 | IDE 侧边栏入口、状态栏告警指示器、WebView 内嵌 Dashboard、一键快照按钮 |
732
+ | 状态 | 🔮 规划中 |
733
+
712
734
  ### V4 不做的事
713
735
 
714
736
  - 不做自动恢复(恢复永远需要人确认,这是产品底线)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.5.9",
3
+ "version": "4.6.0",
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",
@@ -470,10 +470,23 @@ const state = {
470
470
  tickTimer: null,
471
471
  lastRefreshAt: null,
472
472
  drawerOpen: null,
473
- alertHistory: [],
473
+ alertHistory: loadAlertHistory(),
474
+ alertExpiresAt: null,
474
475
  };
475
476
 
476
477
  const REFRESH_MS = 15000;
478
+ const ALERT_HISTORY_KEY = 'cursorGuard_alertHistory';
479
+
480
+ function loadAlertHistory() {
481
+ try {
482
+ const raw = localStorage.getItem(ALERT_HISTORY_KEY);
483
+ return raw ? JSON.parse(raw) : [];
484
+ } catch { return []; }
485
+ }
486
+
487
+ function saveAlertHistory() {
488
+ try { localStorage.setItem(ALERT_HISTORY_KEY, JSON.stringify(state.alertHistory)); } catch { /* quota */ }
489
+ }
477
490
 
478
491
  /* ── DOM helpers ──────────────────────────────────────────── */
479
492
 
@@ -737,9 +750,17 @@ async function manualRefresh() {
737
750
 
738
751
  function updateRefreshDisplay() {
739
752
  const el = $('#last-refresh');
740
- if (!el || !state.lastRefreshAt) return;
741
- const sec = Math.floor((Date.now() - state.lastRefreshAt) / 1000);
742
- el.textContent = `${t('topbar.lastRefresh')}: ${sec}s`;
753
+ if (el && state.lastRefreshAt) {
754
+ const sec = Math.floor((Date.now() - state.lastRefreshAt) / 1000);
755
+ el.textContent = `${t('topbar.lastRefresh')}: ${sec}s`;
756
+ }
757
+
758
+ const cdEl = document.querySelector('.alert-countdown');
759
+ if (cdEl && state.alertExpiresAt) {
760
+ const remain = Math.max(0, state.alertExpiresAt - Date.now());
761
+ const s = Math.ceil(remain / 1000);
762
+ cdEl.textContent = s > 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
763
+ }
743
764
  }
744
765
 
745
766
  /* ── Rendering: Top bar ───────────────────────────────────── */
@@ -889,33 +910,35 @@ function alertFileBreakdown(files) {
889
910
  return t('alert.breakdown', { added, modified, deleted });
890
911
  }
891
912
 
913
+ function buildAlertHistoryHtml() {
914
+ if (state.alertHistory.length === 0) return '';
915
+ const count = state.alertHistory.length;
916
+ const rows = state.alertHistory.slice(-5).reverse().map(h => {
917
+ const breakdown = alertFileBreakdown(h.files);
918
+ return `<div class="alert-history-row text-sm text-muted">
919
+ <span class="alert-history-time">${esc(formatTime(h.timestamp))}</span>
920
+ <span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
921
+ ${breakdown ? `<span class="alert-history-breakdown">${esc(breakdown)}</span>` : ''}
922
+ <span class="badge badge-expired">${t('alert.expired')}</span>
923
+ </div>`;
924
+ }).join('');
925
+ return `
926
+ <div class="alert-history-toggle-wrap">
927
+ <button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
928
+ </div>
929
+ <div class="alert-history alert-history-collapsed">
930
+ <div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}
931
+ </div>`;
932
+ }
933
+
892
934
  function renderAlertCard(alerts) {
893
935
  const el = $('#card-alert');
894
936
  if (!alerts?.active) {
895
- let historyHtml = '';
896
- if (state.alertHistory.length > 0) {
897
- const count = state.alertHistory.length;
898
- const rows = state.alertHistory.slice(-5).reverse().map(h => {
899
- const breakdown = alertFileBreakdown(h.files);
900
- return `<div class="alert-history-row text-sm text-muted">
901
- <span class="alert-history-time">${esc(formatTime(h.timestamp))}</span>
902
- <span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
903
- ${breakdown ? `<span class="alert-history-breakdown">${esc(breakdown)}</span>` : ''}
904
- <span class="badge badge-expired">${t('alert.expired')}</span>
905
- </div>`;
906
- }).join('');
907
- historyHtml = `
908
- <div class="alert-history-toggle-wrap">
909
- <button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
910
- </div>
911
- <div class="alert-history alert-history-collapsed">
912
- <div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}
913
- </div>`;
914
- }
937
+ state.alertExpiresAt = null;
915
938
  el.innerHTML = `
916
939
  <div class="card-label">${t('alert.title')}</div>
917
940
  <div class="card-status"><span class="status-dot status-healthy"></span><span>${t('alert.none')}</span></div>
918
- ${historyHtml}
941
+ ${buildAlertHistoryHtml()}
919
942
  `;
920
943
  return;
921
944
  }
@@ -925,10 +948,12 @@ function renderAlertCard(alerts) {
925
948
  if (a.timestamp && !state.alertHistory.some(h => h.timestamp === a.timestamp)) {
926
949
  state.alertHistory.push({ timestamp: a.timestamp, fileCount: a.fileCount, windowSeconds: a.windowSeconds, threshold: a.threshold, expiresAt: a.expiresAt, files: a.files });
927
950
  if (state.alertHistory.length > 20) state.alertHistory = state.alertHistory.slice(-20);
951
+ saveAlertHistory();
928
952
  }
929
953
 
930
954
  const triggeredAt = a.timestamp ? formatTime(a.timestamp) : '-';
931
955
  const expiresAt = a.expiresAt ? new Date(a.expiresAt) : null;
956
+ state.alertExpiresAt = expiresAt ? expiresAt.getTime() : null;
932
957
  const remainMs = expiresAt ? expiresAt.getTime() - Date.now() : 0;
933
958
  const remainSec = Math.max(0, Math.ceil(remainMs / 1000));
934
959
  const remainMin = Math.floor(remainSec / 60);
@@ -956,6 +981,7 @@ function renderAlertCard(alerts) {
956
981
  <div class="alert-detail-row alert-suggestion text-sm text-muted">${t('alert.suggestion')}</div>
957
982
  </div>
958
983
  ${filesHtml}
984
+ ${buildAlertHistoryHtml()}
959
985
  `;
960
986
  }
961
987
 
@@ -1562,7 +1588,8 @@ function setupEvents() {
1562
1588
  const files = alerts?.latest?.files || [];
1563
1589
  if (files.length > 0) {
1564
1590
  const proj = state.pageData?.dashboard?.watcher?.path || '';
1565
- openFileModal(t('modal.alertFiles'), files, proj, '');
1591
+ const hash = alerts?.latest?.commitHash || 'HEAD';
1592
+ openFileModal(t('modal.alertFiles'), files, proj, hash);
1566
1593
  }
1567
1594
  return;
1568
1595
  }
@@ -168,12 +168,12 @@ function getDashboard(projectDir) {
168
168
  issues.push(`Disk space low (${status.disk.freeGB} GB free)`);
169
169
  }
170
170
 
171
- if (status.lastBackup.git) {
171
+ if (status.lastBackup.git && status.watcher.running) {
172
172
  const lastTs = new Date(status.lastBackup.git.timestamp).getTime();
173
- const staleMinutes = (Date.now() - lastTs) / 60000;
174
- const staleThreshold = Math.min(cfg.auto_backup_interval_seconds * 5 / 60, 30);
175
- if (staleMinutes > staleThreshold) {
176
- issues.push(`Last git backup is stale (${relativeTime(status.lastBackup.git.timestamp)})`);
173
+ const staleSec = (Date.now() - lastTs) / 1000;
174
+ const staleThreshold = Math.max(cfg.auto_backup_interval_seconds * 10, 300);
175
+ if (staleSec > staleThreshold) {
176
+ issues.push(`Last git backup: ${relativeTime(status.lastBackup.git.timestamp)}`);
177
177
  }
178
178
  }
179
179