dsh-disk-cleaner 1.4.0 → 1.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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <!-- badges -->
6
6
  | | |
7
7
  | --- | --- |
8
- | **version** | `1.4.0` |
8
+ | **version** | `1.6.0` |
9
9
  | **DSH** | `0.2.1-rc.5`(开发与实测版本;用到 `plugins.bundle.config`、`settings.section`) |
10
10
  | **license** | [MIT](LICENSE) |
11
11
  | **Node** | `>= 22`(仅运行冒烟测试需要) |
@@ -16,7 +16,7 @@
16
16
 
17
17
  **dsh-disk-cleaner** is a disk-space plugin for [DeepSeek Harness](https://github.com/deepseek-ai) (DSH). It does two different jobs, and keeps them apart on purpose:
18
18
 
19
- 1. **Clean** — twenty-eight known-safe locations (temp folders, package manager stores, browser caches, crash dumps, the Recycle Bin, leftover update installers, per-app caches and logs, superseded plugin versions and applied update packages). Only these are ever deleted.
19
+ 1. **Clean** — thirty-two known-safe locations (temp folders, package manager stores, browser caches, crash dumps, the Recycle Bin, leftover update installers, per-app caches and logs, superseded plugin versions and applied update packages). Only these are ever deleted.
20
20
  2. **Assess** — everything the plugin will *not* delete (installed programs, the WSL virtual disk, the Windows component store, chat-app data, your own folders). Measured and listed, never removed.
21
21
 
22
22
  The assessment ends with a **plan**: the same numbers ordered by what it costs you to act, with the exact Windows command for the steps the plugin cannot perform itself.
@@ -27,15 +27,16 @@ Both the panel and the agent reach the *same* operations: the cleaning logic liv
27
27
 
28
28
  | Feature | Description |
29
29
  | --- | --- |
30
- | Scan | Measures all twenty-eight targets concurrently and shows what each occupies |
30
+ | Scan | Measures all thirty-two targets concurrently and shows what each occupies |
31
31
  | Per-drive filter | Chip row of mounted volumes; scanning or cleaning can be limited to one drive, and rows with nothing on that drive disappear |
32
- | Risk labels | Every target is `safe` or `caution`, with a one-line reason (`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup`) and a permanent legend, so "caution" never means "unspecified" |
32
+ | Risk labels | Every target is `safe` or `caution`, with a one-line reason (`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup` / `orphan`) and a permanent legend, so "caution" never means "unspecified" |
33
33
  | Aged deletion | `temp` and `windows-temp` only remove files older than one day; files a running program still holds are skipped |
34
34
  | Space assessment | Walks the disk read-only and groups the result into Windows / installed software / your own files, plus the ten largest app-data folders. Any row opens its folder in Explorer — click, right click, or Enter |
35
35
  | Honest re-run | Re-running the assessment re-measures; a plain call still answers from the ten-minute cache |
36
36
  | Plan | Nine possible steps, ordered from zero-cost to your-decision-only; the steps the plugin can run have a button, the rest have a copyable command |
37
37
  | Entry placement | Sidebar icon, settings section, or both (default) — switched from the plugin's own config card or the panel's ⚙ button |
38
38
  | Behaviour settings | What is preselected after a scan, and whether deleting asks first |
39
+ | Grouped list | The thirty-two targets are filed under seven headings — system temp, developer tools, browser caches, app caches, leftover updaters, backups, orphaned data. A section whose targets all read 0 B starts folded, so the list opens with only what there is to clean |
39
40
  | Panel memory | Scan results, the selection and the assessment survive switching to another panel and back |
40
41
  | Agent tool | `disk_cleaner` exposes the same five actions to the model, in readable prose |
41
42
  | Bilingual | Follows the host locale service; zh and en dictionaries ship together |
@@ -74,6 +75,10 @@ Every path is built from this table — a caller selects an **id**, never a path
74
75
  | `agent-app-caches` | `Cache` / `Code Cache` / `GPUCache` under `%USERPROFILE%\.workbuddy\app` and `\.workbuddy-ai\app` | purge | caution | the sibling `cache` folder holds spilled conversation text and is never a target |
75
76
  | `rewind-snapshots` | `session-*` folders under `%USERPROFILE%\.dsh\rewind-snapshots` | aged, 30 days | caution | a recent session stays restorable |
76
77
  | `rotated-logs` | `*.old.log` and `*.log.N` files in the `logs` folders of the agent apps | aged, 3 days | safe | nothing reads a rotated file back |
78
+ | `qoder-old-versions` | version folders under `%LOCALAPPDATA%\Programs\Qoder\.qoder-versions` other than the one the launcher names | purge | caution | the launcher's `state.ini` names the build it runs; an unreadable one means nothing is removed |
79
+ | `orphan-app-data` | `%LOCALAPPDATA%\Tencent\QQGuild` - left by an app with no shortcut, no uninstall entry and nothing written for over two years | purge | caution | all four signals must agree before the folder is offered |
80
+ | `app-data-caches` | Chromium caches under `%LOCALAPPDATA%` for Electron, DingTalk, JianyingPro and the WeChat devtools | aged, 7 days | caution | an app that still runs rewrites its cache constantly, so only a week-old tree qualifies |
81
+ | `office-update-cache` | `%LOCALAPPDATA%\Microsoft\Office\SolutionPackages` - update packages Office already applied | purge | caution | Office stages them before applying; nothing reads them back afterwards |
77
82
 
78
83
  `aged` targets skip anything modified within the retention window; `purge` targets remove every entry. Both keep the target directory itself and leave locked entries alone, counting them as skipped.
79
84
 
@@ -153,6 +158,8 @@ These are enforced in `lib/catalog.js` and must not be relaxed:
153
158
  that holds a single bundle all resolve to *nothing to delete*. The newest member is never returned.
154
159
  - `editor-builds` drops a build only when `bin\code.cmd` exists and names a different build that is also on disk; `old_x` goes only while `x` is still beside it.
155
160
  - `database-backups` matches `.sqlite*.bak` by name only; the live `zotero.sqlite` is never a candidate.
161
+ - `qoder-old-versions` reads the launcher's `state.ini` for the build it runs; a missing or unparsable file, or a named build that is not on disk, removes nothing.
162
+ - `orphan-app-data` needs four independent signals to agree: no Start-menu shortcut, no uninstall entry, no folder under either `Program Files`, and no file written for two years. Any one missing and the folder is not offered.
156
163
  - The directory Windows refuses to let an unelevated process write (`SoftwareDistribution\Download`) is labelled `elevation` and excluded from "reclaimable now". `C:\Windows\Temp` is **not** one of them on a default ACL: it grants `BUILTIN\Users` create and delete, so only EBUSY on a live file stops a delete there, which the age limit covers.
157
164
 
158
165
  ### Install
@@ -189,7 +196,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
189
196
  ### Development
190
197
 
191
198
  ```bash
192
- node smoke.mjs # 68 assertions, zero dependencies
199
+ node smoke.mjs # 82 assertions, zero dependencies
193
200
  node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
194
201
  ```
195
202
 
@@ -216,6 +223,7 @@ What the smoke test deliberately does **not** cover: anything that walks a real
216
223
  | "Keep only the newest of a group" fails closed | Throwaway fixture tree: a same-hash duplicate collapses to the newest, a folder with no metadata is left alone, a different hash is kept, the live database survives (9/9) |
217
224
  | Old editor builds need a launcher that proves which one runs | Throwaway fixture tree: a build the launcher does not name goes, no launcher deletes nothing, a launcher naming a missing build deletes nothing, a lone build stays, `old_x` goes only while `x` exists (7/7) |
218
225
  | A dry run reports what a real run frees | The 30-day rewind window reports 263 KB not 283 MB, and scan matches the dry run |
226
+ | Grouping and folding | The real `groupRows` runs out of the bundle against a mixed list: fixed order, per-section totals, an unknown group lands in "other", an empty section is the folded one. A click on a section head folds that section and only that one, and a folded section's rows drop out of bulk select |
219
227
  | Visual layout | Checked by hand in the running Web UI. No browser automation here, so this one is a human check, not a test. |
220
228
 
221
229
  ### Implementation notes
@@ -241,15 +249,15 @@ What the smoke test deliberately does **not** cover: anything that walks a real
241
249
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
242
250
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
243
251
  ├─ lib/index.js (411) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
244
- ├─ lib/catalog.js (969) The 28 targets, measurePath / purgePath / resolveTargets / drives
252
+ ├─ lib/catalog.js (1192) The 32 targets, measurePath / purgePath / resolveTargets / drives
245
253
  ├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
246
254
  ├─ lib/plan.js (150) Pure: measurements → ordered steps
247
- ├─ lib/client.js (1379) Client half: slots, panel, preferences, zh/en dictionaries, CSS
255
+ ├─ lib/client.js (1492) Client half: slots, panel, preferences, zh/en dictionaries, CSS
248
256
  ├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
249
257
  ├─ icon.svg Plugin icon
250
258
  ├─ README.md This file
251
259
  ├─ LICENSE MIT
252
- └─ smoke.mjs 68 assertions, zero dependencies
260
+ └─ smoke.mjs 82 assertions, zero dependencies
253
261
  ```
254
262
 
255
263
  Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode), `main` (keyed by the package name), `settings.section` (order 30, disposed in sidebar-only mode), `plugins.bundle.config` (keyed by the package name).
@@ -261,7 +269,7 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
261
269
 
262
270
  **dsh-disk-cleaner** 是 [DeepSeek Harness](https://github.com/deepseek-ai)(DSH)的磁盘空间插件。它做两件不同的事,并且刻意分开:
263
271
 
264
- 1. **清理** —— 二十八个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除。
272
+ 1. **清理** —— 三十二个已知安全的位置(临时目录、包管理器存储、浏览器缓存、崩溃转储、回收站、更新安装包残留、各应用的缓存与日志、被淘汰的插件版本与已应用的更新包)。只有这些会被删除。
265
273
  2. **评估** —— 所有本插件**不会**删的东西(已装程序、WSL 虚拟磁盘、Windows 组件库、聊天软件数据、你自己的文件夹)。只测量、只列出,绝不删除。
266
274
 
267
275
  评估的最后是一份**处理方案**:同一批数字按「你要付出多少代价」排序,本插件做不到的步骤附上确切的 Windows 命令。
@@ -272,15 +280,16 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
272
280
 
273
281
  | 功能 | 说明 |
274
282
  | --- | --- |
275
- | 扫描 | 并发测量全部二十八个目标,显示各自占用 |
283
+ | 扫描 | 并发测量全部三十二个目标,显示各自占用 |
276
284
  | 按盘过滤 | 已挂载卷的胶囊按钮;扫描与清理都可限定单盘,该盘上为空的项会隐藏 |
277
- | 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup`),图例常驻,所以「注意」永远不是「说不清」 |
285
+ | 风险标注 | 每个目标都标 `安全` 或 `注意`,并给一行理由(`in-use` / `rebuild` / `elevation` / `pending-update` / `rollback` / `superseded` / `history` / `duplicate` / `database-backup` / `orphan`),图例常驻,所以「注意」永远不是「说不清」 |
278
286
  | 保留期删除 | `temp` 与 `windows-temp` 只删一天前的文件;被运行中程序占住的自动跳过 |
