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 +31 -15
- package/lib/catalog.js +241 -18
- package/lib/client.js +138 -24
- package/lib/index.js +20 -3
- package/package.json +1 -1
- package/smoke.mjs +57 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<!-- badges -->
|
|
6
6
|
| | |
|
|
7
7
|
| --- | --- |
|
|
8
|
-
| **version** | `1.
|
|
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** —
|
|
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
|
|
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 #
|
|
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 (
|
|
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 (
|
|
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
|
|
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 #
|
|
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 (
|
|
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 (
|
|
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
|
|
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)
|
|
479
|
-
*
|
|
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: () => [
|
|
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
|
|
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
|
|
779
|
-
*
|
|
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 =
|
|
785
|
-
|
|
786
|
-
return
|
|
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 =
|
|
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.
|
|
830
|
-
*
|
|
831
|
-
*
|
|
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 =
|
|
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 =
|
|
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(
|
|
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((
|
|
913
|
+
groupRows(shown).map((section) =>
|
|
839
914
|
h(
|
|
840
|
-
'
|
|
841
|
-
{ key:
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
'
|
|
850
|
-
{
|
|
851
|
-
|
|
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-
|
|
855
|
-
|
|
934
|
+
{ className: `dsc-size${section.bytes > 0 ? '' : ' dsc-idle'}` },
|
|
935
|
+
formatBytes(section.bytes),
|
|
856
936
|
),
|
|
857
937
|
),
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
{
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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) => ({
|
|
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,
|
|
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) =>
|
|
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
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
|
|
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
|
-
|
|
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}`);
|