cursor-guard 4.7.1 → 4.7.4

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.7.1`
7
- > **文档状态**:`V2` ~ `V4.7.1` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.7.4`
7
+ > **文档状态**:`V2` ~ `V4.7.4` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -734,6 +734,40 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
734
734
  }
735
735
  ```
736
736
 
737
+ ### V4.7.4:路径解析器 + 4 Bug 修复 ✅
738
+
739
+ | 修复 | 说明 |
740
+ |------|------|
741
+ | **根本修复:`paths.js` 路径解析器** | 新增智能路径解析模块,支持 3 种安装方式:① Skill 目录结构(`references/vscode-extension/lib/` → `../../`)② VSIX 扁平结构(`lib/` → `../`)③ 全局 skill 目录回退(`~/.cursor/skills/cursor-guard/references/`)。所有模块统一使用 `guardPath()` 替代硬编码 `../../` |
742
+ | **Bug #1: Dashboard 无法启动** | `dashboard-manager.js` 中 `require('../../dashboard/server')` 等 5 处路径在 VSIX 扁平结构中全部失败。改为 `require(guardPath('dashboard', 'server'))` |
743
+ | **Bug #2: Snapshot Now 报错** | `snapshotNow` 中 `require('../../lib/core/snapshot')` 路径错误 + `loadConfig` 解构已在 v4.7.1 修复但 `require` 路径仍错。改为 `guardPath('lib', 'core', 'snapshot')` |
744
+ | **Bug #3: snapshot skipped 未处理** | `createGitSnapshot` 无变更时返回 `{ status: 'skipped' }`,但 `extension.js` 只处理了 `created` 和 `unchanged`,导致显示 "snapshot failed"。新增 `skipped` 分支 |
745
+ | **Bug #4: WebView PUBLIC_DIR 多一层 `..`** | `webview-provider.js` 的 `path.resolve(__dirname, '..', '..', 'dashboard', 'public')` 多了一层。改为 `getPublicDir()` 动态解析 |
746
+
747
+ ### V4.7.3:可视化图表侧边栏 ✅
748
+
749
+ | 组件 | 说明 |
750
+ |------|------|
751
+ | **`sidebar-webview.js`** | 新增 `WebviewViewProvider`,在 Activity Bar 侧边栏底部渲染自定义 HTML/CSS 图表面板 |
752
+ | **状态徽章行** | 4 个彩色卡片一行排列:Watcher(绿/红)、Alerts(红/绿)、Health(绿/黄/红)、Files(蓝) |
753
+ | **告警横幅** | 活跃告警时显示红色横幅:文件数 + 窗口时间 + 阈值 + 倒计时 |
754
+ | **进度条图表** | Backup Statistics 区域用 4 根彩色进度条展示:Git 备份数、Shadow 快照数、Git 磁盘、Shadow 磁盘,附系统剩余空间 |
755
+ | **备份时间线** | Recent Backups 区域用圆点 + 时间 + 类型 + 摘要的紧凑列表展示最近 6 条备份 |
756
+ | **健康问题列表** | 逐条展示 health issues,黄/红圆点标注严重度 |
757
+ | **保护范围标签** | 用绿色/红色药丸标签展示 protect/ignore 规则 |
758
+ | **快捷操作栏** | 4 个 hover 高亮按钮:Dashboard / Snapshot / Start / Stop |
759
+ | **数据推送** | Poller 每 5 秒通过 `postMessage` 推送完整数据到 WebviewView,实时更新所有图表 |
760
+ | **TreeView 精简** | 上方 TreeView 只保留项目概要(名称/状态/操作),详细数据全在图表面板 |
761
+
762
+ ### V4.7.2:Sidebar Mini Dashboard + Watcher 修复 ✅
763
+
764
+ | 修复/增强 | 说明 |
765
+ |----------|------|
766
+ | **Watcher 无限重启修复** | `startWatcher` 误用 `lib/auto-backup.js`(库模块),改为正确的 CLI 入口 `bin/cursor-guard-backup.js`;新增防重复检查(已有进程则跳过) |
767
+ | **TreeView Mini Dashboard** | 侧边栏从简单列表重构为 7 个信息密集的折叠区:Watcher 详情(PID/启动时间/策略)、告警详情(倒计时/文件列表 top 10)、近期备份(最近 8 条 + 摘要/时间/类型)、统计(git/shadow 数量 + 磁盘占用 + 系统剩余空间)、健康检查(逐条 issue)、保护范围(protect/ignore 规则 + 文件数)、快捷操作 |
768
+ | **Poller 完整数据** | 后台轮询从只拉 `dashboard` scope 改为拉取完整 page data(含 backups/scope/doctor),驱动 TreeView 展示更多信息 |
769
+ | **彩色区分** | 7 个 section 使用不同 ThemeColor(绿=正常/红=告警/蓝=备份/紫=统计/橙=配置/黄=警告),项目节点根据状态显示 Protected/Unprotected/ALERT |
770
+
737
771
  ### V4.7.1:IDE 插件 Bug 修复 + UX 增强 ✅
738
772
 
739
773
  | 修复/增强 | 说明 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.7.1",
3
+ "version": "4.7.4",
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",
@@ -6,8 +6,9 @@ const { WebViewProvider } = require('./lib/webview-provider');
6
6
  const { StatusBarController } = require('./lib/status-bar');
7
7
  const { GuardTreeView } = require('./lib/tree-view');
8
8
  const { Poller } = require('./lib/poller');
9
+ const { SidebarDashboardProvider } = require('./lib/sidebar-webview');
9
10
 
10
- let dashMgr, poller, statusBar, treeView, webviewProvider;
11
+ let dashMgr, poller, statusBar, treeView, webviewProvider, sidebarProvider;
11
12
 
12
13
  async function activate(context) {
13
14
  dashMgr = new DashboardManager();
@@ -15,8 +16,11 @@ async function activate(context) {
15
16
  statusBar = new StatusBarController(poller);
16
17
  treeView = new GuardTreeView(poller, dashMgr);
17
18
  webviewProvider = new WebViewProvider(context, dashMgr);
19
+ sidebarProvider = new SidebarDashboardProvider(poller);
18
20
 
19
21
  context.subscriptions.push(
22
+ vscode.window.registerWebviewViewProvider('cursorGuardDashboard', sidebarProvider),
23
+
20
24
  vscode.commands.registerCommand('cursorGuard.openDashboard', () => {
21
25
  if (!dashMgr.running) {
22
26
  vscode.window.showWarningMessage('Cursor Guard: no projects detected. Add .cursor-guard.json to your workspace.');
@@ -32,10 +36,12 @@ async function activate(context) {
32
36
  const result = await dashMgr.snapshotNow(projectPath);
33
37
  if (result?.status === 'created') {
34
38
  vscode.window.showInformationMessage(`Cursor Guard: snapshot created (${result.changedCount || 0} changes)`);
35
- } else if (result?.status === 'unchanged') {
39
+ } else if (result?.status === 'unchanged' || result?.status === 'skipped') {
36
40
  vscode.window.showInformationMessage('Cursor Guard: no changes to snapshot');
41
+ } else if (result?.status === 'error') {
42
+ vscode.window.showWarningMessage(`Cursor Guard: ${result.error}`);
37
43
  } else {
38
- vscode.window.showWarningMessage(`Cursor Guard: ${result?.error || 'snapshot failed'}`);
44
+ vscode.window.showWarningMessage(`Cursor Guard: snapshot returned status "${result?.status || 'unknown'}"`);
39
45
  }
40
46
  poller.forceRefresh();
41
47
  }),
@@ -83,6 +89,7 @@ async function activate(context) {
83
89
  poller,
84
90
  treeView,
85
91
  webviewProvider,
92
+ sidebarProvider,
86
93
  );
87
94
 
88
95
  const started = await dashMgr.autoStart(vscode.workspace.workspaceFolders);
@@ -105,6 +112,7 @@ function deactivate() {
105
112
  if (statusBar) statusBar.dispose();
106
113
  if (treeView) treeView.dispose();
107
114
  if (webviewProvider) webviewProvider.dispose();
115
+ if (sidebarProvider) sidebarProvider.dispose();
108
116
  if (dashMgr) dashMgr.dispose();
109
117
  }
110
118
 
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const http = require('http');
6
6
  const { spawn } = require('child_process');
7
+ const { guardPath } = require('./paths');
7
8
 
8
9
  const CONFIG_FILE = '.cursor-guard.json';
9
10
 
@@ -30,7 +31,7 @@ class DashboardManager {
30
31
 
31
32
  async start(paths) {
32
33
  if (!this._serverModule) {
33
- this._serverModule = require('../../dashboard/server');
34
+ this._serverModule = require(guardPath('dashboard', 'server'));
34
35
  }
35
36
  const { startDashboardServer, getInstance } = this._serverModule;
36
37
  const existing = getInstance();
@@ -46,7 +47,7 @@ class DashboardManager {
46
47
  async fetchApi(endpoint) {
47
48
  if (!this._instance) return null;
48
49
  const url = `${this.baseUrl}${endpoint}${endpoint.includes('?') ? '&' : '?'}token=${this.token}`;
49
- return new Promise((resolve, reject) => {
50
+ return new Promise((resolve) => {
50
51
  http.get(url, (res) => {
51
52
  let data = '';
52
53
  res.on('data', chunk => data += chunk);
@@ -67,11 +68,19 @@ class DashboardManager {
67
68
  return this.fetchApi(`/api/page-data?id=${projectId}${scopeParam}`);
68
69
  }
69
70
 
71
+ async getFullPageData(projectId) {
72
+ return this.fetchApi(`/api/page-data?id=${projectId}`);
73
+ }
74
+
75
+ async getBackupFiles(projectId, hash) {
76
+ return this.fetchApi(`/api/backup-files?id=${projectId}&hash=${hash}`);
77
+ }
78
+
70
79
  async snapshotNow(projectPath) {
71
80
  if (!projectPath) return;
72
81
  try {
73
- const { createGitSnapshot } = require('../../lib/core/snapshot');
74
- const { loadConfig } = require('../../lib/utils');
82
+ const { createGitSnapshot } = require(guardPath('lib', 'core', 'snapshot'));
83
+ const { loadConfig } = require(guardPath('lib', 'utils'));
75
84
  const { cfg } = loadConfig(projectPath);
76
85
  return createGitSnapshot(projectPath, cfg, { message: 'guard: manual snapshot via IDE extension' });
77
86
  } catch (e) {
@@ -81,11 +90,14 @@ class DashboardManager {
81
90
 
82
91
  startWatcher(projectPath) {
83
92
  if (!projectPath) return null;
84
- const backupScript = path.resolve(__dirname, '..', '..', 'lib', 'auto-backup.js');
85
- const child = spawn(process.execPath, [backupScript, '--path', projectPath], {
93
+ const existingPid = this.getWatcherPid(projectPath);
94
+ if (existingPid) return existingPid;
95
+ const cliScript = guardPath('bin', 'cursor-guard-backup.js');
96
+ const child = spawn(process.execPath, [cliScript, '--path', projectPath], {
86
97
  cwd: projectPath,
87
98
  stdio: 'ignore',
88
99
  detached: true,
100
+ env: { ...process.env, GUARD_SPAWNED_BY_EXT: '1' },
89
101
  });
90
102
  child.unref();
91
103
  return child.pid;
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ let _guardRoot = null;
7
+
8
+ function getGuardRoot() {
9
+ if (_guardRoot) return _guardRoot;
10
+
11
+ const marker = path.join('dashboard', 'server.js');
12
+
13
+ // Strategy 1: skill dir structure — lib/ is inside references/vscode-extension/
14
+ const fromSkill = path.resolve(__dirname, '..', '..');
15
+ if (fs.existsSync(path.join(fromSkill, marker))) {
16
+ _guardRoot = fromSkill;
17
+ return _guardRoot;
18
+ }
19
+
20
+ // Strategy 2: VSIX flat — dashboard/ is sibling to lib/
21
+ const fromFlat = path.resolve(__dirname, '..');
22
+ if (fs.existsSync(path.join(fromFlat, marker))) {
23
+ _guardRoot = fromFlat;
24
+ return _guardRoot;
25
+ }
26
+
27
+ // Strategy 3: search common skill install locations
28
+ const home = process.env.USERPROFILE || process.env.HOME || '';
29
+ const candidates = [
30
+ path.join(home, '.cursor', 'skills', 'cursor-guard', 'references'),
31
+ path.join(home, '.cursor', 'skills', 'cursor-guard'),
32
+ ];
33
+
34
+ // Strategy 4: search workspace node_modules
35
+ if (typeof require.main?.filename === 'string') {
36
+ const wsRoot = path.dirname(require.main.filename);
37
+ candidates.push(path.join(wsRoot, 'node_modules', 'cursor-guard', 'references'));
38
+ }
39
+
40
+ for (const dir of candidates) {
41
+ if (fs.existsSync(path.join(dir, marker))) {
42
+ _guardRoot = dir;
43
+ return _guardRoot;
44
+ }
45
+ }
46
+
47
+ throw new Error(
48
+ 'Cannot locate cursor-guard installation. '
49
+ + 'Ensure cursor-guard is installed as a skill or via npm.'
50
+ );
51
+ }
52
+
53
+ function guardPath(...segments) {
54
+ return path.join(getGuardRoot(), ...segments);
55
+ }
56
+
57
+ function getPackageJson() {
58
+ const root = getGuardRoot();
59
+ // package.json is one level above references/ in skill structure
60
+ const skillPkg = path.resolve(root, '..', 'package.json');
61
+ if (fs.existsSync(skillPkg)) return skillPkg;
62
+ // or at the same level in flat structure
63
+ const flatPkg = path.join(root, 'package.json');
64
+ if (fs.existsSync(flatPkg)) return flatPkg;
65
+ return null;
66
+ }
67
+
68
+ function getPublicDir() {
69
+ return guardPath('dashboard', 'public');
70
+ }
71
+
72
+ module.exports = { getGuardRoot, guardPath, getPackageJson, getPublicDir };
@@ -41,8 +41,14 @@ class Poller {
41
41
  const projects = await this._dashMgr.getProjects();
42
42
  if (!Array.isArray(projects)) return;
43
43
  for (const p of projects) {
44
- const pageData = await this._dashMgr.getPageData(p.id, 'dashboard');
45
- this._data.set(p.id, { ...p, dashboard: pageData?.dashboard || null });
44
+ const fullData = await this._dashMgr.getFullPageData(p.id);
45
+ this._data.set(p.id, {
46
+ ...p,
47
+ dashboard: fullData?.dashboard || null,
48
+ backups: fullData?.backups || [],
49
+ scope: fullData?.scope || null,
50
+ doctor: fullData?.doctor || null,
51
+ });
46
52
  }
47
53
  this._emit();
48
54
  } catch { /* non-critical */ }
@@ -0,0 +1,383 @@
1
+ 'use strict';
2
+
3
+ const vscode = require('vscode');
4
+
5
+ class SidebarDashboardProvider {
6
+ constructor(poller) {
7
+ this._poller = poller;
8
+ this._view = null;
9
+ this._sub = poller.onChange(data => this._push(data));
10
+ }
11
+
12
+ resolveWebviewView(webviewView) {
13
+ this._view = webviewView;
14
+ webviewView.webview.options = { enableScripts: true };
15
+ webviewView.webview.html = _getHtml();
16
+
17
+ webviewView.webview.onDidReceiveMessage(msg => {
18
+ if (msg.cmd === 'ready') this._push(this._poller.data);
19
+ if (msg.cmd === 'exec') vscode.commands.executeCommand(msg.command);
20
+ });
21
+
22
+ webviewView.onDidChangeVisibility(() => {
23
+ if (webviewView.visible) this._push(this._poller.data);
24
+ });
25
+ }
26
+
27
+ _push(data) {
28
+ if (!this._view?.visible) return;
29
+ const payload = {};
30
+ for (const [id, p] of data) {
31
+ payload[id] = {
32
+ name: p.name || id,
33
+ dashboard: p.dashboard,
34
+ backups: (p.backups || []).slice(0, 6),
35
+ };
36
+ }
37
+ this._view.webview.postMessage({ type: 'update', data: payload });
38
+ }
39
+
40
+ dispose() {
41
+ this._sub?.dispose();
42
+ }
43
+ }
44
+
45
+ function _getHtml() {
46
+ return `<!DOCTYPE html>
47
+ <html lang="en">
48
+ <head>
49
+ <meta charset="UTF-8">
50
+ <style>
51
+ :root {
52
+ --bg: #1e1e2e;
53
+ --surface: #282838;
54
+ --border: #383850;
55
+ --text: #cdd6f4;
56
+ --dim: #6c7086;
57
+ --green: #a6e3a1;
58
+ --red: #f38ba8;
59
+ --yellow: #f9e2af;
60
+ --blue: #89b4fa;
61
+ --purple: #cba6f7;
62
+ --orange: #fab387;
63
+ --teal: #94e2d5;
64
+ --radius: 6px;
65
+ }
66
+ * { margin: 0; padding: 0; box-sizing: border-box; }
67
+ body {
68
+ font: 11px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
69
+ color: var(--text);
70
+ background: transparent;
71
+ padding: 8px;
72
+ }
73
+ .card {
74
+ background: var(--surface);
75
+ border: 1px solid var(--border);
76
+ border-radius: var(--radius);
77
+ padding: 8px 10px;
78
+ margin-bottom: 6px;
79
+ }
80
+ .card-title {
81
+ font-size: 9px;
82
+ font-weight: 700;
83
+ text-transform: uppercase;
84
+ letter-spacing: 0.8px;
85
+ color: var(--dim);
86
+ margin-bottom: 6px;
87
+ }
88
+ .status-row {
89
+ display: flex;
90
+ gap: 6px;
91
+ margin-bottom: 6px;
92
+ }
93
+ .status-badge {
94
+ flex: 1;
95
+ text-align: center;
96
+ padding: 6px 4px;
97
+ border-radius: var(--radius);
98
+ background: var(--bg);
99
+ border: 1px solid var(--border);
100
+ }
101
+ .status-badge .icon { font-size: 16px; display: block; }
102
+ .status-badge .label { font-size: 9px; color: var(--dim); margin-top: 2px; }
103
+ .status-badge .value { font-size: 11px; font-weight: 700; }
104
+ .status-badge.ok { border-color: var(--green); }
105
+ .status-badge.ok .value { color: var(--green); }
106
+ .status-badge.warn { border-color: var(--yellow); }
107
+ .status-badge.warn .value { color: var(--yellow); }
108
+ .status-badge.danger { border-color: var(--red); }
109
+ .status-badge.danger .value { color: var(--red); }
110
+ .status-badge.info { border-color: var(--blue); }
111
+ .status-badge.info .value { color: var(--blue); }
112
+
113
+ .alert-bar {
114
+ background: rgba(243,139,168,0.15);
115
+ border: 1px solid var(--red);
116
+ border-radius: var(--radius);
117
+ padding: 6px 10px;
118
+ margin-bottom: 6px;
119
+ text-align: center;
120
+ }
121
+ .alert-bar .alert-title { color: var(--red); font-weight: 700; font-size: 12px; }
122
+ .alert-bar .alert-detail { color: var(--dim); font-size: 10px; margin-top: 2px; }
123
+ .alert-bar.hidden { display: none; }
124
+
125
+ .bar-group { margin-bottom: 4px; }
126
+ .bar-label {
127
+ display: flex;
128
+ justify-content: space-between;
129
+ font-size: 10px;
130
+ margin-bottom: 2px;
131
+ }
132
+ .bar-label .name { color: var(--text); }
133
+ .bar-label .val { color: var(--dim); font-weight: 600; }
134
+ .bar-track {
135
+ height: 6px;
136
+ background: var(--bg);
137
+ border-radius: 3px;
138
+ overflow: hidden;
139
+ }
140
+ .bar-fill {
141
+ height: 100%;
142
+ border-radius: 3px;
143
+ transition: width 0.4s ease;
144
+ }
145
+ .bar-fill.blue { background: var(--blue); }
146
+ .bar-fill.purple { background: var(--purple); }
147
+ .bar-fill.green { background: var(--green); }
148
+ .bar-fill.orange { background: var(--orange); }
149
+ .bar-fill.teal { background: var(--teal); }
150
+
151
+ .backup-list { list-style: none; }
152
+ .backup-item {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 6px;
156
+ padding: 3px 0;
157
+ border-bottom: 1px solid var(--border);
158
+ font-size: 10px;
159
+ }
160
+ .backup-item:last-child { border: none; }
161
+ .backup-dot {
162
+ width: 6px; height: 6px;
163
+ border-radius: 50%;
164
+ flex-shrink: 0;
165
+ }
166
+ .backup-dot.auto { background: var(--blue); }
167
+ .backup-dot.snapshot { background: var(--purple); }
168
+ .backup-dot.restore { background: var(--orange); }
169
+ .backup-time { color: var(--dim); white-space: nowrap; }
170
+ .backup-type { font-weight: 600; min-width: 36px; }
171
+ .backup-summary { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
172
+
173
+ .scope-tags { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; }
174
+ .scope-tag {
175
+ font-size: 9px;
176
+ padding: 1px 6px;
177
+ border-radius: 10px;
178
+ background: var(--bg);
179
+ }
180
+ .scope-tag.protect { color: var(--green); border: 1px solid var(--green); }
181
+ .scope-tag.ignore { color: var(--red); border: 1px solid var(--red); }
182
+
183
+ .health-row { display: flex; align-items: center; gap: 4px; font-size: 10px; padding: 2px 0; }
184
+ .health-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
185
+
186
+ .actions-row { display: flex; gap: 4px; flex-wrap: wrap; }
187
+ .action-btn {
188
+ flex: 1;
189
+ min-width: 70px;
190
+ padding: 5px 4px;
191
+ font-size: 9px;
192
+ font-weight: 600;
193
+ text-align: center;
194
+ border: 1px solid var(--border);
195
+ border-radius: var(--radius);
196
+ background: var(--bg);
197
+ color: var(--text);
198
+ cursor: pointer;
199
+ transition: all 0.15s;
200
+ }
201
+ .action-btn:hover { border-color: var(--blue); color: var(--blue); }
202
+
203
+ .empty-state {
204
+ text-align: center;
205
+ padding: 20px;
206
+ color: var(--dim);
207
+ font-size: 11px;
208
+ }
209
+
210
+ .ring-chart {
211
+ position: relative;
212
+ width: 56px; height: 56px;
213
+ margin: 0 auto 4px;
214
+ }
215
+ .ring-chart svg { transform: rotate(-90deg); }
216
+ .ring-chart .ring-bg { stroke: var(--bg); }
217
+ .ring-chart .ring-fill { transition: stroke-dashoffset 0.6s ease; }
218
+ .ring-label {
219
+ position: absolute;
220
+ top: 50%; left: 50%;
221
+ transform: translate(-50%, -50%);
222
+ font-size: 12px;
223
+ font-weight: 700;
224
+ }
225
+ </style>
226
+ </head>
227
+ <body>
228
+ <div id="root">
229
+ <div class="empty-state">Waiting for data...</div>
230
+ </div>
231
+ <script>
232
+ const vscode = acquireVsCodeApi();
233
+ window.addEventListener('message', e => {
234
+ if (e.data.type === 'update') render(e.data.data);
235
+ });
236
+ vscode.postMessage({ cmd: 'ready' });
237
+
238
+ function render(projects) {
239
+ const ids = Object.keys(projects);
240
+ if (ids.length === 0) {
241
+ document.getElementById('root').innerHTML = '<div class="empty-state">No projects detected</div>';
242
+ return;
243
+ }
244
+ let html = '';
245
+ for (const id of ids) {
246
+ const p = projects[id];
247
+ const d = p.dashboard;
248
+ if (!d) { html += '<div class="empty-state">Loading ' + esc(p.name) + '...</div>'; continue; }
249
+ html += renderProject(p.name, d, p.backups || []);
250
+ }
251
+ html += renderActions();
252
+ document.getElementById('root').innerHTML = html;
253
+ document.querySelectorAll('.action-btn').forEach(btn => {
254
+ btn.addEventListener('click', () => vscode.postMessage({ cmd: 'exec', command: btn.dataset.cmd }));
255
+ });
256
+ }
257
+
258
+ function renderProject(name, d, backups) {
259
+ let h = '';
260
+
261
+ // Status badges row
262
+ const wOk = d.watcher?.running;
263
+ const hasAlert = d.alerts?.active;
264
+ const health = d.health?.status || 'unknown';
265
+
266
+ h += '<div class="status-row">';
267
+ h += badge(wOk ? '👁' : '🚫', 'Watcher', wOk ? 'Running' : 'Stopped', wOk ? 'ok' : 'danger');
268
+ h += badge(hasAlert ? '🔔' : '✅', 'Alerts', hasAlert ? (d.alerts.latest?.fileCount || '!') : 'None', hasAlert ? 'danger' : 'ok');
269
+ h += badge('💚', 'Health', health, health === 'healthy' ? 'ok' : health === 'critical' ? 'danger' : 'warn');
270
+ h += badge('📁', 'Files', d.protectionScope?.fileCount || 0, 'info');
271
+ h += '</div>';
272
+
273
+ // Alert bar
274
+ if (hasAlert) {
275
+ const a = d.alerts.latest;
276
+ const remain = a.expiresAt ? Math.max(0, Math.ceil((new Date(a.expiresAt).getTime() - Date.now()) / 1000)) : 0;
277
+ const display = remain > 60 ? Math.floor(remain/60) + 'm ' + (remain%60) + 's' : remain + 's';
278
+ h += '<div class="alert-bar">';
279
+ h += '<div class="alert-title">⚠ ' + (a.fileCount||'?') + ' files changed in ' + (a.windowSeconds||'?') + 's</div>';
280
+ h += '<div class="alert-detail">Threshold: ' + (a.threshold||'?') + ' · Expires: ' + display + '</div>';
281
+ h += '</div>';
282
+ }
283
+
284
+ // Backup stats bars
285
+ const gitC = d.counts?.git?.commits || 0;
286
+ const shadowC = d.counts?.shadow?.snapshots || 0;
287
+ const maxC = Math.max(gitC, shadowC, 1);
288
+ const gitDisk = d.diskUsage?.git?.display || '0B';
289
+ const shadowDisk = d.diskUsage?.shadow?.display || '0B';
290
+ const gitBytes = d.diskUsage?.git?.bytes || 0;
291
+ const shadowBytes = d.diskUsage?.shadow?.bytes || 0;
292
+ const maxBytes = Math.max(gitBytes, shadowBytes, 1);
293
+
294
+ h += '<div class="card">';
295
+ h += '<div class="card-title">Backup Statistics</div>';
296
+ h += bar('Git backups', gitC, gitC / maxC * 100, 'blue');
297
+ h += bar('Shadow snapshots', shadowC, shadowC / maxC * 100, 'purple');
298
+ h += bar('Git disk', gitDisk, gitBytes / maxBytes * 100, 'teal');
299
+ h += bar('Shadow disk', shadowDisk, shadowBytes / maxBytes * 100, 'orange');
300
+ if (d.disk) {
301
+ h += '<div class="bar-label" style="margin-top:4px"><span class="name">System free</span><span class="val">' + d.disk.freeGB + ' GB</span></div>';
302
+ }
303
+ h += '</div>';
304
+
305
+ // Recent backups timeline
306
+ h += '<div class="card">';
307
+ h += '<div class="card-title">Recent Backups</div>';
308
+ if (backups.length === 0) {
309
+ h += '<div style="color:var(--dim);font-size:10px">No backups yet</div>';
310
+ } else {
311
+ h += '<ul class="backup-list">';
312
+ for (const b of backups) {
313
+ const time = b.timestamp ? new Date(b.timestamp).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '?';
314
+ const type = b.type || 'auto';
315
+ const dotClass = type === 'git-snapshot' ? 'snapshot' : type === 'pre-restore' ? 'restore' : 'auto';
316
+ const typeLabel = type === 'git-snapshot' ? 'snap' : type === 'pre-restore' ? 'pre-rst' : 'auto';
317
+ const summary = b.summary ? truncate(b.summary, 30) : '';
318
+ const files = b.filesChanged ? b.filesChanged + ' files' : '';
319
+ h += '<li class="backup-item">';
320
+ h += '<span class="backup-dot ' + dotClass + '"></span>';
321
+ h += '<span class="backup-time">' + time + '</span>';
322
+ h += '<span class="backup-type">' + typeLabel + '</span>';
323
+ h += '<span class="backup-summary">' + esc(files + (files && summary ? ' · ' : '') + summary) + '</span>';
324
+ h += '</li>';
325
+ }
326
+ h += '</ul>';
327
+ }
328
+ h += '</div>';
329
+
330
+ // Health issues
331
+ if (d.health?.issues?.length > 0) {
332
+ h += '<div class="card">';
333
+ h += '<div class="card-title">Health Issues</div>';
334
+ for (const issue of d.health.issues) {
335
+ const critical = issue.includes('critically') || issue.includes('requires Git');
336
+ h += '<div class="health-row"><span class="health-dot" style="background:' + (critical ? 'var(--red)' : 'var(--yellow)') + '"></span>' + esc(issue) + '</div>';
337
+ }
338
+ h += '</div>';
339
+ }
340
+
341
+ // Protection scope
342
+ h += '<div class="card">';
343
+ h += '<div class="card-title">Protection Scope</div>';
344
+ const protect = d.protectionScope?.protect || ['**'];
345
+ const ignore = d.protectionScope?.ignore || [];
346
+ h += '<div style="font-size:10px;margin-bottom:4px">' + (d.protectionScope?.fileCount || 0) + ' files monitored</div>';
347
+ h += '<div class="scope-tags">';
348
+ for (const p of protect) h += '<span class="scope-tag protect">✓ ' + esc(p) + '</span>';
349
+ for (const i of ignore.slice(0, 6)) h += '<span class="scope-tag ignore">✗ ' + esc(i) + '</span>';
350
+ if (ignore.length > 6) h += '<span class="scope-tag ignore">+' + (ignore.length - 6) + ' more</span>';
351
+ h += '</div></div>';
352
+
353
+ return h;
354
+ }
355
+
356
+ function renderActions() {
357
+ return '<div class="card"><div class="card-title">Quick Actions</div><div class="actions-row">'
358
+ + '<button class="action-btn" data-cmd="cursorGuard.openDashboard">🖥 Dashboard</button>'
359
+ + '<button class="action-btn" data-cmd="cursorGuard.snapshotNow">📸 Snapshot</button>'
360
+ + '<button class="action-btn" data-cmd="cursorGuard.startWatcher">▶ Start</button>'
361
+ + '<button class="action-btn" data-cmd="cursorGuard.stopWatcher">⏹ Stop</button>'
362
+ + '</div></div>';
363
+ }
364
+
365
+ function badge(icon, label, value, cls) {
366
+ return '<div class="status-badge ' + cls + '">'
367
+ + '<span class="icon">' + icon + '</span>'
368
+ + '<span class="value">' + esc(String(value)) + '</span>'
369
+ + '<span class="label">' + label + '</span>'
370
+ + '</div>';
371
+ }
372
+ function bar(name, val, pct, color) {
373
+ return '<div class="bar-group"><div class="bar-label"><span class="name">' + name + '</span><span class="val">' + val + '</span></div>'
374
+ + '<div class="bar-track"><div class="bar-fill ' + color + '" style="width:' + Math.max(pct, 2) + '%"></div></div></div>';
375
+ }
376
+ function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
377
+ function truncate(s, n) { return s.length > n ? s.slice(0, n) + '...' : s; }
378
+ </script>
379
+ </body>
380
+ </html>`;
381
+ }
382
+
383
+ module.exports = { SidebarDashboardProvider };
@@ -2,6 +2,15 @@
2
2
 
3
3
  const vscode = require('vscode');
4
4
 
5
+ const C = {
6
+ green: new vscode.ThemeColor('charts.green'),
7
+ red: new vscode.ThemeColor('charts.red'),
8
+ yellow: new vscode.ThemeColor('charts.yellow'),
9
+ blue: new vscode.ThemeColor('charts.blue'),
10
+ purple: new vscode.ThemeColor('charts.purple'),
11
+ orange: new vscode.ThemeColor('charts.orange'),
12
+ };
13
+
5
14
  class GuardTreeView {
6
15
  constructor(poller, dashMgr) {
7
16
  this._poller = poller;
@@ -11,179 +20,82 @@ class GuardTreeView {
11
20
 
12
21
  this._treeView = vscode.window.createTreeView('cursorGuardProjects', {
13
22
  treeDataProvider: this,
14
- showCollapseAll: true,
23
+ showCollapseAll: false,
15
24
  });
16
25
 
17
26
  this._sub = poller.onChange(() => this._onDidChange.fire());
18
27
  }
19
28
 
20
29
  refresh() { this._onDidChange.fire(); }
30
+ getTreeItem(el) { return el; }
21
31
 
22
- getTreeItem(element) { return element; }
23
-
24
- getChildren(element) {
25
- if (!element) return this._getRootItems();
26
- if (element.contextValue === 'project') return this._getProjectChildren(element.projectId);
27
- if (element.contextValue === 'actions') return this._getActionItems();
32
+ getChildren(el) {
33
+ if (!el) return this._getRootItems();
34
+ if (el.contextValue === 'project') return this._getProjectStatus(el.projectId);
28
35
  return [];
29
36
  }
30
37
 
31
38
  _getRootItems() {
32
39
  const data = this._poller.data;
33
- const items = [];
34
-
35
40
  if (data.size === 0) {
36
- const hint = new TreeItem('No projects detected', 'info', {
37
- icon: new vscode.ThemeIcon('info', new vscode.ThemeColor('charts.yellow')),
38
- description: 'Add .cursor-guard.json',
41
+ return [_item('No projects detected', 'info', { icon: 'info', color: C.yellow, desc: 'Add .cursor-guard.json' })];
42
+ }
43
+ const items = [];
44
+ for (const [id, p] of data) {
45
+ const d = p.dashboard;
46
+ const hasAlert = d?.alerts?.active;
47
+ const watcherOk = d?.watcher?.running;
48
+ const color = hasAlert ? C.red : watcherOk ? C.green : C.yellow;
49
+ const status = hasAlert ? `ALERT ${d.alerts.latest?.fileCount || ''} files` : watcherOk ? 'Protected' : 'Unprotected';
50
+ const item = _item(p.name || id, 'project', {
51
+ icon: hasAlert ? 'bell' : watcherOk ? 'shield' : 'eye-closed',
52
+ color,
53
+ desc: status,
54
+ collapsible: vscode.TreeItemCollapsibleState.Expanded,
39
55
  });
40
- items.push(hint);
41
- } else {
42
- for (const [id, p] of data) {
43
- const d = p.dashboard;
44
- const hasAlert = d?.alerts?.active;
45
- const watcherOk = d?.watcher?.running;
46
- const iconColor = hasAlert
47
- ? new vscode.ThemeColor('charts.red')
48
- : watcherOk
49
- ? new vscode.ThemeColor('charts.green')
50
- : new vscode.ThemeColor('charts.yellow');
51
- const icon = hasAlert
52
- ? new vscode.ThemeIcon('shield', iconColor)
53
- : watcherOk
54
- ? new vscode.ThemeIcon('shield', iconColor)
55
- : new vscode.ThemeIcon('shield', iconColor);
56
-
57
- const item = new TreeItem(p.name || id, 'project', {
58
- icon,
59
- description: hasAlert ? 'ALERT' : watcherOk ? 'Protected' : 'Unprotected',
60
- collapsible: vscode.TreeItemCollapsibleState.Expanded,
61
- });
62
- item.projectId = id;
63
- items.push(item);
64
- }
56
+ item.projectId = id;
57
+ items.push(item);
65
58
  }
66
-
67
- const actionsItem = new TreeItem('Quick Actions', 'actions', {
68
- icon: new vscode.ThemeIcon('zap', new vscode.ThemeColor('charts.blue')),
69
- collapsible: vscode.TreeItemCollapsibleState.Collapsed,
70
- });
71
- items.push(actionsItem);
72
-
73
59
  return items;
74
60
  }
75
61
 
76
- _getActionItems() {
77
- const openDash = new TreeItem('Open Dashboard', 'action', {
78
- icon: new vscode.ThemeIcon('dashboard', new vscode.ThemeColor('charts.blue')),
79
- });
80
- openDash.command = { command: 'cursorGuard.openDashboard', title: 'Open Dashboard' };
81
-
82
- const snapshot = new TreeItem('Snapshot Now', 'action', {
83
- icon: new vscode.ThemeIcon('device-camera', new vscode.ThemeColor('charts.purple')),
84
- });
85
- snapshot.command = { command: 'cursorGuard.snapshotNow', title: 'Snapshot Now' };
86
-
87
- const startW = new TreeItem('Start Watcher', 'action', {
88
- icon: new vscode.ThemeIcon('play', new vscode.ThemeColor('charts.green')),
89
- });
90
- startW.command = { command: 'cursorGuard.startWatcher', title: 'Start Watcher' };
91
-
92
- const stopW = new TreeItem('Stop Watcher', 'action', {
93
- icon: new vscode.ThemeIcon('debug-stop', new vscode.ThemeColor('charts.red')),
94
- });
95
- stopW.command = { command: 'cursorGuard.stopWatcher', title: 'Stop Watcher' };
96
-
97
- const refresh = new TreeItem('Refresh', 'action', {
98
- icon: new vscode.ThemeIcon('refresh', new vscode.ThemeColor('charts.orange')),
99
- });
100
- refresh.command = { command: 'cursorGuard.refreshTree', title: 'Refresh' };
101
-
102
- return [openDash, snapshot, startW, stopW, refresh];
103
- }
104
-
105
- _getProjectChildren(projectId) {
106
- const p = this._poller.data.get(projectId);
107
- if (!p?.dashboard) {
108
- return [new TreeItem('Loading...', 'loading', {
109
- icon: new vscode.ThemeIcon('loading~spin', new vscode.ThemeColor('charts.blue')),
110
- })];
111
- }
62
+ _getProjectStatus(pid) {
63
+ const p = this._poller.data.get(pid);
64
+ if (!p?.dashboard) return [_item('Loading...', 'loading', { icon: 'loading~spin', color: C.blue })];
112
65
  const d = p.dashboard;
113
66
  const items = [];
114
67
 
115
68
  if (d.watcher?.running) {
116
- const w = new TreeItem('Watcher: Running', 'watcher', {
117
- icon: new vscode.ThemeIcon('eye', new vscode.ThemeColor('charts.green')),
118
- description: d.watcher.pid ? `PID ${d.watcher.pid}` : '',
119
- });
69
+ const w = _item('Watcher: Running', 'watcher', { icon: 'eye', color: C.green, desc: `PID ${d.watcher.pid || '?'}` });
120
70
  items.push(w);
121
71
  } else {
122
- const w = new TreeItem('Watcher: Stopped', 'watcher', {
123
- icon: new vscode.ThemeIcon('eye-closed', new vscode.ThemeColor('charts.red')),
124
- description: 'Click Quick Actions > Start',
125
- });
72
+ const w = _item('Watcher: Stopped', 'watcher', { icon: 'eye-closed', color: C.red });
73
+ w.command = { command: 'cursorGuard.startWatcher', title: 'Start Watcher' };
74
+ w.tooltip = 'Click to start watcher';
126
75
  items.push(w);
127
76
  }
128
77
 
129
- if (d.alerts?.active) {
130
- const a = d.alerts.latest || {};
131
- const alertItem = new TreeItem(
132
- `ALERT: ${a.fileCount || '?'} files in ${a.windowSeconds || '?'}s`,
133
- 'alert',
134
- {
135
- icon: new vscode.ThemeIcon('bell', new vscode.ThemeColor('charts.red')),
136
- description: `threshold: ${a.threshold}`,
137
- }
138
- );
139
- items.push(alertItem);
140
- } else {
141
- items.push(new TreeItem('No alerts', 'noalert', {
142
- icon: new vscode.ThemeIcon('check', new vscode.ThemeColor('charts.green')),
143
- }));
144
- }
78
+ const gitC = d.counts?.git?.commits || 0;
79
+ const shadowC = d.counts?.shadow?.snapshots || 0;
80
+ const lastAgo = d.lastBackup?.git?.relativeTime || 'never';
81
+ items.push(_item(`Backups: ${gitC + shadowC}`, 'stat', { icon: 'history', color: C.blue, desc: `last ${lastAgo}` }));
145
82
 
146
- if (d.lastBackup?.git) {
147
- const ago = this._relativeTime(d.lastBackup.git.timestamp);
148
- items.push(new TreeItem(`Last Backup: ${ago}`, 'backup', {
149
- icon: new vscode.ThemeIcon('git-commit', new vscode.ThemeColor('charts.blue')),
150
- }));
151
- }
83
+ const health = d.health?.status || 'unknown';
84
+ const hColor = health === 'healthy' ? C.green : health === 'critical' ? C.red : C.yellow;
85
+ const hIcon = health === 'healthy' ? 'pass-filled' : health === 'critical' ? 'error' : 'warning';
86
+ items.push(_item(`Health: ${health}`, 'health', { icon: hIcon, color: hColor }));
152
87
 
153
- if (d.counts) {
154
- const gitCount = d.counts.git?.commits || 0;
155
- const shadowCount = d.counts.shadow?.snapshots || 0;
156
- items.push(new TreeItem(`Git: ${gitCount} Shadow: ${shadowCount}`, 'counts', {
157
- icon: new vscode.ThemeIcon('database', new vscode.ThemeColor('charts.purple')),
158
- }));
159
- }
88
+ const openItem = _item('Open Dashboard', 'action', { icon: 'browser', color: C.blue });
89
+ openItem.command = { command: 'cursorGuard.openDashboard', title: 'Open' };
90
+ items.push(openItem);
160
91
 
161
- const health = d.health?.status || 'unknown';
162
- const healthColor = health === 'healthy'
163
- ? new vscode.ThemeColor('charts.green')
164
- : health === 'critical'
165
- ? new vscode.ThemeColor('charts.red')
166
- : new vscode.ThemeColor('charts.yellow');
167
- const healthIcon = health === 'healthy' ? 'pass-filled' : health === 'critical' ? 'error' : 'warning';
168
- const healthItem = new TreeItem(`Health: ${health}`, 'health', {
169
- icon: new vscode.ThemeIcon(healthIcon, healthColor),
170
- description: d.health?.issues?.length > 0 ? d.health.issues[0] : '',
171
- });
172
- items.push(healthItem);
92
+ const snapItem = _item('Snapshot Now', 'action', { icon: 'device-camera', color: C.purple });
93
+ snapItem.command = { command: 'cursorGuard.snapshotNow', title: 'Snap' };
94
+ items.push(snapItem);
173
95
 
174
96
  return items;
175
97
  }
176
98
 
177
- _relativeTime(ts) {
178
- const diff = Date.now() - new Date(ts).getTime();
179
- const sec = Math.floor(diff / 1000);
180
- if (sec < 60) return `${sec}s ago`;
181
- const min = Math.floor(sec / 60);
182
- if (min < 60) return `${min}m ago`;
183
- const hr = Math.floor(min / 60);
184
- return `${hr}h ago`;
185
- }
186
-
187
99
  dispose() {
188
100
  this._sub?.dispose();
189
101
  this._treeView.dispose();
@@ -191,13 +103,12 @@ class GuardTreeView {
191
103
  }
192
104
  }
193
105
 
194
- class TreeItem extends vscode.TreeItem {
195
- constructor(label, contextValue, opts = {}) {
196
- super(label, opts.collapsible || vscode.TreeItemCollapsibleState.None);
197
- this.contextValue = contextValue;
198
- if (opts.icon) this.iconPath = opts.icon;
199
- if (opts.description) this.description = opts.description;
200
- }
106
+ function _item(label, ctx, opts = {}) {
107
+ const ti = new vscode.TreeItem(label, opts.collapsible || vscode.TreeItemCollapsibleState.None);
108
+ ti.contextValue = ctx;
109
+ if (opts.icon) ti.iconPath = new vscode.ThemeIcon(opts.icon, opts.color);
110
+ if (opts.desc) ti.description = opts.desc;
111
+ return ti;
201
112
  }
202
113
 
203
114
  module.exports = { GuardTreeView };
@@ -3,8 +3,9 @@
3
3
  const vscode = require('vscode');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { getPublicDir } = require('./paths');
6
7
 
7
- const PUBLIC_DIR = path.resolve(__dirname, '..', '..', 'dashboard', 'public');
8
+ const PUBLIC_DIR = getPublicDir();
8
9
 
9
10
  class WebViewProvider {
10
11
  constructor(context, dashMgr) {
@@ -66,6 +66,11 @@
66
66
  {
67
67
  "id": "cursorGuardProjects",
68
68
  "name": "Projects"
69
+ },
70
+ {
71
+ "id": "cursorGuardDashboard",
72
+ "name": "Dashboard",
73
+ "type": "webview"
69
74
  }
70
75
  ]
71
76
  },