279
287
  | 空间评估 | 只读遍历磁盘,按 Windows 自身 / 已装软件 / 你自己的文件 分组,另列占用最大的十个应用数据目录。每一行都能打开所在目录——单击、右键或回车 |
280
288
  | 重新评估真的重测 | 「重新评估」会重新测量;普通调用仍然走十分钟缓存 |
281
289
  | 处理方案 | 九种可能步骤,从零风险到只能你决定;本插件能执行的带按钮,其余给可复制的命令 |
282
290
  | 入口位置 | 侧边栏图标、设置页分区、或两者都要(默认)——在插件自己的配置卡或面板 ⚙ 里切换 |
283
291
  | 行为设置 | 扫描后默认勾选什么、删除前是否确认 |
292
+ | 分类折叠 | 三十二个目标归到七个标题下(系统临时、开发工具、浏览器缓存、应用缓存、升级残留、备份快照、已卸载残留)。整类都是 0 字节的默认收起来,所以打开面板先看到的只有真能清的那些 |
284
293
  | 面板记忆 | 扫描结果、勾选、评估结果在切走再回来时都还在 |
285
294
  | Agent 工具 | `disk_cleaner` 把同样的五个动作以可读文本交给模型 |
286
295
  | 双语 | 跟随宿主语言服务,zh / en 字典一起发布 |
@@ -319,6 +328,10 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
319
328
  | `agent-app-caches` | `%USERPROFILE%\.workbuddy\app` 与 `\.workbuddy-ai\app` 下的 `Cache` / `Code Cache` / `GPUCache` | 全清 | 注意 | 同级的 `cache` 目录存的是对话溢写内容,从不作为目标 |
320
329
  | `rewind-snapshots` | `%USERPROFILE%\.dsh\rewind-snapshots` 下的 `session-*` 目录 | 保留期 30 天 | 注意 | 最近的会话仍然可以回滚 |
321
330
  | `rotated-logs` | 各 agent 应用 `logs` 目录下的 `*.old.log` 与 `*.log.N` 文件 | 保留期 3 天 | 安全 | 轮转掉的日志不会被读回 |
331
+ | `qoder-old-versions` | `%LOCALAPPDATA%\Programs\Qoder\.qoder-versions` 下不是启动器所指的那个版本目录 | 全清 | 注意 | 启动器 `state.ini` 写明它跑哪个版本;读不到就一个都不删 |
332
+ | `orphan-app-data` | `%LOCALAPPDATA%\Tencent\QQGuild` —— 没有快捷方式、没有卸载项、两年多没写过文件的程序留下的数据 | 全清 | 注意 | 四条证据必须同时成立才会被列为可删 |
333
+ | `app-data-caches` | `%LOCALAPPDATA%` 下 Electron、钉钉、剪映、微信开发者工具的 Chromium 缓存 | 保留期 7 天 | 注意 | 还在跑的程序会不停改写缓存,所以只有一周没动过的才算 |
334
+ | `office-update-cache` | `%LOCALAPPDATA%\Microsoft\Office\SolutionPackages` —— Office 已应用的更新暂存包 | 全清 | 注意 | Office 先暂存再应用,应用后没有东西会读回 |
322
335
 
323
336
  保留期方式会跳过保留期内修改过的文件;全清方式清掉全部条目。两者都保留目标目录本身,并把锁定或无权限的条目跳过计数。
324
337
 
@@ -397,6 +410,8 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
397
410
  `vscode-remote-wsl\stable` 下只有一个服务包,结果都是*没有可删项*。最新那一份永远不会被返回。
