cursor-guard 4.9.8 → 4.9.9

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.
@@ -57,7 +57,9 @@ class SidebarDashboardProvider {
57
57
  }
58
58
 
59
59
  _push(data) {
60
- if (!this._view?.visible) return;
60
+ if (!this._view) return;
61
+ // Do not gate on webviewView.visible: on first load, `ready` can arrive while
62
+ // visible is still false, and we would never post `update` → stuck on "Waiting for data...".
61
63
 
62
64
  const payload = {};
63
65
  for (const [id, project] of data) {
@@ -107,8 +109,10 @@ function _getHtml(brandInnerHtml) {
107
109
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
108
110
  --shadow-soft: 0 4px 14px rgba(0, 0, 0, 0.12);
109
111
  --accent: var(--blue);
110
- --glow-green: color-mix(in srgb, var(--green) 35%, transparent);
111
- --glow-blue: color-mix(in srgb, var(--blue) 28%, transparent);
112
+ --glow-green: color-mix(in srgb, var(--green) 38%, transparent);
113
+ /* Shell: dark base + green haze (no blue wash) */
114
+ --shell-green-1: color-mix(in srgb, var(--green) 16%, #070907);
115
+ --shell-green-2: color-mix(in srgb, var(--green) 9%, #050805);
112
116
  }
113
117
 
114
118
  * { box-sizing: border-box; }
@@ -123,11 +127,198 @@ body {
123
127
 
124
128
  .cg-shell {
125
129
  position: relative;
126
- padding: 10px 10px 18px;
130
+ padding: 8px 8px 14px;
127
131
  min-height: 100%;
128
132
  background:
129
- radial-gradient(120% 80% at 0% -20%, var(--glow-blue), transparent 55%),
130
- radial-gradient(90% 60% at 100% 0%, var(--glow-green), transparent 45%);
133
+ radial-gradient(110% 75% at 8% -15%, var(--shell-green-1), transparent 56%),
134
+ radial-gradient(95% 70% at 102% 108%, var(--shell-green-2), transparent 52%),
135
+ linear-gradient(168deg, #0a0c0f 0%, var(--surface) 48%, #0c100e 100%);
136
+ }
137
+
138
+ .cg-dashboard-scroll {
139
+ margin-top: 4px;
140
+ }
141
+
142
+ .cg-section-fold {
143
+ margin-bottom: 8px;
144
+ }
145
+
146
+ .cg-section-fold .cg-main-fold {
147
+ border-radius: 10px;
148
+ }
149
+
150
+ .cg-section-fold .cg-main-fold-head {
151
+ padding: 5px 8px;
152
+ }
153
+
154
+ .cg-section-fold .cg-main-fold-title {
155
+ font-size: 10px;
156
+ letter-spacing: 0.08em;
157
+ }
158
+
159
+ .cg-section-fold .cg-main-fold-chevron {
160
+ width: 18px;
161
+ height: 18px;
162
+ font-size: 10px;
163
+ }
164
+
165
+ .cg-section-fold .cg-main-fold-body {
166
+ padding: 0 6px 6px;
167
+ }
168
+
169
+ .cg-section-fold-body .hero {
170
+ margin-bottom: 0;
171
+ margin-top: 2px;
172
+ padding: 12px 10px;
173
+ }
174
+
175
+ .cg-section-fold-body .hero-title {
176
+ font-size: 16px;
177
+ }
178
+
179
+ .cg-section-fold-body > .card {
180
+ margin-bottom: 0;
181
+ margin-top: 2px;
182
+ }
183
+
184
+ .cg-section-fold-body .cg-actions-wrap {
185
+ margin-top: 0;
186
+ padding-top: 8px;
187
+ }
188
+
189
+ .cg-main-fold {
190
+ border-radius: var(--radius-lg);
191
+ border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
192
+ background: color-mix(in srgb, var(--surface-2) 45%, transparent);
193
+ box-shadow: var(--shadow);
194
+ overflow: hidden;
195
+ }
196
+
197
+ .cg-main-fold-head {
198
+ display: flex;
199
+ align-items: center;
200
+ justify-content: space-between;
201
+ gap: 10px;
202
+ width: 100%;
203
+ padding: 10px 12px;
204
+ border: none;
205
+ background: color-mix(in srgb, var(--text) 4%, transparent);
206
+ color: inherit;
207
+ cursor: pointer;
208
+ font: inherit;
209
+ text-align: left;
210
+ transition: background 0.15s ease;
211
+ }
212
+
213
+ .cg-main-fold-head:hover {
214
+ background: color-mix(in srgb, var(--text) 8%, transparent);
215
+ }
216
+
217
+ .cg-main-fold-title {
218
+ font-size: 11px;
219
+ font-weight: 700;
220
+ letter-spacing: 0.1em;
221
+ text-transform: uppercase;
222
+ color: var(--muted);
223
+ flex: 1;
224
+ min-width: 0;
225
+ }
226
+
227
+ .cg-main-fold-chevron {
228
+ display: flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ width: 22px;
232
+ height: 22px;
233
+ border-radius: 6px;
234
+ font-size: 11px;
235
+ line-height: 1;
236
+ color: var(--muted);
237
+ background: color-mix(in srgb, var(--text) 6%, transparent);
238
+ transition: transform 0.2s ease, background 0.15s ease;
239
+ flex-shrink: 0;
240
+ }
241
+
242
+ .cg-main-fold-head:hover .cg-main-fold-chevron {
243
+ background: color-mix(in srgb, var(--text) 10%, transparent);
244
+ color: var(--text);
245
+ }
246
+
247
+ .cg-main-fold--collapsed .cg-main-fold-chevron {
248
+ transform: rotate(-90deg);
249
+ }
250
+
251
+ .cg-main-fold-body {
252
+ border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
253
+ }
254
+
255
+ .cg-main-fold--collapsed .cg-main-fold-body {
256
+ display: none;
257
+ }
258
+
259
+ .cg-main-fold--collapsed .cg-main-fold-head {
260
+ border-bottom: none;
261
+ }
262
+
263
+ .cg-brand-section {
264
+ margin-bottom: 4px;
265
+ }
266
+
267
+ .cg-brand-section--compact .cg-brand-topbar {
268
+ padding: 4px 8px 2px;
269
+ gap: 6px;
270
+ }
271
+
272
+ .cg-brand-section--compact .cg-brand-topbar .lang-btn {
273
+ padding: 3px 8px;
274
+ font-size: 10px;
275
+ }
276
+
277
+ .cg-brand-section--compact .cg-brand-mark {
278
+ width: 28px;
279
+ height: 28px;
280
+ border-radius: 8px;
281
+ }
282
+
283
+ .cg-brand-section--compact .cg-brand-mark--has-img {
284
+ padding: 3px;
285
+ }
286
+
287
+ .cg-brand-section--compact .cg-brand {
288
+ padding: 6px 8px;
289
+ gap: 8px;
290
+ border-radius: 10px;
291
+ }
292
+
293
+ .cg-brand-section--compact .cg-brand-title {
294
+ font-size: 12px;
295
+ }
296
+
297
+ .cg-brand-section--compact .cg-brand-sub--project {
298
+ font-size: 10px;
299
+ }
300
+
301
+ .cg-brand-section--compact .cg-brand-sub--backup {
302
+ font-size: 9px;
303
+ letter-spacing: 0.06em;
304
+ }
305
+
306
+ .cg-brand-section--compact .cg-brand {
307
+ margin-bottom: 0;
308
+ }
309
+
310
+ .cg-brand--details-only .cg-brand-text {
311
+ flex: 1;
312
+ }
313
+
314
+ .cg-brand-topbar {
315
+ display: flex;
316
+ align-items: center;
317
+ justify-content: flex-end;
318
+ gap: 8px;
319
+ width: 100%;
320
+ padding: 6px 12px 4px;
321
+ background: transparent;
131
322
  }
132
323
 
133
324
  .cg-brand {
@@ -348,6 +539,8 @@ body {
348
539
  .card-head { transition: none; }
349
540
  .btn { transition: none; }
350
541
  .lang-btn { transition: none; }
542
+ .cg-main-fold-chevron { transition: none; }
543
+ .cg-section-fold .cg-main-fold-chevron { transition: none; }
351
544
  }
352
545
 
353
546
  .hero.risk {
@@ -553,20 +746,48 @@ body {
553
746
  border: 1px solid transparent;
554
747
  }
555
748
 
556
- .pill.green { background: rgba(154, 215, 162, 0.12); color: var(--green); }
749
+ .pill.green {
750
+ background: color-mix(in srgb, var(--green) 18%, transparent);
751
+ color: var(--green);
752
+ border-color: color-mix(in srgb, var(--green) 42%, transparent);
753
+ }
754
+ .pill.ignore {
755
+ background: color-mix(in srgb, var(--muted) 14%, transparent);
756
+ color: color-mix(in srgb, var(--muted) 92%, var(--text));
757
+ border-color: color-mix(in srgb, var(--muted) 28%, transparent);
758
+ }
557
759
  .pill.red { background: rgba(242, 159, 159, 0.12); color: var(--red); }
558
760
  .pill.orange { background: rgba(244, 179, 110, 0.12); color: var(--orange); }
559
- .pill.dim { background: rgba(154, 164, 189, 0.12); color: var(--muted); }
560
-
561
- .tag-group { margin-top: 8px; }
761
+ .pill.dim { background: rgba(154, 164, 189, 0.1); color: var(--muted); border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
762
+
763
+ .tag-group { margin-top: 12px; }
764
+ .pill-wrap + .tag-group { margin-top: 10px; }
765
+ .tag-group--protect {
766
+ padding: 8px 8px 10px;
767
+ margin-left: -4px;
768
+ margin-right: -4px;
769
+ border-radius: 10px;
770
+ border: 1px solid color-mix(in srgb, var(--green) 32%, var(--border));
771
+ background: color-mix(in srgb, var(--green) 7%, transparent);
772
+ }
773
+ .tag-group--ignore {
774
+ padding: 8px 8px 10px;
775
+ margin-left: -4px;
776
+ margin-right: -4px;
777
+ border-radius: 10px;
778
+ border: 1px solid color-mix(in srgb, var(--muted) 22%, var(--border));
779
+ background: color-mix(in srgb, var(--muted) 6%, transparent);
780
+ }
562
781
  .tag-label {
563
- margin-bottom: 4px;
782
+ margin-bottom: 6px;
564
783
  font-size: 10px;
565
784
  font-weight: 700;
566
- letter-spacing: 0.06em;
785
+ letter-spacing: 0.08em;
567
786
  text-transform: uppercase;
568
787
  color: var(--muted);
569
788
  }
789
+ .tag-group--protect .tag-label { color: var(--green); opacity: 0.95; }
790
+ .tag-group--ignore .tag-label { color: color-mix(in srgb, var(--muted) 88%, var(--text)); }
570
791
 
571
792
  .tag-list {
572
793
  display: flex;
@@ -586,9 +807,16 @@ body {
586
807
  }
587
808
 
588
809
  .tag.green {
589
- color: var(--green);
590
- border-color: rgba(154, 215, 162, 0.3);
591
- background: rgba(154, 215, 162, 0.08);
810
+ color: color-mix(in srgb, var(--green) 92%, #fff);
811
+ border-color: color-mix(in srgb, var(--green) 48%, transparent);
812
+ background: color-mix(in srgb, var(--green) 14%, transparent);
813
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--green) 12%, transparent);
814
+ }
815
+
816
+ .tag.ignore {
817
+ color: color-mix(in srgb, var(--muted) 95%, var(--text));
818
+ border-color: color-mix(in srgb, var(--muted) 38%, var(--border));
819
+ background: color-mix(in srgb, var(--muted) 10%, var(--surface-2));
592
820
  }
593
821
 
594
822
  .tag.red {
@@ -599,6 +827,8 @@ body {
599
827
 
600
828
  .tag.dim {
601
829
  color: var(--muted);
830
+ border-color: color-mix(in srgb, var(--border) 80%, transparent);
831
+ background: color-mix(in srgb, var(--surface-2) 60%, transparent);
602
832
  }
603
833
 
604
834
  .cg-actions-wrap {
@@ -658,23 +888,27 @@ body {
658
888
  </head>
659
889
  <body>
660
890
  <div class="cg-shell">
661
- <header class="cg-brand" aria-label="Cursor Guard">
662
- <div class="${brandMarkClass}" aria-hidden="true">${brandInnerHtml}</div>
663
- <div class="cg-brand-text">
664
- <span class="cg-brand-title" id="cg-brand-title">Cursor Guard</span>
665
- <div class="cg-brand-meta">
666
- <span class="cg-brand-sub cg-brand-sub--project" id="cg-brand-project">-</span>
667
- <div class="cg-brand-sub cg-brand-sub--backup" id="cg-brand-backup">
668
- <span class="cg-brand-backup-prefix" id="cg-brand-backup-prefix" hidden>Last backup </span><span id="cg-brand-backup-age" class="backup-age">-</span>
891
+ <section class="cg-brand-section cg-brand-section--compact" aria-label="Brand bar">
892
+ <div class="cg-brand-topbar">
893
+ <button id="lang-toggle" class="lang-btn" type="button">中文</button>
894
+ </div>
895
+ <header class="cg-brand cg-brand--details-only" aria-label="Cursor Guard">
896
+ <div class="${brandMarkClass}" aria-hidden="true">${brandInnerHtml}</div>
897
+ <div class="cg-brand-text">
898
+ <span class="cg-brand-title" id="cg-brand-title">Cursor Guard</span>
899
+ <div class="cg-brand-meta">
900
+ <span class="cg-brand-sub cg-brand-sub--project" id="cg-brand-project">-</span>
901
+ <div class="cg-brand-sub cg-brand-sub--backup" id="cg-brand-backup">
902
+ <span class="cg-brand-backup-prefix" id="cg-brand-backup-prefix" hidden>Last backup </span><span id="cg-brand-backup-age" class="backup-age">-</span>
903
+ </div>
669
904
  </div>
670
905
  </div>
906
+ </header>
907
+ </section>
908
+ <div class="cg-dashboard-scroll" id="cg-dashboard-scroll">
909
+ <div id="root">
910
+ <div class="empty">Waiting for data...</div>
671
911
  </div>
672
- <div class="cg-brand-tools">
673
- <button id="lang-toggle" class="lang-btn" type="button">中文</button>
674
- </div>
675
- </header>
676
- <div id="root">
677
- <div class="empty">Waiting for data...</div>
678
912
  </div>
679
913
  </div>
680
914
  <script>
@@ -694,6 +928,8 @@ const I18N = {
694
928
  'en-US': {
695
929
  'chrome.title': 'Cursor Guard',
696
930
  'chrome.switch': '\u4e2d\u6587',
931
+ 'section.status': 'Status',
932
+ 'section.actions': 'Actions',
697
933
  'state.waiting': 'Waiting for data...',
698
934
  'state.loading': 'Loading...',
699
935
  'state.empty': 'No projects detected.<br>Add .cursor-guard.json to get started.',
@@ -766,74 +1002,76 @@ const I18N = {
766
1002
  'zh-CN': {
767
1003
  'chrome.title': 'Cursor Guard',
768
1004
  'chrome.switch': 'EN',
769
- 'state.waiting': '绛夊緟鏁版嵁涓?..',
770
- 'state.loading': '鍔犺浇涓?..',
771
- 'state.empty': '鏈娴嬪埌椤圭洰銆?br>娣诲姞 .cursor-guard.json 鍚庡嵆鍙惎鐢ㄣ€?,
772
- 'hero.pre.kicker': '浜嬪厛棰勮',
773
- 'hero.pre.title': '鍒犻櫎椋庨櫓',
774
- 'hero.pre.subtitle': '璇峰厛妫€鏌ヨ繖娆$牬鍧忔€х紪杈?,
775
- 'hero.alert.kicker': '鍙樻洿鍛婅',
776
- 'hero.alert.subtitle': '妫€娴嬪埌寮傚父楂橀鏂囦欢鍙樻洿',
777
- 'hero.protection.kicker': '淇濇姢鐘舵€?,
778
- 'hero.protection.stopped': 'Watcher 鏈繍琛?,
779
- 'hero.protection.stoppedSub': '鍚姩 watcher 浠ュ紑鍚寔缁繚鎶?,
780
- 'hero.health.kicker': '鍋ュ悍鐘舵€?,
781
- 'hero.health.critical': '涓ラ噸闂',
782
- 'hero.health.check': '璇锋鏌ヨ瘖鏂粨鏋?,
783
- 'hero.protection.safe': '淇濇姢涓?,
784
- 'hero.protection.safeSub': 'Watcher 姝e湪杩愯锛屽浠界姸鎬佸仴搴?,
785
- 'card.deletionRisk': '鍒犻櫎椋庨櫓',
786
- 'card.activeAlert': '娲昏穬鍛婅',
787
- 'card.quickStats': '蹇€熸瑙?,
788
- 'card.protectionScope': '淇濇姢鑼冨洿',
789
- 'row.file': '鏂囦欢',
790
- 'row.risk': '椋庨櫓',
791
- 'row.methodsRemoved': '绉婚櫎鐨勬柟娉曟暟',
792
- 'row.summary': '鎽樿',
793
- 'row.window': '绐楀彛',
794
- 'row.files': '鏂囦欢鏁?,
795
- 'row.threshold': '闃堝€?,
796
- 'row.expires': '鍓╀綑鏃堕棿',
797
- 'row.watcher': '\u76d1\u63a7',
798
- 'row.health': '\u5065\u5eb7',
799
- 'row.lastBackup': '涓婃澶囦唤',
800
- 'row.gitBackups': 'Git 澶囦唤鏁?,
801
- 'row.shadowCopies': 'Shadow 澶囦唤鏁?,
802
- 'row.diskFree': '鍓╀綑纾佺洏',
803
- 'status.watcher.running': '\u8fd0\u884c\u4e2d',
804
- 'status.watcher.stale': '\u9501\u6b8b\u7559',
805
- 'status.watcher.stopped': '\u5df2\u505c\u6b62',
806
- 'status.health.healthy': '\u5065\u5eb7',
807
- 'status.health.warning': '\u8b66\u544a',
808
- 'status.health.critical': '\u4e25\u91cd',
809
- 'pill.protected': '{n} 涓彈淇濇姢',
810
- 'pill.excluded': '{n} 涓帓闄?,
811
- 'pill.total': '{n} 涓€昏',
812
- 'tag.protect': '淇濇姢',
813
- 'tag.ignore': '蹇界暐',
814
- 'tag.more': '+{n} 涓洿澶?,
815
- 'actions.openDashboard': '鎵撳紑鐪嬫澘',
816
- 'actions.restore': '鎭㈠',
817
- 'actions.viewDetails': '鏌ョ湅璇︽儏',
818
- 'actions.snapshot': '绔嬪嵆蹇収',
819
- 'actions.watcherOn': '\u505c\u6b62 Watcher',
820
- 'actions.watcherOff': '\u542f\u52a8 Watcher',
821
- 'actions.doctor': '璇婃柇',
822
- 'brand.noWorkspace': '\u65e0\u5de5\u4f5c\u533a',
823
- 'brand.addConfig': '\u6dfb\u52a0 .cursor-guard.json',
824
- 'brand.loadingBackup': '\u5907\u4efd\u4fe1\u606f\u52a0\u8f7d\u4e2d...',
825
- 'brand.noGitBackup': '\u6682\u65e0 Git \u5907\u4efd',
826
- 'brand.backupPrefix': '\u4e0a\u6b21\u5907\u4efd',
827
- 'stats.never': '\u4ece\u672a',
828
- 'misc.unknown': '\u672a\u77e5',
1005
+ 'section.status': '\u72b6\u6001',
1006
+ 'section.actions': '\u64cd\u4f5c',
1007
+ 'state.waiting': '等待数据...',
1008
+ 'state.loading': '加载中...',
1009
+ 'state.empty': '未检测到项目。<br>添加 .cursor-guard.json 即可开始使用。',
1010
+ 'brand.noWorkspace': '无工作区',
1011
+ 'brand.addConfig': '添加 .cursor-guard.json',
1012
+ 'brand.loadingBackup': '备份信息加载中...',
1013
+ 'brand.noGitBackup': '暂无 Git 备份',
1014
+ 'brand.backupPrefix': '上次备份',
1015
+ 'hero.pre.kicker': '事先预警',
1016
+ 'hero.pre.title': '删除风险',
1017
+ 'hero.pre.subtitle': '请先检查此次破坏性编辑',
1018
+ 'hero.alert.kicker': '变更告警',
1019
+ 'hero.alert.subtitle': '检测到异常高频文件变更',
1020
+ 'hero.protection.kicker': '保护状态',
1021
+ 'hero.protection.stopped': 'Watcher 未运行',
1022
+ 'hero.protection.stoppedSub': '启动 watcher 以开启持续保护',
1023
+ 'hero.health.kicker': '健康状态',
1024
+ 'hero.health.critical': '严重问题',
1025
+ 'hero.health.check': '请检查诊断结果',
1026
+ 'hero.protection.safe': '已保护',
1027
+ 'hero.protection.safeSub': 'Watcher 正在运行,备份状态健康',
1028
+ 'card.deletionRisk': '删除风险',
1029
+ 'card.activeAlert': '活跃告警',
1030
+ 'card.quickStats': '快速概览',
1031
+ 'card.protectionScope': '保护范围',
1032
+ 'row.file': '文件',
1033
+ 'row.risk': '风险',
1034
+ 'row.methodsRemoved': '移除的方法数',
1035
+ 'row.summary': '摘要',
1036
+ 'row.window': '窗口',
1037
+ 'row.files': '文件数',
1038
+ 'row.threshold': '阈值',
1039
+ 'row.expires': '剩余时间',
1040
+ 'row.watcher': '监控',
1041
+ 'row.health': '健康',
1042
+ 'row.lastBackup': '上次备份',
1043
+ 'row.gitBackups': 'Git 备份数',
1044
+ 'row.shadowCopies': 'Shadow 备份数',
1045
+ 'row.diskFree': '剩余磁盘',
1046
+ 'status.watcher.running': '运行中',
1047
+ 'status.watcher.stale': '锁残留',
1048
+ 'status.watcher.stopped': '已停止',
1049
+ 'status.health.healthy': '健康',
1050
+ 'status.health.warning': '警告',
1051
+ 'status.health.critical': '严重',
1052
+ 'pill.protected': '{n} 个受保护',
1053
+ 'pill.excluded': '{n} 个排除',
1054
+ 'pill.total': '{n} 个总计',
1055
+ 'tag.protect': '保护',
1056
+ 'tag.ignore': '忽略',
1057
+ 'tag.more': '+{n} 个更多',
1058
+ 'actions.openDashboard': '打开看板',
1059
+ 'actions.restore': '恢复',
1060
+ 'actions.viewDetails': '查看详情',
1061
+ 'actions.snapshot': '立即快照',
1062
+ 'actions.watcherOn': '停止 Watcher',
1063
+ 'actions.watcherOff': '启动 Watcher',
1064
+ 'actions.doctor': '诊断',
1065
+ 'stats.never': '从未',
1066
+ 'misc.unknown': '未知',
829
1067
  'misc.na': 'N/A',
830
- 'time.secondsAgo': '{n} 绉掑墠',
831
- 'time.minutesAgo': '{m} 鍒?{s} 绉掑墠',
832
- 'time.hoursAgo': '{h} 灏忔椂 {m} 鍒嗗墠',
833
- 'time.daysAgo': '{d} 澶╁墠',
834
- 'time.seconds': '{n} 绉?,
835
- 'time.minutes': '{m} 鍒?{s} 绉?,
836
- 'alert.filesChangedFast': '{count} 涓枃浠跺揩閫熷彉鏇?
1068
+ 'time.secondsAgo': '{n} 秒前',
1069
+ 'time.minutesAgo': '{m} {s} 秒前',
1070
+ 'time.hoursAgo': '{h} 小时 {m} 分前',
1071
+ 'time.daysAgo': '{d} 天前',
1072
+ 'time.seconds': '{n} 秒',
1073
+ 'time.minutes': '{m} {s} 秒',
1074
+ 'alert.filesChangedFast': '{count} 个文件快速变更'
837
1075
  }
838
1076
  };
839
1077
 
@@ -870,6 +1108,50 @@ function updateChrome() {
870
1108
  updateBrandBar(_projects);
871
1109
  }
872
1110
 
1111
+ function escAttr(value) {
1112
+ return String(value)
1113
+ .replace(/&/g, '&amp;')
1114
+ .replace(/"/g, '&quot;');
1115
+ }
1116
+
1117
+ function sectionStorageKey(projectId, suffix) {
1118
+ return String(projectId || 'default').replace(/[^a-zA-Z0-9_-]/g, '_') + ':' + suffix;
1119
+ }
1120
+
1121
+ function wrapSection(projectId, suffix, title, innerHtml, extraClass) {
1122
+ extraClass = extraClass || '';
1123
+ const sk = sectionStorageKey(projectId, suffix);
1124
+ const pid = 'cg-sec-' + sk.replace(/[^a-zA-Z0-9_-]/g, '_');
1125
+ const cls = 'cg-section-fold cg-main-fold cg-main-fold--open' + (extraClass ? ' ' + extraClass : '');
1126
+ return (
1127
+ '<div class="' + cls + '" data-section-key="' + escAttr(sk) + '">' +
1128
+ '<button type="button" class="cg-main-fold-head cg-section-fold-head" aria-expanded="true" aria-controls="' + escAttr(pid) + '">' +
1129
+ '<span class="cg-main-fold-title">' + esc(title) + '</span>' +
1130
+ '<span class="cg-main-fold-chevron" aria-hidden="true">&#9662;</span></button>' +
1131
+ '<div class="cg-main-fold-body cg-section-fold-body" id="' + escAttr(pid) + '">' + innerHtml + '</div></div>'
1132
+ );
1133
+ }
1134
+
1135
+ function bindSectionFolds(container) {
1136
+ const PREFIX = 'cg-section-fold-v1:';
1137
+ container.querySelectorAll('.cg-section-fold[data-section-key]').forEach(section => {
1138
+ const key = section.getAttribute('data-section-key');
1139
+ const btn = section.querySelector('.cg-section-fold-head');
1140
+ if (!key || !btn) return;
1141
+ if (sessionStorage.getItem(PREFIX + key) === '1') {
1142
+ section.classList.add('cg-main-fold--collapsed');
1143
+ section.classList.remove('cg-main-fold--open');
1144
+ btn.setAttribute('aria-expanded', 'false');
1145
+ }
1146
+ btn.addEventListener('click', () => {
1147
+ const collapsed = section.classList.toggle('cg-main-fold--collapsed');
1148
+ section.classList.toggle('cg-main-fold--open', !collapsed);
1149
+ btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
1150
+ sessionStorage.setItem(PREFIX + key, collapsed ? '1' : '0');
1151
+ });
1152
+ });
1153
+ }
1154
+
873
1155
  function formatRelativeAge(ms) {
874
1156
  const sec = Math.floor((Date.now() - ms) / 1000);
875
1157
  if (sec < 60) return t('time.secondsAgo', { n: sec });
@@ -993,11 +1275,12 @@ function render(projects) {
993
1275
  html += '<div class="empty">' + esc(t('state.loading')) + '</div>';
994
1276
  continue;
995
1277
  }
996
- html += renderProject(dashboard);
1278
+ html += renderProject(dashboard, id);
997
1279
  }
998
- html += renderActions(_projects);
1280
+ html += renderActions(_projects, ids[0]);
999
1281
  root.innerHTML = html;
1000
1282
  updateBrandBar(_projects);
1283
+ bindSectionFolds(root);
1001
1284
 
1002
1285
  const alertCard = root.querySelector('.alert-card[data-expires]');
1003
1286
  _alertExpiresAt = alertCard ? parseInt(alertCard.dataset.expires, 10) || 0 : 0;
@@ -1009,7 +1292,7 @@ function render(projects) {
1009
1292
  });
1010
1293
  }
1011
1294
 
1012
- function renderProject(dashboard) {
1295
+ function renderProject(dashboard, projectId) {
1013
1296
  const watcherRunning = dashboard.watcher?.running;
1014
1297
  const latestPreWarning = dashboard.preWarnings?.active ? dashboard.preWarnings.latest : null;
1015
1298
  const preWarning = latestPreWarning?.mode === 'dashboard' ? latestPreWarning : null;
@@ -1018,48 +1301,52 @@ function renderProject(dashboard) {
1018
1301
  const critical = health === 'critical';
1019
1302
  let html = '';
1020
1303
 
1304
+ let heroHtml = '';
1021
1305
  if (preWarning) {
1022
- html += hero('risk', t('hero.pre.kicker'), t('hero.pre.title'), preWarning.summary || t('hero.pre.subtitle'));
1306
+ heroHtml = hero('risk', t('hero.pre.kicker'), t('hero.pre.title'), preWarning.summary || t('hero.pre.subtitle'));
1023
1307
  } else if (alert) {
1024
- html += hero('alert', t('hero.alert.kicker'), t('alert.filesChangedFast', { count: displayCount(alert.fileCount) }), t('hero.alert.subtitle'));
1308
+ heroHtml = hero('alert', t('hero.alert.kicker'), t('alert.filesChangedFast', { count: displayCount(alert.fileCount) }), t('hero.alert.subtitle'));
1025
1309
  } else if (!watcherRunning) {
1026
- html += hero('stopped', t('hero.protection.kicker'), t('hero.protection.stopped'), t('hero.protection.stoppedSub'));
1310
+ heroHtml = hero('stopped', t('hero.protection.kicker'), t('hero.protection.stopped'), t('hero.protection.stoppedSub'));
1027
1311
  } else if (critical) {
1028
- html += hero('critical', t('hero.health.kicker'), t('hero.health.critical'), dashboard.health.issues?.[0] || t('hero.health.check'));
1312
+ heroHtml = hero('critical', t('hero.health.kicker'), t('hero.health.critical'), dashboard.health.issues?.[0] || t('hero.health.check'));
1029
1313
  } else {
1030
- html += hero('protected', t('hero.protection.kicker'), t('hero.protection.safe'), t('hero.protection.safeSub'), { live: true });
1314
+ heroHtml = hero('protected', t('hero.protection.kicker'), t('hero.protection.safe'), t('hero.protection.safeSub'), { live: true });
1031
1315
  }
1316
+ html += wrapSection(projectId, 'status', t('section.status'), heroHtml, '');
1032
1317
 
1033
1318
  if (preWarning) {
1034
- html += '<div class="card risk-card">';
1035
- html += '<div class="card-title">' + esc(t('card.deletionRisk')) + '</div>';
1036
- html += row(t('row.file'), esc(preWarning.file || 'Unknown'), 'orange');
1037
- html += row(t('row.risk'), esc(String(preWarning.riskPercent || '?')) + '%', 'orange');
1319
+ let inner = '<div class="card risk-card">';
1320
+ inner += '<div class="card-title">' + esc(t('card.deletionRisk')) + '</div>';
1321
+ inner += row(t('row.file'), esc(preWarning.file || 'Unknown'), 'orange');
1322
+ inner += row(t('row.risk'), esc(String(preWarning.riskPercent || '?')) + '%', 'orange');
1038
1323
  if (preWarning.removedMethodCount) {
1039
- html += row(t('row.methodsRemoved'), esc(String(preWarning.removedMethodCount)), 'red');
1324
+ inner += row(t('row.methodsRemoved'), esc(String(preWarning.removedMethodCount)), 'red');
1040
1325
  }
1041
- html += row(t('row.summary'), esc(preWarning.summary || t('hero.pre.subtitle')), 'orange');
1042
- html += '<div class="actions">';
1043
- html += '<button class="btn" data-cmd="cursorGuard.openDashboard">' + esc(t('actions.openDashboard')) + '</button>';
1044
- html += '<button class="btn" data-cmd="cursorGuard.quickRestore">' + esc(t('actions.restore')) + '</button>';
1045
- html += '</div>';
1046
- html += '</div>';
1326
+ inner += row(t('row.summary'), esc(preWarning.summary || t('hero.pre.subtitle')), 'orange');
1327
+ inner += '<div class="actions">';
1328
+ inner += '<button class="btn" data-cmd="cursorGuard.openDashboard">' + esc(t('actions.openDashboard')) + '</button>';
1329
+ inner += '<button class="btn" data-cmd="cursorGuard.quickRestore">' + esc(t('actions.restore')) + '</button>';
1330
+ inner += '</div>';
1331
+ inner += '</div>';
1332
+ html += wrapSection(projectId, 'pre-warning', t('card.deletionRisk'), inner, '');
1047
1333
  }
1048
1334
 
1049
1335
  if (alert) {
1050
1336
  const expiresTs = alert.expiresAt ? new Date(alert.expiresAt).getTime() : 0;
1051
1337
  const remain = expiresTs ? Math.max(0, Math.ceil((expiresTs - Date.now()) / 1000)) : 0;
1052
1338
  const display = formatCountdown(remain);
1053
- html += '<div class="card alert-card" data-expires="' + expiresTs + '">';
1054
- html += '<div class="card-title">' + esc(t('card.activeAlert')) + '</div>';
1055
- html += row(t('row.window'), (alert.windowSeconds || '?') + 's', 'red');
1056
- html += row(t('row.files'), String(alert.fileCount || '?'), 'red');
1057
- html += row(t('row.threshold'), String(alert.threshold || '?'), 'yellow');
1058
- html += row(t('row.expires'), '<span class="alert-countdown">' + esc(display) + '</span>', 'yellow', true);
1059
- html += '<div class="actions">';
1060
- html += '<button class="btn" data-cmd="cursorGuard.openDashboard">' + esc(t('actions.viewDetails')) + '</button>';
1061
- html += '</div>';
1062
- html += '</div>';
1339
+ let inner = '<div class="card alert-card" data-expires="' + expiresTs + '">';
1340
+ inner += '<div class="card-title">' + esc(t('card.activeAlert')) + '</div>';
1341
+ inner += row(t('row.window'), (alert.windowSeconds || '?') + 's', 'red');
1342
+ inner += row(t('row.files'), String(alert.fileCount || '?'), 'red');
1343
+ inner += row(t('row.threshold'), String(alert.threshold || '?'), 'yellow');
1344
+ inner += row(t('row.expires'), '<span class="alert-countdown">' + esc(display) + '</span>', 'yellow', true);
1345
+ inner += '<div class="actions">';
1346
+ inner += '<button class="btn" data-cmd="cursorGuard.openDashboard">' + esc(t('actions.viewDetails')) + '</button>';
1347
+ inner += '</div>';
1348
+ inner += '</div>';
1349
+ html += wrapSection(projectId, 'alert', t('card.activeAlert'), inner, '');
1063
1350
  }
1064
1351
 
1065
1352
  const gitCount = dashboard.counts?.git?.commits || 0;
@@ -1072,47 +1359,50 @@ function renderProject(dashboard) {
1072
1359
  const watcherInfo = watcherStateInfo(dashboard);
1073
1360
  const healthInfo = healthStateInfo(dashboard);
1074
1361
 
1075
- html += '<div class="card">';
1076
- html += '<div class="card-title">' + esc(t('card.quickStats')) + '</div>';
1077
- html += row(t('row.watcher'), watcherInfo.label, watcherInfo.tone);
1078
- html += row(t('row.health'), healthInfo.label, healthInfo.tone);
1362
+ let statsInner = '<div class="card">';
1363
+ statsInner += '<div class="card-title">' + esc(t('card.quickStats')) + '</div>';
1364
+ statsInner += row(t('row.watcher'), watcherInfo.label, watcherInfo.tone);
1365
+ statsInner += row(t('row.health'), healthInfo.label, healthInfo.tone);
1079
1366
  if (lastGitTs) {
1080
- html += '<div class="row"><span class="row-name">' + esc(t('row.lastBackup')) + '</span><span class="row-value green backup-age" data-backup-ts="' + new Date(lastGitTs).getTime() + '">' + esc(formatRelativeAge(new Date(lastGitTs).getTime())) + '</span></div>';
1367
+ statsInner += '<div class="row"><span class="row-name">' + esc(t('row.lastBackup')) + '</span><span class="row-value green backup-age" data-backup-ts="' + new Date(lastGitTs).getTime() + '">' + esc(formatRelativeAge(new Date(lastGitTs).getTime())) + '</span></div>';
1081
1368
  } else {
1082
- html += row(t('row.lastBackup'), lastGit, 'green');
1369
+ statsInner += row(t('row.lastBackup'), lastGit, 'green');
1083
1370
  }
1084
- html += row(t('row.gitBackups'), String(gitCount), 'blue');
1085
- if (shadowCount > 0) html += row(t('row.shadowCopies'), String(shadowCount), 'blue');
1086
- html += row(t('row.diskFree'), freeDisplay, diskWarn ? 'yellow' : 'green');
1087
- html += '</div>';
1371
+ statsInner += row(t('row.gitBackups'), String(gitCount), 'blue');
1372
+ if (shadowCount > 0) statsInner += row(t('row.shadowCopies'), String(shadowCount), 'blue');
1373
+ statsInner += row(t('row.diskFree'), freeDisplay, diskWarn ? 'yellow' : 'green');
1374
+ statsInner += '</div>';
1375
+ html += wrapSection(projectId, 'quick-stats', t('card.quickStats'), statsInner, '');
1088
1376
 
1089
1377
  const scope = dashboard.protectionScope || {};
1090
1378
  const protect = scope.protect || [];
1091
1379
  const ignore = scope.ignore || [];
1092
1380
 
1093
- html += '<div class="card">';
1094
- html += '<div class="card-title">' + esc(t('card.protectionScope')) + '</div>';
1095
- html += '<div class="pill-wrap">';
1096
- html += '<span class="pill green">' + esc(t('pill.protected', { n: String(scope.fileCount || 0) })) + '</span>';
1381
+ let scopeInner = '<div class="card">';
1382
+ scopeInner += '<div class="card-title">' + esc(t('card.protectionScope')) + '</div>';
1383
+ scopeInner += '<div class="pill-wrap">';
1384
+ scopeInner += '<span class="pill green">' + esc(t('pill.protected', { n: String(scope.fileCount || 0) })) + '</span>';
1097
1385
  if ((scope.excludedCount || 0) > 0) {
1098
- html += '<span class="pill red">' + esc(t('pill.excluded', { n: String(scope.excludedCount || 0) })) + '</span>';
1386
+ scopeInner += '<span class="pill ignore">' + esc(t('pill.excluded', { n: String(scope.excludedCount || 0) })) + '</span>';
1099
1387
  }
1100
- html += '<span class="pill dim">' + esc(t('pill.total', { n: String(scope.totalFiles || 0) })) + '</span>';
1101
- html += '</div>';
1388
+ scopeInner += '<span class="pill dim">' + esc(t('pill.total', { n: String(scope.totalFiles || 0) })) + '</span>';
1389
+ scopeInner += '</div>';
1102
1390
 
1103
1391
  if (protect.length > 0) {
1104
- html += renderTags(t('tag.protect'), protect, 'green');
1392
+ scopeInner += renderTags(t('tag.protect'), protect, 'green', 'tag-group--protect');
1105
1393
  }
1106
1394
  if (ignore.length > 0) {
1107
- html += renderTags(t('tag.ignore'), ignore, 'red');
1395
+ scopeInner += renderTags(t('tag.ignore'), ignore, 'ignore', 'tag-group--ignore');
1108
1396
  }
1109
- html += '</div>';
1397
+ scopeInner += '</div>';
1398
+ html += wrapSection(projectId, 'scope', t('card.protectionScope'), scopeInner, '');
1110
1399
 
1111
1400
  return html;
1112
1401
  }
1113
1402
 
1114
- function renderTags(label, values, tone) {
1115
- let html = '<div class="tag-group">';
1403
+ function renderTags(label, values, tone, groupClass) {
1404
+ const gc = groupClass ? ' ' + groupClass : '';
1405
+ let html = '<div class="tag-group' + gc + '">';
1116
1406
  html += '<div class="tag-label">' + esc(label) + ' (' + values.length + ')</div>';
1117
1407
  html += '<div class="tag-list">';
1118
1408
  const shown = values.slice(0, 6);
@@ -1140,21 +1430,22 @@ function healthStateInfo(dashboard) {
1140
1430
  return { label: t('status.health.warning'), tone: 'yellow' };
1141
1431
  }
1142
1432
 
1143
- function renderActions(projects) {
1433
+ function renderActions(projects, primaryProjectId) {
1144
1434
  const primary = pickPrimaryProject(projects || {});
1145
1435
  const dashboard = primary?.project?.dashboard || null;
1146
1436
  const watcherRunning = dashboard?.watcher?.running;
1437
+ const pid = primaryProjectId || primary?.id || 'default';
1147
1438
 
1148
- let html = '<div class="cg-actions-wrap"><div class="actions">';
1149
- html += '<button class="btn primary" data-cmd="cursorGuard.snapshotNow">' + esc(t('actions.snapshot')) + '</button>';
1150
- html += '<button class="btn" data-cmd="cursorGuard.quickRestore">' + esc(t('actions.restore')) + '</button>';
1151
- html += watcherRunning
1439
+ let inner = '<div class="cg-actions-wrap"><div class="actions">';
1440
+ inner += '<button class="btn primary" data-cmd="cursorGuard.snapshotNow">' + esc(t('actions.snapshot')) + '</button>';
1441
+ inner += '<button class="btn" data-cmd="cursorGuard.quickRestore">' + esc(t('actions.restore')) + '</button>';
1442
+ inner += watcherRunning
1152
1443
  ? '<button class="btn" data-cmd="cursorGuard.stopWatcher">' + esc(t('actions.watcherOn')) + '</button>'
1153
1444
  : '<button class="btn" data-cmd="cursorGuard.startWatcher">' + esc(t('actions.watcherOff')) + '</button>';
1154
- html += '<button class="btn" data-cmd="cursorGuard.doctor">' + esc(t('actions.doctor')) + '</button>';
1155
- html += '<button class="btn primary full" data-cmd="cursorGuard.openDashboard">' + esc(t('actions.openDashboard')) + '</button>';
1156
- html += '</div></div>';
1157
- return html;
1445
+ inner += '<button class="btn" data-cmd="cursorGuard.doctor">' + esc(t('actions.doctor')) + '</button>';
1446
+ inner += '<button class="btn primary full" data-cmd="cursorGuard.openDashboard">' + esc(t('actions.openDashboard')) + '</button>';
1447
+ inner += '</div></div>';
1448
+ return wrapSection(pid, 'actions', t('section.actions'), inner, '');
1158
1449
  }
1159
1450
 
1160
1451
  function hero(tone, kicker, title, subtitle, opts) {