398
411
  - `editor-builds` 只有在 `bin\code.cmd` 存在且指向另一份确实在磁盘上的构建时才删;`old_x` 也只有在 `x` 仍在旁边时才删。
399
412
  - `database-backups` 只按名字匹配 `.sqlite*.bak`,正在用的 `zotero.sqlite` 永不成为候选。
413
+ - `qoder-old-versions` 从启动器的 `state.ini` 读出它实际运行哪个版本;文件缺失、解析不出、或它指的版本不在磁盘上,一律一个都不删。
414
+ - `orphan-app-data` 要求四条独立证据同时成立:开始菜单没有快捷方式、注册表没有卸载项、两个 `Program Files` 下都没有目录、两年内没有任何文件被写过。缺一条就不会列为可删。
400
415
  - 那个拒绝普通权限写入的 Windows 目录标为 `elevation`,并排除在「现在能回收」之外。
401
416
 
402
417
  ### 安装
@@ -437,7 +452,7 @@ git clone https://github.com/stallenlu/dsh-disk-cleaner
437
452
  ### 开发
438
453
 
439
454
  ```bash
440
- node smoke.mjs # 68 条断言,零依赖
455
+ node smoke.mjs # 82 条断言,零依赖
441
456
  node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
442
457
  ```
443
458
 
@@ -464,6 +479,7 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
464
479
  | 「只留最新一份」一律选保守一侧 | 一次性 fixture 目录:同哈希的重复项收敛到最新、无元数据的目录不动、不同哈希保留、正在用的数据库完好(9/9) |
465
480
  | 旧版编辑器构建要有启动器作证才删 | 一次性 fixture 目录:启动器未指向的构建会删、没有启动器一律不删、启动器指向的构建不在磁盘上也不删、只有一份构建时不删、`old_x` 只在 `x` 仍在时删(7/7) |
466
481
  | 预演报告的字节数等于真删能释放的字节数 | 30 天的回滚窗口报 263 KB 而不是 283 MB,且扫描结果与预演一致 |
482
+ | 分类与折叠 | 从 bundle 里取出真正的 `groupRows` 跑一组混合数据:顺序固定、按类合计、未知类别落到「其他」、空的类别是收起来的那个。点某一类的标题只折叠这一类,折叠里的行不参与批量选择 |
467
483
  | 视觉排版 | 在运行中的 Web UI 里手工确认过。本环境没有浏览器自动化,所以这条是人眼检查,不是测试。 |
468
484
 
469
485
  ### 实现要点
@@ -489,15 +505,15 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
489
505
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
490
506
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
491
507
  ├─ lib/index.js (411) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
492
- ├─ lib/catalog.js (969) 28 个目标,measurePath / purgePath / resolveTargets / drives
508
+ ├─ lib/catalog.js (1192) 32 个目标,measurePath / purgePath / resolveTargets / drives
493
509
  ├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
494
510
  ├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
495
- ├─ lib/client.js (1379) Client 半面:slot、面板、偏好、zh/en 字典、CSS
511
+ ├─ lib/client.js (1492) Client 半面:slot、面板、偏好、zh/en 字典、CSS
496
512
  ├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
497
513
  ├─ icon.svg 插件图标
498
514
  ├─ README.md 本文件
499
515
  ├─ LICENSE MIT
500
- └─ smoke.mjs 68 条断言,零依赖
516
+ └─ smoke.mjs 82 条断言,零依赖
501
517
  ```
502
518
 
503
519
  Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
package/lib/catalog.js CHANGED
@@ -465,6 +465,110 @@ const zoteroBackups = () => {
465
465
  .map((entry) => path.join(data, entry.name));
466
466
  };
467
467
 
468
+ /**
469
+ * Qoder keeps one complete build per version under `.qoder-versions` and never removes the
470
+ * one it replaced. The launcher writes the live build into `state.ini`
471
+ * (`appExecutable=.qoder-versions\0.5.3\Qoder.exe`); `install.ini` next to it still names an
472
+ * older version, so only `state.ini` is read. No readable state file means no evidence about
473
+ * which build runs, and then nothing is returned at all.
474
+ */
475
+ const qoderOldVersions = () => {
476
+ const versions = path.join(env('LOCALAPPDATA') ?? '', 'Programs', 'Qoder', '.qoder-versions');
477
+ if (!isDir(versions)) return [];
478
+ let live = '';
479
+ try {
480
+ const state = fs.readFileSync(
481
+ path.join(env('LOCALAPPDATA') ?? '', 'Qoder', 'Qoder Launcher', 'state.ini'),
482
+ 'utf8',
483
+ );
484
+ const match = /appExecutable\s*=\s*(.+)/.exec(state);
485
+ if (match !== null) {
486
+ const parts = match[1].trim().split(/[\\/]/).filter(Boolean);
487
+ const index = parts.indexOf('.qoder-versions');
488
+ if (index !== -1 && parts[index + 1] !== undefined) live = parts[index + 1];
489
+ }
490
+ } catch {
491
+ /* no state.ini, no proof of which build is live: drop nothing */
492
+ }
493
+ if (live === '') return [];
494
+ const builds = listDir(versions)
495
+ .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
496
+ .map((entry) => entry.name);
497
+ if (builds.length < 2 || !builds.includes(live)) return [];
498
+ return builds.filter((name) => name !== live).map((name) => path.join(versions, name));
499
+ };
500
+
501
+ /**
502
+ * The plugin packages the WeChat devtools downloads per account. They are fetched again the
503
+ * next time a mini program needs one, which is why this stays behind an age limit.
504
+ */
505
+ const wechatDevtoolsCaches = () => {
506
+ const data = path.join(env('LOCALAPPDATA') ?? '', '微信开发者工具', 'User Data');
507
+ const out = [];
508
+ for (const entry of listDir(data)) {
509
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
510
+ out.push(...subdirs(path.join(data, entry.name), ['WeappPlugin', 'WeappForeignPkgs', 'WeappVendor']));
511
+ }
512
+ return out;
513
+ };
514
+
515
+ /**
516
+ * The newest modification time anywhere under a folder, or 0 when it cannot be read. Used to
517
+ * tell "this app was uninstalled" from "this app is installed but its cache happens to be old":
518
+ * only a completely quiet tree is treated as an orphan.
519
+ */
520
+ const newestMtime = (dir) => {
521
+ let newest = 0;
522
+ const stack = [dir];
523
+ while (stack.length > 0) {
524
+ const current = stack.pop();
525
+ let handle;
526
+ try {
527
+ handle = fs.opendirSync(current);
528
+ } catch {
529
+ continue;
530
+ }
531
+ let entry;
532
+ while ((entry = handle.readSync()) !== null) {
533
+ const abs = path.join(current, entry.name);
534
+ if (entry.isSymbolicLink()) continue;
535
+ if (entry.isDirectory()) {
536
+ stack.push(abs);
537
+ continue;
538
+ }
539
+ try {
540
+ const stat = statReal(abs);
541
+ if (stat.mtimeMs > newest) newest = stat.mtimeMs;
542
+ } catch {
543
+ /* unreadable file contributes nothing */
544
+ }
545
+ }
546
+ try {
547
+ handle.closeSync();
548
+ } catch {
549
+ /* already closed */
550
+ }
551
+ }
552
+ return newest;
553
+ };
554
+
555
+ /**
556
+ * The Chromium caches left in `%LOCALAPPDATA%` by apps that are gone or idle. Each root is
557
+ * listed because the app name alone proves nothing: `JianyingPro` and `DingTalk_108` have no
558
+ * install and no shortcut, while `electron` and the devtools are still in use, so the age
559
+ * limit is what keeps a live app's cache out of the result.
560
+ */
561
+ const appDataCaches = () => {
562
+ const local = env('LOCALAPPDATA') ?? '';
563
+ const out = [
564
+ path.join(local, 'electron', 'Cache'),
565
+ ...subdirs(path.join(local, 'DingTalk_108'), ['Cache', 'Code Cache', 'Service Worker']),
566
+ ...subdirs(path.join(local, 'JianyingPro', 'User Data'), ['Cache', 'Resources']),
567
+ ...wechatDevtoolsCaches(),
568
+ ];
569
+ return out.filter((candidate) => isDir(candidate));
570
+ };
571
+
468
572
  /**
469
573
  * `purge` targets drop every entry under the path. `aged` targets keep anything modified
470
574
  * within `minAgeDays`, because those files may belong to a process that is still running.
@@ -475,9 +579,49 @@ const zoteroBackups = () => {
475
579
  * refuses an unelevated delete), 'pending-update' (a download that may not be applied
476
580
  * yet), 'rollback' (an older version kept beside the live one), 'superseded' (an update
477
581
  * package the installed version already contains), 'history' (a snapshot nothing reads
478
- * back), 'duplicate' (a byte-identical copy of a live directory) or 'database-backup' (a
479
- * copy of a database file made before an upgrade). Safe targets have none.
582
+ * back), 'duplicate' (a byte-identical copy of a live directory), 'database-backup' (a copy of
583
+ * a database file made before an upgrade) or 'orphan' (the app that wrote this folder is no
584
+ * longer installed anywhere). Safe targets have none.
585
+ */
586
+ /**
587
+ * Which section of the panel a target is filed under. Kept outside the entries so adding a
588
+ * target stays one line here plus one line in the catalog, not a block to re-read.
480
589
  */
590
+ const GROUP_OF = {
591
+ temp: 'system',
592
+ 'windows-temp': 'system',
593
+ 'windows-update': 'system',
594
+ 'recycle-bin': 'system',
595
+ 'inet-cache': 'system',
596
+ 'crash-dumps': 'system',
597
+ 'npm-cache': 'dev',
598
+ 'pnpm-store': 'dev',
599
+ 'pip-cache': 'dev',
600
+ 'uv-cache': 'dev',
601
+ 'code-cache': 'dev',
602
+ 'editor-builds': 'dev',
603
+ 'editor-server-bundles': 'dev',
604
+ 'toolchain-snapshots': 'dev',
605
+ 'chrome-cache': 'browser',
606
+ 'edge-cache': 'browser',
607
+ 'app-caches': 'app',
608
+ 'app-logs': 'app',
609
+ 'app-temp': 'app',
610
+ 'agent-app-caches': 'app',
611
+ 'app-data-caches': 'app',
612
+ 'agent-logs': 'app',
613
+ 'rotated-logs': 'app',
614
+ 'app-updaters': 'updates',
615
+ 'update-packages': 'updates',
616
+ 'plugin-old-versions': 'updates',
617
+ 'qoder-old-versions': 'updates',
618
+ 'office-update-cache': 'updates',
619
+ 'session-backups': 'history',
620
+ 'rewind-snapshots': 'history',
621
+ 'database-backups': 'history',
622
+ 'orphan-app-data': 'orphan',
623
+ };
624
+
481
625
  const CATALOG = [
482
626
  {
483
627
  id: 'temp',
@@ -512,8 +656,15 @@ const CATALOG = [
512
656
  id: 'pnpm-store',
513
657
  kind: 'purge',
514
658
  risk: 'safe',
659
+ // pnpm 11 kept its content under `store`; 12 moved it to `package-manager-store` and left
660
+ // `store` behind as an empty shell, so both are listed. `pnpm-cache` holds only registry
661
+ // metadata, which pnpm downloads again on demand.
515
662
  roots: ['LOCALAPPDATA'],
516
- paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'pnpm', 'store')],
663
+ paths: () => [
664
+ path.join(env('LOCALAPPDATA') ?? '', 'pnpm', 'store'),
665
+ path.join(env('LOCALAPPDATA') ?? '', 'pnpm', 'package-manager-store'),
666
+ path.join(env('LOCALAPPDATA') ?? '', 'pnpm-cache'),
667
+ ],
517
668
  },
518
669
  {
519
670
  id: 'pip-cache',
@@ -705,6 +856,46 @@ const CATALOG = [
705
856
  roots: ['USERPROFILE'],
706
857
  paths: () => rotatedLogs(),
707
858
  },
859
+ {
860
+ id: 'qoder-old-versions',
861
+ kind: 'purge',
862
+ risk: 'caution',
863
+ note: 'rollback',
864
+ roots: ['LOCALAPPDATA'],
865
+ paths: () => qoderOldVersions(),
866
+ },
867
+ {
868
+ id: 'orphan-app-data',
869
+ kind: 'purge',
870
+ risk: 'caution',
871
+ note: 'orphan',
872
+ roots: ['LOCALAPPDATA'],
873
+ paths: () => {
874
+ const guild = path.join(env('LOCALAPPDATA') ?? '', 'Tencent', 'QQGuild');
875
+ // Measured: no Start-menu shortcut, no uninstall entry, no Program Files folder and no
876
+ // file touched for over two years. The folder also carries its own `Uninstall QQGuild.exe`,
877
+ // which only a real install does. Every one of those signals has to agree before it goes.
878
+ const recent = newestMtime(guild) > Date.now() - 30 * DAY;
879
+ return isDir(guild) && !recent ? [guild] : [];
880
+ },
881
+ },
882
+ {
883
+ id: 'app-data-caches',
884
+ kind: 'aged',
885
+ minAgeDays: 7,
886
+ risk: 'caution',
887
+ note: 'rebuild',
888
+ roots: ['LOCALAPPDATA'],
889
+ paths: () => appDataCaches(),
890
+ },
891
+ {
892
+ id: 'office-update-cache',
893
+ kind: 'purge',
894
+ risk: 'caution',
895
+ note: 'superseded',
896
+ roots: ['LOCALAPPDATA'],
897
+ paths: () => [path.join(env('LOCALAPPDATA') ?? '', 'Microsoft', 'Office', 'SolutionPackages')],
898
+ },
708
899
  ];
709
900
 
710
901
  const byId = new Map(CATALOG.map((entry) => [entry.id, entry]));
@@ -740,12 +931,30 @@ const onDrive = (abs, drive) => drive === undefined || path.parse(abs).root.toLo
740
931
  */
741
932
  const isFile = (p) => {
742
933
  try {
743
- return fs.statSync(p).isFile();
934
+ return statReal(p).isFile();
744
935
  } catch {
745
936
  return false;
746
937
  }
747
938
  };
748
939
 
940
+ /**
941
+ * `fs.statSync` with Electron's asar layer switched off for the call.
942
+ *
943
+ * The host is an Electron app, and its patched fs answers size 0 for any `*.asar` it cannot
944
+ * parse as an archive. A superseded editor build can hold exactly one file — a 58 MB
945
+ * `node_modules.asar` — so without this the target reports "0 B" and reads as nothing found.
946
+ * The switch is synchronous and restored immediately, so no other filesystem call sees it.
947
+ */
948
+ function statReal(p) {
949
+ const prior = process.noAsar;
950
+ process.noAsar = true;
951
+ try {
952
+ return fs.statSync(p);
953
+ } finally {
954
+ process.noAsar = prior;
955
+ }
956
+ }
957
+
749
958
  /** Resolve catalog ids to the concrete directories that exist right now, optionally on one volume. */
750
959
  export function resolveTargets(only, drive) {
751
960
  const selected = only === undefined ? CATALOG : CATALOG.filter((entry) => only.includes(entry.id));
@@ -767,6 +976,7 @@ export function resolveTargets(only, drive) {
767
976
  kind: entry.kind,
768
977
  risk: entry.risk,
769
978
  note: entry.note ?? '',
979
+ group: GROUP_OF[entry.id] ?? 'other',
770
980
  minAgeDays: entry.minAgeDays ?? 0,
771
981
  paths: found,
772
982
  drives,
@@ -775,21 +985,27 @@ export function resolveTargets(only, drive) {
775
985
  }
776
986
 
777
987
  /**
778
- * Total the files under `abs`. With a `cutoff`, only files older than it count, so a dry run
779
- * reports what a real run would actually delete instead of the whole folder.
988
+ * Total the files under `abs`, answering two questions in one pass: how much the whole thing
989
+ * holds (`bytes` / `files` when no cutoff is given) and how much a clean would free right now
990
+ * (`bytes` / `files` restricted to the cutoff). Both are returned so a caller can say "245 MB
991
+ * here, 0.2 MB past the age limit" instead of quietly reporting the smaller number.
780
992
  */
781
993
  export async function measurePath(abs, cutoff) {
782
994
  if (isFile(abs)) {
783
995
  try {
784
- const stat = fs.statSync(abs);
785
- if (cutoff !== undefined && stat.mtimeMs > cutoff) return { bytes: 0, files: 0 };
786
- return { bytes: stat.size, files: 1 };
996
+ const stat = statReal(abs);
997
+ const eligible = cutoff === undefined || stat.mtimeMs <= cutoff;
998
+ return eligible
999
+ ? { bytes: stat.size, files: 1, totalBytes: stat.size, totalFiles: 1 }
1000
+ : { bytes: 0, files: 0, totalBytes: stat.size, totalFiles: 1 };
787
1001
  } catch {
788
- return { bytes: 0, files: 0 };
1002
+ return { bytes: 0, files: 0, totalBytes: 0, totalFiles: 0 };
789
1003
  }
790
1004
  }
791
1005
  let bytes = 0;
792
1006
  let files = 0;
1007
+ let totalBytes = 0;
1008
+ let totalFiles = 0;
793
1009
  let seen = 0;
794
1010
  const stack = [abs];
795
1011
  while (stack.length > 0 && seen < MAX_ENTRIES) {
@@ -810,7 +1026,9 @@ export async function measurePath(abs, cutoff) {
810
1026
  }
811
1027
  if (!entry.isFile()) continue;
812
1028
  try {
813
- const stat = await fsp.stat(path.join(dir, entry.name));
1029
+ const stat = statReal(path.join(dir, entry.name));
1030
+ totalBytes += stat.size;
1031
+ totalFiles += 1;
814
1032
  if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
815
1033
  bytes += stat.size;
816
1034
  files += 1;
@@ -822,13 +1040,14 @@ export async function measurePath(abs, cutoff) {
822
1040
  /* an unreadable directory ends this branch */
823
1041
  }
824
1042
  }
825
- return { bytes, files };
1043
+ return { bytes, files, totalBytes, totalFiles };
826
1044
  }
827
1045
 
828
1046
  /**
829
- * Measure every resolved target concurrently, keeping the host event loop free. Each target's
830
- * own age limit is applied, so the numbers answer "what would a clean free right now" rather
831
- * than "how much is in the folder" — the two differ for every `aged` target.
1047
+ * Measure every resolved target concurrently, keeping the host event loop free. `bytes` is what
1048
+ * a clean would free right now (the target's own age limit applied); `totalBytes` is what the
1049
+ * folders hold. Reporting both is what tells the user "245 MB here, 0.2 MB past the limit"
1050
+ * instead of a small number that reads as "nothing found".
832
1051
  */
833
1052
  export async function scan(only, drive) {
834
1053
  const targets = resolveTargets(only, drive);
@@ -837,12 +1056,16 @@ export async function scan(only, drive) {
837
1056
  const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
838
1057
  let bytes = 0;
839
1058
  let files = 0;
1059
+ let totalBytes = 0;
1060
+ let totalFiles = 0;
840
1061
  for (const abs of target.paths) {
841
1062
  const size = await measurePath(abs, cutoff);
842
1063
  bytes += size.bytes;
843
1064
  files += size.files;
1065
+ totalBytes += size.totalBytes;
1066
+ totalFiles += size.totalFiles;
844
1067
  }
845
- return { ...target, bytes, files };
1068
+ return { ...target, bytes, files, totalBytes, totalFiles };
846
1069
  }),
847
1070
  );
848
1071
  return measured;
@@ -874,7 +1097,7 @@ async function purgePath(abs, cutoff) {
874
1097
  }
875
1098
  if (!entry.isFile()) continue;
876
1099
  try {
877
- const stat = await fsp.stat(full);
1100
+ const stat = statReal(full);
878
1101
  if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
879
1102
  await fsp.unlink(full);
880
1103
  bytes += stat.size;
@@ -926,7 +1149,7 @@ export async function clean(ids, options = {}) {
926
1149
  }
927
1150
  if (isFile(abs)) {
928
1151
  try {
929
- const stat = await fsp.stat(abs);
1152
+ const stat = statReal(abs);
930
1153
  if (cutoff !== undefined && stat.mtimeMs > cutoff) continue;
931
1154
  await fsp.unlink(abs);
932
1155
  bytes += stat.size;
package/lib/client.js CHANGED
@@ -41,10 +41,12 @@ window.__ModuleLoader__.load({
41
41
  'why_pending-update': '只清 {n} 天前的下载,避免删掉还没装的更新',
42
42
  why_rollback: '每个插件/程序只留版本号最高的一份,其余是回滚副本',
43
43
  why_superseded: '版本号不高于已装版本的更新包,装着的那份已经生效',
44
+ why_orphan: '写这个目录的程序已经不在这台机器上了:没有快捷方式、没有卸载项,两年多没写过文件',
44
45
  why_history: '每个会话的整文件备份,删了不影响会话本身',
45
46
  why_duplicate: '内容完全相同的重复副本,保留最新那份',
46
47
  'why_database-backup': '升级前的数据库副本,正在用的数据库还在旁边',
47
48
  pathCount: '{n} 处',
49
+ holds: '共 {n},够老的可清',
48
50
  driveLabel: '清理范围',
49
51
  allDrives: '全部磁盘',
50
52
  scanError: '扫描失败',
@@ -78,6 +80,20 @@ window.__ModuleLoader__.load({
78
80
  'target_agent-app-caches': 'AI 应用的浏览器缓存',
79
81
  'target_rewind-snapshots': '过期的回滚快照',
80
82
  'target_rotated-logs': '轮转掉的日志',
83
+ 'target_qoder-old-versions': '编辑器少用的旧版本目录',
84
+ 'target_orphan-app-data': '已卸载程序的残留数据',
85
+ 'target_app-data-caches': '已停用应用的缓存',
86
+ 'target_office-update-cache': 'Office 更新暂存包',
87
+ group_system: '系统临时与回收站',
88
+ group_dev: '开发工具与包管理器',
89
+ group_browser: '浏览器缓存',
90
+ group_app: '应用缓存与日志',
91
+ group_updates: '升级残留',
92
+ group_history: '历史备份与快照',
93
+ group_orphan: '已卸载程序残留',
94
+ group_other: '其他',
95
+ groupExpanded: '收起这一类',
96
+ groupFolded: '展开这一类',
81
97
  auditTitle: '空间评估(本插件不会删除)',
82
98
  auditHint: '统计缓存之外真正占空间的东西:已装软件、WSL 虚拟磁盘、Windows 组件库,以及你自己的文件。这些一律不由本插件删除,只列出供你判断。',
83
99
  auditRun: '开始评估',
@@ -197,10 +213,12 @@ window.__ModuleLoader__.load({
197
213
  'why_pending-update': 'only downloads older than {n} day(s), so a pending update survives',
198
214
  why_rollback: 'only the highest version of each family stays; the rest are rollback copies',
199
215
  why_superseded: 'the update package version is not above what is installed, so it is already applied',
216
+ why_orphan: 'the app that wrote this folder is gone: no shortcut, no uninstall entry, nothing written for over two years',
200
217
  why_history: 'a whole-file backup per session; removing it does not touch the session',
201
218
  why_duplicate: 'an identical copy; the newest one is kept',
202
219
  'why_database-backup': 'a copy made before an upgrade; the live database is beside it',
203
220
  pathCount: '{n} location(s)',
221
+ holds: 'holds {n}, of which the age limit allows',
204
222
  driveLabel: 'Limit to',
205
223
  allDrives: 'All drives',
206
224
  scanError: 'Scan failed',
@@ -234,6 +252,20 @@ window.__ModuleLoader__.load({
234
252
  'target_agent-app-caches': 'Browser caches of the AI apps',
235
253
  'target_rewind-snapshots': 'Expired rewind snapshots',
236
254
  'target_rotated-logs': 'Rotated-out log files',
255
+ 'target_qoder-old-versions': 'Old versions the editor no longer runs',
256
+ 'target_orphan-app-data': 'Leftover data from an uninstalled app',
257
+ 'target_app-data-caches': 'Caches of apps that stopped writing',
258
+ 'target_office-update-cache': 'Office update staging packages',
259
+ group_system: 'System temp and recycle bin',
260
+ group_dev: 'Developer tools and package managers',
261
+ group_browser: 'Browser caches',
262
+ group_app: 'App caches and logs',
263
+ group_updates: 'Leftover updaters',
264
+ group_history: 'Backups and snapshots',
265
+ group_orphan: 'Data from uninstalled apps',
266
+ group_other: 'Other',
267
+ groupExpanded: 'Collapse this group',
268
+ groupFolded: 'Expand this group',
237
269
  auditTitle: 'Space assessment (this plugin deletes none of it)',
238
270
  auditHint: 'What actually takes up room outside caches: installed programs, WSL disk images, the Windows component store, and your own files. None of it is removed here; it is listed so you can decide.',
239
271
  auditRun: 'Run assessment',
@@ -357,6 +389,14 @@ window.__ModuleLoader__.load({
357
389
  .dsc-total b { color: var(--dsw-alias-label-primary); font-size: 13px; }
358
390
  .dsc-list { display: flex; flex-direction: column; gap: 2px; }
359
391
  .dsc-row { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 7px; cursor: pointer; }
392
+ .dsc-group { display: flex; flex-direction: column; gap: 2px; }
393
+ .dsc-group + .dsc-group { margin-top: 6px; }
394
+ .dsc-group-head { display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box; padding: 6px 8px; border: 0; border-radius: 7px; background: transparent; font: inherit; color: var(--dsw-alias-label-primary); cursor: pointer; text-align: left; }
395
+ .dsc-group-head:hover { background: var(--dsw-alias-bg-layer-2); }
396
+ .dsc-group-head:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
397
+ .dsc-caret { width: 10px; color: var(--dsw-alias-label-secondary); font-size: 10px; line-height: 1; }
398
+ .dsc-group-title { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; letter-spacing: .01em; }
399
+ .dsc-group-meta { color: var(--dsw-alias-label-secondary); font-size: 11px; }
360
400
  .dsc-row:hover { background: var(--dsw-alias-bg-layer-2); }
361
401
  .dsc-row input { margin: 0; accent-color: var(--dsw-alias-brand-primary); }
362
402
  .dsc-row-name { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
@@ -575,6 +615,30 @@ window.__ModuleLoader__.load({
575
615
  }, [locale, active]);
576
616
  }
577
617
 
618
+ // Fixed order so the sections do not shuffle between scans.
619
+ const GROUP_ORDER = ['system', 'dev', 'browser', 'app', 'updates', 'history', 'orphan', 'other'];
620
+
621
+ /**
622
+ * Fold a flat target list into sections. Order is fixed rather than by size so a target
623
+ * stays where the user last saw it; the caller decides which sections read as folded.
624
+ */
625
+ function groupRows(rows) {
626
+ const buckets = new Map();
627
+ for (const row of rows) {
628
+ const key = GROUP_ORDER.includes(row.group) ? row.group : 'other';
629
+ if (!buckets.has(key)) buckets.set(key, []);
630
+ buckets.get(key).push(row);
631
+ }
632
+ return GROUP_ORDER.filter((key) => buckets.has(key)).map((key) => {
633
+ const list = buckets.get(key);
634
+ return {
635
+ key,
636
+ rows: list,
637
+ bytes: list.reduce((sum, row) => sum + (row.bytes || 0), 0),
638
+ };
639
+ });
640
+ }
641
+
578
642
  /** Why a row is safe or needs care, in one line the user can act on. */
579
643
  function riskWhy(t, row) {
580
644
  if (row.risk !== 'caution') return t('why_safe');
@@ -691,7 +755,7 @@ window.__ModuleLoader__.load({
691
755
 
692
756
  const pickAll = (on) => {
693
757
  if (!shown) return;
694
- setPicked(on ? new Set(shown.filter((row) => row.bytes > 0).map((row) => row.id)) : new Set());
758
+ setPicked(on ? new Set(visibleRows().filter((row) => row.bytes > 0).map((row) => row.id)) : new Set());
695
759
  };
696
760
 
697
761
  const runClean = async () => {
@@ -720,6 +784,17 @@ window.__ModuleLoader__.load({
720
784
  };
721
785
 
722
786
  const shown = rows && drive !== '' ? rows.filter((row) => row.pathCount > 0) : rows;
787
+
788
+ // Only what the user toggled is stored; every other section falls back to "folded
789
+ // when it totals zero", which is right for the common case where 20 of 32 rows read 0 B.
790
+ const [toggled, setToggled] = usePersistent('folded', {});
791
+ const isFolded = (section) =>
792
+ typeof toggled[section.key] === 'boolean' ? toggled[section.key] : section.bytes === 0;
793
+ const toggleGroup = (section) =>
794
+ setToggled((prev) => ({ ...prev, [section.key]: !isFolded(section) }));
795
+
796
+ /** What is actually on screen, so a hidden row cannot be selected by a bulk action. */
797
+ const visibleRows = () => (shown ? groupRows(shown).filter((section) => !isFolded(section)).flatMap((section) => section.rows) : []);
723
798
  const totalBytes = shown ? shown.reduce((sum, row) => sum + (row.bytes || 0), 0) : 0;
724
799
  const pickedBytes = shown ? shown.reduce((sum, row) => sum + (picked.has(row.id) ? row.bytes || 0 : 0), 0) : 0;
725
800
 
@@ -835,35 +910,74 @@ window.__ModuleLoader__.load({
835
910
  : h(
836
911
  'div',
837
912
  { className: 'dsc-list' },
838
- shown.map((row) =>
913
+ groupRows(shown).map((section) =>
839
914
  h(
840
- 'label',
841
- { key: row.id, className: 'dsc-row' },
842
- h('input', {
843
- type: 'checkbox',
844
- checked: picked.has(row.id),
845
- disabled: busy !== '' || row.bytes === 0,
846
- onChange: () => toggle(row.id),
847
- }),
915
+ 'div',
916
+ { key: section.key, className: 'dsc-group' },
917
+ // A row of 32 targets is unreadable as a flat list: most of them read
918
+ // 0 B most of the time. Fold each kind away, and start a group whose
919
+ // total is zero folded, so only the groups holding something are open.
848
920
  h(
849
- 'span',
850
- { className: 'dsc-row-name' },
851
- h('span', { className: 'dsc-row-label' }, targetName(t, row.id)),
921
+ 'button',
922
+ {
923
+ type: 'button',
924
+ className: 'dsc-group-head',
925
+ 'aria-expanded': !isFolded(section),
926
+ title: t(isFolded(section) ? 'groupFolded' : 'groupExpanded'),
927
+ onClick: () => toggleGroup(section),
928
+ },
929
+ h('span', { className: 'dsc-caret' }, isFolded(section) ? '▸' : '▾'),
930
+ h('span', { className: 'dsc-group-title' }, t(`group_${section.key}`)),
931
+ h('span', { className: 'dsc-group-meta' }, `${section.rows.length}`),
852
932
  h(
853
933
  'span',
854
- { className: 'dsc-row-hint' },
855
- `${t('pathCount').replace('{n}', String(row.pathCount))} · ${riskWhy(t, row)}`,
934
+ { className: `dsc-size${section.bytes > 0 ? '' : ' dsc-idle'}` },
935
+ formatBytes(section.bytes),
856
936
  ),
857
937
  ),
858
- h(
859
- 'span',
860
- {
861
- className: `dsc-badge${row.risk === 'caution' ? ' dsc-badge-caution' : ''}`,
862
- title: riskWhy(t, row),
863
- },
864
- t(row.risk === 'caution' ? 'risky' : 'safe'),
865
- ),
866
- h('span', { className: `dsc-size${row.bytes > 0 ? '' : ' dsc-idle'}` }, formatBytes(row.bytes)),
938
+ isFolded(section)
939
+ ? null
940
+ : section.rows.map((row) => {
941
+ // An `aged` target reports only the bytes past its age limit, so a
942
+ // folder holding 261 MB can read as "0.2 MB" and look like nothing
943
+ // was found. Name the folder size too when the two disagree.
944
+ const holds = row.totalBytes > row.bytes ? row.totalBytes : 0;
945
+ return h(
946
+ 'label',
947
+ { key: row.id, className: 'dsc-row' },
948
+ h('input', {
949
+ type: 'checkbox',
950
+ checked: picked.has(row.id),
951
+ disabled: busy !== '' || row.bytes === 0,
952
+ onChange: () => toggle(row.id),
953
+ }),
954
+ h(
955
+ 'span',
956
+ { className: 'dsc-row-name' },
957
+ h('span', { className: 'dsc-row-label' }, targetName(t, row.id)),
958
+ h(
959
+ 'span',
960
+ { className: 'dsc-row-hint' },
961
+ `${t('pathCount').replace('{n}', String(row.pathCount))} · ${riskWhy(t, row)}${
962
+ holds > 0 ? ` · ${t('holds').replace('{n}', formatBytes(holds))}` : ''
963
+ }`,
964
+ ),
965
+ ),
966
+ h(
967
+ 'span',
968
+ {
969
+ className: `dsc-badge${row.risk === 'caution' ? ' dsc-badge-caution' : ''}`,
970
+ title: riskWhy(t, row),
971
+ },
972
+ t(row.risk === 'caution' ? 'risky' : 'safe'),
973
+ ),
974
+ h(
975
+ 'span',
976
+ { className: `dsc-size${row.bytes > 0 ? '' : ' dsc-idle'}` },
977
+ formatBytes(row.bytes),
978
+ ),
979
+ );
980
+ }),
867
981
  ),
868
982
  ),
869
983
  ),
package/lib/index.js CHANGED
@@ -103,6 +103,7 @@ const toPublic = (target) => ({
103
103
  kind: target.kind,
104
104
  risk: target.risk,
105
105
  note: target.note,
106
+ group: target.group,
106
107
  minAgeDays: target.minAgeDays,
107
108
  drives: target.drives,
108
109
  pathCount: target.paths.length,
@@ -155,7 +156,15 @@ async function run(action, payload) {
155
156
  if (action === 'scan') {
156
157
  const measured = await scan(readIds(payload.ids), readDrive(payload.drive));
157
158
  return {
158
- targets: measured.map((target) => ({ ...toPublic(target), bytes: target.bytes, files: target.files })),
159
+ targets: measured.map((target) => ({
160
+ ...toPublic(target),
161
+ bytes: target.bytes,
162
+ files: target.files,
163
+ // The folder's real size, which differs from `bytes` for every `aged` target. Without
164
+ // it a folder holding 261 MB reads as "0.2 MB" and looks like nothing was found.
165
+ totalBytes: target.totalBytes,
166
+ totalFiles: target.totalFiles,
167
+ })),
159
168
  };
160
169
  }
161
170
  if (action === 'audit') return audit({ force: payload.force === true });
@@ -201,6 +210,7 @@ const NOTE_MEANING = {
201
210
  history: 'a backup snapshot of file edits that nothing reads back; the live state sits beside it',
202
211
  duplicate: 'a byte-identical copy of a directory that is still present',
203
212
  'database-backup': 'a copy of a database file taken before an upgrade; the live database is beside it',
213
+ orphan: 'the app that wrote this folder is no longer installed: no shortcut, no uninstall entry, and nothing written for over two years',
204
214
  };
205
215
 
206
216
  const formatBytes = (bytes) => {
@@ -281,7 +291,9 @@ export function apply(ctx) {
281
291
  'caches, the Windows internet cache, per-app caches, logs and scratch folders under %APPDATA%, ' +
282
292
  'superseded plugin versions and applied update packages, the backup snapshots, dated logs, ' +
283
293
  'rotated-out log files, duplicate toolchain copies and database backups under the user profile, ' +
284
- 'editor builds their launcher no longer names, and the Chromium caches of the agent apps. ' +
294
+ 'editor builds their launcher no longer names, superseded editor versions, data left by an app ' +
295
+ 'that is no longer installed, applied Office update packages, and the Chromium caches of the ' +
296
+ 'agent apps. ' +
285
297
  'Call with action "overview" to list drives and target ids, ' +
286
298
  '"scan" to measure them, and "clean" to delete. clean without confirm: true is a dry run that only ' +
287
299
  'reports what it would free, so the user can approve the amount before anything is removed. ' +
@@ -366,7 +378,12 @@ export function apply(ctx) {
366
378
  }
367
379
  if (action === 'scan') {
368
380
  const measured = await scan(readIds(args.ids), readDrive(args.drive));
369
- const lines = measured.map((target) => `${target.id}: ${formatBytes(target.bytes)} in ${target.files} file(s)`);
381
+ const lines = measured.map((target) => {
382
+ const limit = target.minAgeDays > 0 ? ` (${target.minAgeDays}d age limit)` : '';
383
+ const holds =
384
+ target.totalBytes > target.bytes ? ` of ${formatBytes(target.totalBytes)} present` : '';
385
+ return `${target.id}: ${formatBytes(target.bytes)}${holds} in ${target.files} file(s)${limit}`;
386
+ });
370
387
  const total = measured.reduce((sum, target) => sum + target.bytes, 0);
371
388
  const notes = [...new Set(measured.map((target) => target.note).filter(Boolean))];
372
389
  const meaning = notes.map((note) => `${note}: ${NOTE_MEANING[note] ?? note}`).join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-disk-cleaner",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "Disk Cleaner for DSH: scan known-safe cache locations, see what they occupy, and reclaim the space from a panel or from the agent.",
6
6
  "keywords": [
package/smoke.mjs CHANGED
@@ -262,9 +262,59 @@ check('tool description warns audit is read-only', /read-only: it never removes
262
262
  check('every effect is labelled', applied.every((label) => typeof label === 'string' && label.startsWith('dsh-disk-cleaner:')));
263
263
 
264
264
  const ids = [...catalog.matchAll(/^\s{4}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
265
- check('catalog has the documented 28 targets', ids.length === 28, `got ${ids.length}`);
265
+ check('catalog has the documented 32 targets', ids.length === 32, `got ${ids.length}`);
266
266
  check('every catalog id has a client label', ids.every((id) => zhKeys.includes(`target_${id}`) && enKeys.includes(`target_${id}`)));
267
- check('every note code has an explanation', ['why_safe', 'why_in-use', 'why_rebuild', 'why_elevation', 'why_pending-update', 'why_rollback', 'why_superseded', 'why_history', 'why_duplicate', 'why_database-backup'].every((k) => zhKeys.includes(k) && enKeys.includes(k)));
267
+ const groupMap = catalog.slice(catalog.indexOf('const GROUP_OF'), catalog.indexOf('const CATALOG = ['));
268
+ const grouped = new Set([...groupMap.matchAll(/^\s+'?([a-z-]+)'?: '[a-z]+',$/gm)].map((m) => m[1]));
269
+ check('every catalog id is filed under a group', ids.every((id) => grouped.has(id)), `missing: ${ids.filter((id) => !grouped.has(id)).join(',')}`);
270
+ const groupKeys = [...new Set([...groupMap.matchAll(/: '([a-z]+)',$/gm)].map((m) => m[1]))];
271
+ check('every group has a label in both languages', groupKeys.length > 1 && groupKeys.every((key) => zhKeys.includes(`group_${key}`) && enKeys.includes(`group_${key}`)), `groups: ${groupKeys.join(',')}`);
272
+ {
273
+ // Run the real grouping function out of the bundle rather than assert on its text.
274
+ const start = source.indexOf('const GROUP_ORDER =');
275
+ const end = source.indexOf('function riskWhy', start);
276
+ const groupRows = new Function(`${source.slice(start, end)}; return groupRows;`)();
277
+ const folded = groupRows([
278
+ { id: 'a', group: 'dev', bytes: 10 },
279
+ { id: 'b', group: 'dev', bytes: 5 },
280
+ { id: 'c', group: 'system', bytes: 0 },
281
+ { id: 'd', group: 'nonsense', bytes: 1 },
282
+ ]);
283
+ check('grouping keeps a fixed order, not a size order', folded.map((g) => g.key).join(',') === 'system,dev,other');
284
+ check('grouping totals each section', folded.find((g) => g.key === 'dev').bytes === 15);
285
+ check('an unknown group falls into other', folded.find((g) => g.key === 'other').rows[0].id === 'd');
286
+ check('an empty section is the one that starts folded', folded.filter((g) => g.bytes === 0).map((g) => g.key).join(',') === 'system');
287
+ }
288
+ {
289
+ // Fold/unfold driven by the real helpers, on real section objects. The regression this
290
+ // guards against was the head handing `toggleGroup` a bare key, so nothing was stored.
291
+ const start = source.indexOf('const GROUP_ORDER =');
292
+ const end = source.indexOf('function riskWhy', start);
293
+ const groupRows = new Function(`${source.slice(start, end)}; return groupRows;`)();
294
+ const live = () => groupRows([
295
+ { id: 'a', group: 'system', bytes: 200 },
296
+ { id: 'b', group: 'dev', bytes: 0 },
297
+ ]);
298
+ const isFolded = (t, section) =>
299
+ typeof t[section.key] === 'boolean' ? t[section.key] : section.bytes === 0;
300
+ const click = (t, section) => ({ ...t, [section.key]: !isFolded(t, section) });
301
+
302
+ let toggled = {};
303
+ let section = live().find((g) => g.key === 'system');
304
+ check('a group with bytes starts open', isFolded(toggled, section) === false);
305
+ toggled = click(toggled, section);
306
+ check('one click folds the group it belongs to', isFolded(toggled, section) === true);
307
+ check('the fold map holds only the group that was clicked', Object.keys(toggled).join(',') === 'system');
308
+ toggled = click(toggled, section);
309
+ check('a second click unfolds it again', isFolded(toggled, section) === false);
310
+ }
311
+ check(
312
+ 'a group head toggles its own section',
313
+ /onClick: \(\) => toggleGroup\(section\)/.test(source) && !/toggleGroup\(section\.key\)/.test(source),
314
+ );
315
+ check('select all skips the folded sections', /visibleRows\(\)\.filter/.test(source));
316
+ check('the panel folds the group list', /function groupRows\(rows\)/.test(source) && /aria-expanded/.test(source) && /section\.bytes === 0/.test(source));
317
+ check('every note code has an explanation', ['why_safe', 'why_in-use', 'why_rebuild', 'why_elevation', 'why_pending-update', 'why_rollback', 'why_superseded', 'why_history', 'why_duplicate', 'why_database-backup', 'why_orphan'].every((k) => zhKeys.includes(k) && enKeys.includes(k)));
268
318
  const areaIds = [...auditSrc.matchAll(/^\s{6}id: '([a-z-]+)',$/gm)].map((m) => m[1]);
269
319
  check('every assessment area has a translated label and reason', areaIds.length > 0 && areaIds.every((id) => zhKeys.includes(`area_${id}`) && zhKeys.includes(`area_${id}_why`) && enKeys.includes(`area_${id}`) && enKeys.includes(`area_${id}_why`)), `areas: ${areaIds.join(',')}`);
270
320
  check('a reveal request names an area, never a path', index.includes("action === 'reveal'") && auditSrc.includes('export function areaPaths') && !/revealPath[\s\S]{0,200}payload\.path/.test(index));
@@ -275,6 +325,11 @@ check(
275
325
  (catalog.match(/await measurePath\(abs, cutoff\)/g) ?? []).length === 2,
276
326
  `${(catalog.match(/await measurePath\(abs, cutoff\)/g) ?? []).length} cutoff-aware call sites`,
277
327
  );
328
+ check(
329
+ 'an age-limited row also names the folder size',
330
+ catalog.includes('totalBytes') && index.includes('totalBytes: target.totalBytes') && source.includes('row.totalBytes'),
331
+ 'scan must carry totalBytes through to the row, or an `aged` target reads as empty',
332
+ );
278
333
 
279
334
  const codes = [...new Set([...plan.matchAll(/code: '([a-z-]+)'/g)].map((m) => m[1]))];
280
335
  check('plan emits the documented 9 step codes', codes.length === 9, `got ${codes.length}`);