dsh-disk-cleaner 1.6.4 → 1.6.6

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.6.4` |
8
+ | **version** | `1.6.6` |
9
9
  | **DSH** | `0.2.1-rc.5`(开发与实测版本;用到 `plugins.bundle.config`、`settings.section`) |
10
10
  | **license** | [MIT](LICENSE) |
11
11
  | **Node** | `>= 22`(仅运行冒烟测试需要) |
@@ -164,18 +164,18 @@ These are enforced in `lib/catalog.js` and must not be relaxed:
164
164
 
165
165
  ### Install
166
166
 
167
- A catalog entry has been submitted to the curated [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) list, which powers **dshmarket → Discover** ([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970), pending review) — once it merges, search **dsh-disk-cleaner** there and install it in one click. Until then, use a direct install below.
167
+ Listed in the curated [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) catalog, which powers **dshmarket → Discover** ([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970), merged): search **dsh-disk-cleaner** there and install it in one click. The direct installs below do the same thing by hand.
168
168
 
169
- Published on npm as [`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner) — latest `1.6.4`:
169
+ Published on npm as [`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner) — latest `1.6.6`:
170
170
 
171
171
  ```bash
172
172
  npm i dsh-disk-cleaner
173
173
  ```
174
174
 
175
- A prebuilt tarball is attached to the [v1.6.3 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.4); installing from it skips DSH's build-approval step:
175
+ A prebuilt tarball is attached to the [v1.6.6 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.6); installing from it skips DSH's build-approval step:
176
176
 
177
177
  ```bash
178
- dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.4/dsh-disk-cleaner-1.6.4.tgz
178
+ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.6/dsh-disk-cleaner-1.6.6.tgz
179
179
  ```
180
180
 
181
181
  Then enable it in the profile — in `$DSH_PROFILE_DIR/package.json`:
@@ -183,7 +183,7 @@ Then enable it in the profile — in `$DSH_PROFILE_DIR/package.json`:
183
183
  ```json
184
184
  {
185
185
  "dependencies": {
186
- "dsh-disk-cleaner": "^1.6.4"
186
+ "dsh-disk-cleaner": "^1.6.6"
187
187
  },
188
188
  "dsh": {
189
189
  "profile": {
@@ -204,7 +204,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
204
204
  ### Development
205
205
 
206
206
  ```bash
207
- node smoke.mjs # 98 assertions, zero dependencies
207
+ node smoke.mjs # 110 assertions, zero dependencies
208
208
  node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
209
209
  ```
210
210
 
@@ -258,22 +258,24 @@ What the smoke test deliberately does **not** cover: anything that walks a real
258
258
  ```
259
259
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
260
260
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
261
- ├─ lib/index.js (434) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
261
+ ├─ lib/index.js (482) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
262
262
  ├─ lib/catalog.js (1222) The 32 targets, measurePath / purgePath / resolveTargets / drives
263
263
  ├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
264
264
  ├─ lib/plan.js (150) Pure: measurements → ordered steps
265
- ├─ lib/client.js (1597) Client half: slots, panel, preferences, zh/en dictionaries, CSS
265
+ ├─ lib/client.js (1808) Client half: slots, panel, preferences, zh/en dictionaries, CSS
266
266
  ├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
267
267
  ├─ icon.svg Plugin icon
268
268
  ├─ README.md This file
269
269
  ├─ LICENSE MIT
270
- └─ smoke.mjs 98 assertions, zero dependencies
270
+ └─ smoke.mjs 110 assertions, zero dependencies
271
271
  ```
272
272
 
273
273
  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).
274
274
 
275
275
  ### What's new
276
276
 
277
+ - **1.6.6** — The results the plugin already computed are now shown. A **Dry run** button next to *Clean* asks the exact same question without `confirm`, so you see what a delete would free before anything goes; the result banner then survives the rescan that follows it (it used to be erased a line later). Plan steps name targets in words instead of printing `npm-cache, chrome-cache, pip-cache`, an empty plan says "nothing worth doing" instead of rendering a hole, and route errors arrive as a stable `code` the panel translates, so a Chinese user never reads `origin rejected` or `unknown target: x`. Every real clean now appends one line to `$DSH_HOME/dsh-disk-cleaner/history.jsonl`, and a **Clean history** panel reads it back with a *Copy as CSV* button. Under the hood: scan walks four targets at a time instead of all 32 at once, `SystemRoot` decides where the Windows targets live instead of a hardcoded `C:`, a request without an `Origin` header is refused, and a refused directory removal is counted separately from a legitimately full one.
278
+ - **1.6.5** — Documentation only: the install section now points at the Release it names (the prebuilt-tarball link is labelled v1.6.5 and resolves to the v1.6.5 tag), and the catalog paragraph says the awesome-dsh-plugin entry is merged rather than pending review. No code change.
277
279
  - **1.6.4** — The panel now paints itself with [dsh-design-baseline](https://github.com/stallenlu/dsh-design-baseline): the stylesheet is inlined into the client bundle, the panel root carries `dsb-root`, and every colour resolves through a `--dsb-*` token instead of a host `--dsw-alias-*` name. That means it follows the host's light and dark themes (including `color-scheme`, so native `<select>` menus and scrollbars match), and colour is spent only on data that has state — the primary button and the active chip are now inverted neutrals rather than brand blue. No behaviour changes.
278
280
  - **1.6.3** — Safety and honesty rather than new features. The Recycle Bin is no longer filed under `safe`: emptying it cannot be undone, so it is never preselected, never picked by "select all", and carries its own `irreversible` note instead of the generic "rebuilt; next start is slower". The confirm dialog now names each target and the total instead of asking a generic "delete these?". A target that names no environment root is bounded by the volume it sits on rather than skipping the boundary check. `measure` and `purge` now count entries in the same unit, and a walk that hits the 2,000,000-entry cap says so (`truncated`) instead of claiming exact totals. A directory removal refused for a reason other than "not empty" is counted as `blocked` instead of being swallowed, and a clean that left files behind reports a warning rather than a green success. The orphan target is named for what it actually measures (the QQGuild folder).
279
281
  - **1.6.2** — Documentation only: the README, the in-source comment, the panel's zh/en explanations and the tool's note legend now describe `orphan-app-data` as it actually behaves (the QQGuild folder is offered for full deletion once no file in it has been written for 30 days), instead of claiming four independent signals and a two-year idle period. No behaviour change; the refreshed README ships in the npm package and the [v1.6.2 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.2) tarball.
@@ -434,18 +436,18 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
434
436
 
435
437
  ### 安装
436
438
 
437
- 已向精选目录 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) 提交收录,它是 **dshmarket → 发现** 的数据源([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970),审核中)——合并后即可在 dshmarket 里搜索 **dsh-disk-cleaner** 一键安装。在那之前,用下面任一种直接安装方式。
439
+ 已收录进精选目录 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin),它是 **dshmarket → 发现** 的数据源([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970),已合并):在 dshmarket 里搜索 **dsh-disk-cleaner** 即可一键安装。下面的直接安装方式就是手动做同一件事。
438
440
 
439
- 已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner),最新版 `1.6.4`。
441
+ 已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner),最新版 `1.6.6`。
440
442
 
441
443
  ```bash
442
444
  npm i dsh-disk-cleaner
443
445
  ```
444
446
 
445
- [v1.6.3 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.4) 还附带一个预构建 tarball,从它安装可跳过 DSH 的构建授权步骤:
447
+ [v1.6.6 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.6) 还附带一个预构建 tarball,从它安装可跳过 DSH 的构建授权步骤:
446
448
 
447
449
  ```bash
448
- dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.4/dsh-disk-cleaner-1.6.4.tgz
450
+ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.6/dsh-disk-cleaner-1.6.6.tgz
449
451
  ```
450
452
 
451
453
  然后在 profile 里启用 —— 在 `$DSH_PROFILE_DIR/package.json`:
@@ -453,7 +455,7 @@ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner
453
455
  ```json
454
456
  {
455
457
  "dependencies": {
456
- "dsh-disk-cleaner": "^1.6.4"
458
+ "dsh-disk-cleaner": "^1.6.6"
457
459
  },
458
460
  "dsh": {
459
461
  "profile": {
@@ -474,7 +476,7 @@ bundle 自带的 `cordis.patch.yml` 会插入插件行,无需改 profile 的 p
474
476
  ### 开发
475
477
 
476
478
  ```bash
477
- node smoke.mjs # 98 条断言,零依赖
479
+ node smoke.mjs # 110 条断言,零依赖
478
480
  node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
479
481
  ```
480
482
 
@@ -528,22 +530,24 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
528
530
  ```
529
531
  ├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
530
532
  ├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
531
- ├─ lib/index.js (434) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
533
+ ├─ lib/index.js (482) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
532
534
  ├─ lib/catalog.js (1222) 32 个目标,measurePath / purgePath / resolveTargets / drives
533
535
  ├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
534
536
  ├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
535
- ├─ lib/client.js (1597) Client 半面:slot、面板、偏好、zh/en 字典、CSS
537
+ ├─ lib/client.js (1808) Client 半面:slot、面板、偏好、zh/en 字典、CSS
536
538
  ├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
537
539
  ├─ icon.svg 插件图标
538
540
  ├─ README.md 本文件
539
541
  ├─ LICENSE MIT
540
- └─ smoke.mjs 98 条断言,零依赖
542
+ └─ smoke.mjs 110 条断言,零依赖
541
543
  ```
542
544
 
543
545
  Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
544
546
 
545
547
  ### 更新记录
546
548
 
549
+ - **1.6.6** — 把插件本来就算出来的结果真正显示出来。*清理* 旁边多了 **预演** 按钮:发同一个请求但不带 `confirm`,删之前就能看到会释放多少;清理结果横幅不再被紧随其后的重新扫描抹掉(以前下一行就被清空)。计划步骤改用目标名称,不再直出 `npm-cache, chrome-cache, pip-cache`;没有可做的事时说明「现在没有值得动手的地方」,而不是留一片空白;接口错误改带稳定 `code`,由面板翻成中文,不会再出现 `origin rejected` 或 `unknown target: x`。每次真正清理都会往 `$DSH_HOME/dsh-disk-cleaner/history.jsonl` 追加一行,新增的 **清理记录** 面板可读回并按 *复制为 CSV* 导出。底层:扫描从「32 个目标同时跑」改为每次 4 个,Windows 目标路径改由 `SystemRoot` 决定而不再硬编码 `C:`,缺少 `Origin` 头的请求会被拒绝,目录删除被拒与非空目录被跳过分别计数。
550
+ - **1.6.5** — 仅文档修正:安装段现在指向它所写的那个 Release(预构建 tarball 的链接标为 v1.6.5,且指向 v1.6.5 的 tag);目录段落也从「审核中」改为「已合并」。代码无变化。
547
551
  - **1.6.4** — 面板改用 [dsh-design-baseline](https://github.com/stallenlu/dsh-design-baseline) 上色:样式表内联进 client bundle,根节点带 `dsb-root`,所有颜色都走 `--dsb-*` 令牌而不是宿主的 `--dsw-alias-*` 名字。因此它跟随宿主的明暗主题(含 `color-scheme`,原生 `<select>` 弹层与滚动条也跟着变),并且颜色只花在有状态的数据上——主按钮与选中项改成中性反色,不再是品牌蓝。行为没有变化。
548
552
  - **1.6.3** — 改的是安全与诚实,不是新功能。回收站不再归入「安全」:清空不可恢复,所以它永不被预选、不被「全选」勾中,并单独带 `irreversible` 注意码,而不是笼统的「重建,下次启动慢一些」。确认框现在逐条列出目标名与合计字节,不再只问一句「删除选中缓存目录里的文件?」。没有声明根目录的目标,改为按所在卷做边界校验,而不是直接跳过检查。`measure` 与 `purge` 现在用同一单位计数,走查触到 200 万条上限时会如实标 `truncated`,不再声称精确总量。目录删除失败若原因不是「非空」会计入 `blocked`(此前被静默吞掉),清理后仍有残留的目标会报警告而不是绿色成功。orphan 目标按它实际测量的东西重新命名(QQ 频道目录)。
549
553
  - **1.6.2** — 仅文档修正:README、源码注释、面板中英文说明和工具里的注意码释义,现在都按真实行为描述 `orphan-app-data`(QQGuild 目录内 30 天没有文件写入时会被整目录列为可全清),不再声称「四条独立证据 / 两年未写」。行为不变;更新后的 README 也随 npm 包与 [v1.6.2 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.2) 的 tarball 一起发布。
package/lib/catalog.js CHANGED
@@ -643,7 +643,9 @@ const CATALOG = [
643
643
  // the user to an elevated prompt that changed nothing and hid the bytes from the plan.
644
644
  note: 'in-use',
645
645
  roots: ['SystemRoot'],
646
- paths: () => ['C:\\Windows\\Temp'],
646
+ // SystemRoot, not a hardcoded C: — the boundary check above already trusted it,
647
+ // so a machine whose Windows sits on another volume must delete from the same place.
648
+ paths: () => [path.join(env('SystemRoot') ?? 'C:\\Windows', 'Temp')],
647
649
  },
648
650
  {
649
651
  id: 'npm-cache',
@@ -717,7 +719,7 @@ const CATALOG = [
717
719
  risk: 'caution',
718
720
  note: 'elevation',
719
721
  roots: ['SystemRoot'],
720
- paths: () => ['C:\\Windows\\SoftwareDistribution\\Download'],
722
+ paths: () => [path.join(env('SystemRoot') ?? 'C:\\Windows', 'SoftwareDistribution', 'Download')],
721
723
  },
722
724
  {
723
725
  id: 'recycle-bin',
@@ -1057,27 +1059,45 @@ export async function measurePath(abs, cutoff) {
1057
1059
  * folders hold. Reporting both is what tells the user "245 MB here, 0.2 MB past the limit"
1058
1060
  * instead of a small number that reads as "nothing found".
1059
1061
  */
1062
+ const measureTarget = async (target) => {
1063
+ const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
1064
+ let bytes = 0;
1065
+ let files = 0;
1066
+ let totalBytes = 0;
1067
+ let totalFiles = 0;
1068
+ let truncated = false;
1069
+ for (const abs of target.paths) {
1070
+ const size = await measurePath(abs, cutoff);
1071
+ bytes += size.bytes;
1072
+ files += size.files;
1073
+ totalBytes += size.totalBytes;
1074
+ totalFiles += size.totalFiles;
1075
+ truncated = truncated || size.truncated === true;
1076
+ }
1077
+ return { ...target, bytes, files, totalBytes, totalFiles, truncated };
1078
+ };
1079
+
1080
+ /**
1081
+ * Measuring all 32 targets at once meant 32 concurrent directory walks, which turns a
1082
+ * scan on a spinning disk (or behind a scanner) into a stall. Four at a time keeps the
1083
+ * wall clock close and the disk queue short; results keep catalog order either way.
1084
+ */
1085
+ const SCAN_CONCURRENCY = 4;
1086
+
1060
1087
  export async function scan(only, drive) {
1061
1088
  const targets = resolveTargets(only, drive);
1062
- const measured = await Promise.all(
1063
- targets.map(async (target) => {
1064
- const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
1065
- let bytes = 0;
1066
- let files = 0;
1067
- let totalBytes = 0;
1068
- let totalFiles = 0;
1069
- let truncated = false;
1070
- for (const abs of target.paths) {
1071
- const size = await measurePath(abs, cutoff);
1072
- bytes += size.bytes;
1073
- files += size.files;
1074
- totalBytes += size.totalBytes;
1075
- totalFiles += size.totalFiles;
1076
- truncated = truncated || size.truncated === true;
1077
- }
1078
- return { ...target, bytes, files, totalBytes, totalFiles, truncated };
1079
- }),
1080
- );
1089
+ const measured = new Array(targets.length);
1090
+ let next = 0;
1091
+ const worker = async () => {
1092
+ while (next < targets.length) {
1093
+ const index = next;
1094
+ next += 1;
1095
+ measured[index] = await measureTarget(targets[index]);
1096
+ }
1097
+ };
1098
+ const workers = [];
1099
+ for (let i = 0; i < Math.min(SCAN_CONCURRENCY, targets.length); i += 1) workers.push(worker());
1100
+ await Promise.all(workers);
1081
1101
  return measured;
1082
1102
  }
1083
1103
 
package/lib/client.js CHANGED
@@ -102,6 +102,32 @@ window.__ModuleLoader__.load({
102
102
  cleanConfirmList: '即将清理 {n} 项,合计 {bytes}:\n\n{list}\n\n回收站等不可恢复的项目会单独标出。',
103
103
  cleanConfirmIrreversible: '\n\n注意:其中「回收站」清空后无法恢复。',
104
104
  truncatedHint: '这个目录太大,只统计/清理了前 {n} 条,实际释放可能少于这个数。',
105
+ dryRunHint: '预演完成:没有删除任何文件。',
106
+ wouldFree: '可释放',
107
+ dryRunBtn: '预演',
108
+ dryRunBusy: '预演中…',
109
+ listSep: '、',
110
+ planEmpty: '现在没有值得动手的地方:缓存都还小,或者才清过。等它们长回来再看。',
111
+ historyTitle: '清理记录',
112
+ historyOpen: '查看清理记录',
113
+ historyLoading: '读取中…',
114
+ historyEmpty: '还没有清理记录。真正执行过一次清理后,这里会留下每一条。',
115
+ historyCopy: '复制为 CSV',
116
+ historyCopied: '已复制',
117
+ historyRun: '{time} · 释放 {bytes} · {files} 个文件',
118
+ historySourceAgent: '由 Agent 执行',
119
+ 'err_method-not-allowed': '这个接口只接受本地面板的请求。',
120
+ 'err_origin-rejected': '请求来源不被接受:请从面板里操作。',
121
+ 'err_body-too-large': '请求内容过大。',
122
+ 'err_body-invalid': '请求内容无法解析。',
123
+ 'err_bad-ids': '目标列表格式不对。',
124
+ 'err_too-many-ids': '一次选的目标太多了。',
125
+ 'err_unknown-target': '有不认识的目标名称,请重新扫描后再试。',
126
+ 'err_unknown-drive': '磁盘名称不认识。',
127
+ 'err_drive-not-mounted': '这个盘当前没有挂载。',
128
+ 'err_no-targets': '没有选中任何目标。',
129
+ 'err_unknown-action': '不支持的操作。',
130
+ 'err_internal': '插件内部出错了,详细信息见日志。',
105
131
  'target_temp': '用户临时文件',
106
132
  'target_windows-temp': 'Windows 临时文件',
107
133
  'target_npm-cache': 'npm 缓存',
@@ -278,6 +304,32 @@ window.__ModuleLoader__.load({
278
304
  cleanConfirmList: 'About to clean {n} target(s), {bytes} in total:\n\n{list}\n\nAnything irreversible is called out separately.',
279
305
  cleanConfirmIrreversible: '\n\nNote: emptying the Recycle Bin cannot be undone.',
280
306
  truncatedHint: 'This folder is too large: only the first {n} entries were measured or removed, so less may be freed.',
307
+ dryRunHint: 'Dry run finished: nothing was deleted.',
308
+ wouldFree: 'Could free',
309
+ dryRunBtn: 'Dry run',
310
+ dryRunBusy: 'Running…',
311
+ listSep: ', ',
312
+ planEmpty: 'Nothing worth doing right now: the caches are still small, or you just cleaned them. Come back when they grow.',
313
+ historyTitle: 'Clean history',
314
+ historyOpen: 'Show clean history',
315
+ historyLoading: 'Reading…',
316
+ historyEmpty: 'No runs recorded yet. Every real clean leaves one entry here.',
317
+ historyCopy: 'Copy as CSV',
318
+ historyCopied: 'Copied',
319
+ historyRun: '{time} · freed {bytes} · {files} file(s)',
320
+ historySourceAgent: 'run by the agent',
321
+ 'err_method-not-allowed': 'This route only accepts requests from the local panel.',
322
+ 'err_origin-rejected': 'Request origin rejected: use the panel.',
323
+ 'err_body-too-large': 'The request was too large.',
324
+ 'err_body-invalid': 'The request could not be parsed.',
325
+ 'err_bad-ids': 'The target list is malformed.',
326
+ 'err_too-many-ids': 'Too many targets in one request.',
327
+ 'err_unknown-target': 'Unknown target name; scan again and retry.',
328
+ 'err_unknown-drive': 'Unknown drive.',
329
+ 'err_drive-not-mounted': 'That drive is not mounted.',
330
+ 'err_no-targets': 'No target selected.',
331
+ 'err_unknown-action': 'Unsupported action.',
332
+ 'err_internal': 'The plugin hit an internal error; see the log.',
281
333
  'target_temp': 'User temp files',
282
334
  'target_windows-temp': 'Windows temp files',
283
335
  'target_npm-cache': 'npm cache',
@@ -545,6 +597,21 @@ window.__ModuleLoader__.load({
545
597
  return label === key ? id : label;
546
598
  }
547
599
 
600
+ /**
601
+ * The route answers with a stable `code` beside its English message. The panel shows the
602
+ * translated code and only falls back to the message when the code is unknown, so a
603
+ * Chinese user never reads "origin rejected" or "unknown target: x".
604
+ */
605
+ function describeError(t, err) {
606
+ const code = err && typeof err.code === 'string' ? err.code : '';
607
+ if (code !== '') {
608
+ const key = `err_${code}`;
609
+ const text = t(key);
610
+ if (text !== key) return text;
611
+ }
612
+ return err instanceof Error ? err.message : String(err);
613
+ }
614
+
548
615
  async function call(action, payload) {
549
616
  const response = await fetch(ROUTE, {
550
617
  method: 'POST',
@@ -558,7 +625,9 @@ window.__ModuleLoader__.load({
558
625
  throw new Error(`HTTP ${response.status}`);
559
626
  }
560
627
  if (!response.ok || !data || data.ok !== true) {
561
- throw new Error((data && data.error) || `HTTP ${response.status}`);
628
+ const error = new Error((data && data.error) || `HTTP ${response.status}`);
629
+ if (data && typeof data.code === 'string') error.code = data.code;
630
+ throw error;
562
631
  }
563
632
  return data;
564
633
  }
@@ -779,10 +848,15 @@ window.__ModuleLoader__.load({
779
848
  };
780
849
  }, [drives.length, setDrives]);
781
850
 
782
- const scan = useCallback(async () => {
851
+ /**
852
+ * `keepNotice` is what makes the clean result readable at all: a clean ends by
853
+ * rescanning, and a rescan used to clear the banner it had just set.
854
+ */
855
+ const scan = useCallback(async (options) => {
856
+ const keepNotice = options !== null && typeof options === 'object' && options.keepNotice === true;
783
857
  setBusy('scan');
784
858
  setError('');
785
- setNotice(null);
859
+ if (!keepNotice) setNotice(null);
786
860
  try {
787
861
  const data = await call('scan', drive === '' ? undefined : { drive });
788
862
  const targets = Array.isArray(data.targets) ? data.targets : [];
@@ -798,7 +872,7 @@ window.__ModuleLoader__.load({
798
872
  .map((row) => row.id);
799
873
  setPicked(new Set(preselect));
800
874
  } catch (err) {
801
- setError(`${t('scanError')}: ${err instanceof Error ? err.message : String(err)}`);
875
+ setError(`${t('scanError')}: ${describeError(t, err)}`);
802
876
  } finally {
803
877
  setBusy('');
804
878
  }
@@ -830,10 +904,16 @@ window.__ModuleLoader__.load({
830
904
  setPicked(new Set(eligible));
831
905
  };
832
906
 
833
- const runClean = async () => {
907
+ /**
908
+ * `dry` runs the exact same request without `confirm`, so the number the panel reports
909
+ * is the number the delete would achieve. It never asks for confirmation: it deletes
910
+ * nothing. A real run keeps asking.
911
+ */
912
+ const runClean = async (dry) => {
913
+ const isDry = dry === true;
834
914
  if (!rows || picked.size === 0) return;
835
915
  const chosen = shown.filter((row) => picked.has(row.id));
836
- if (confirmFirst.get() === 'always') {
916
+ if (!isDry && confirmFirst.get() === 'always') {
837
917
  // A confirm box that does not say what it deletes is not a confirmation. Name the
838
918
  // targets and the total, and call out anything that cannot be undone.
839
919
  const bytes = chosen.reduce((sum, row) => sum + (row.bytes || 0), 0);
@@ -851,12 +931,20 @@ window.__ModuleLoader__.load({
851
931
  setError('');
852
932
  setNotice(null);
853
933
  try {
854
- const data = await call('clean', drive === '' ? { ids, confirm: true } : { ids, drive, confirm: true });
934
+ const body = isDry ? { ids } : { ids, confirm: true };
935
+ const data = await call('clean', drive === '' ? body : { ...body, drive });
855
936
  const results = Array.isArray(data.results) ? data.results : [];
856
937
  const bytes = results.reduce((sum, item) => sum + (item.bytes || 0), 0);
857
938
  const files = results.reduce((sum, item) => sum + (item.removed || 0), 0);
858
939
  const failed = results.reduce((sum, item) => sum + (item.failed || 0), 0);
859
940
  const truncated = results.filter((item) => item.truncated === true).length > 0;
941
+ if (isDry) {
942
+ setNotice({
943
+ kind: 'ok',
944
+ text: `${t('dryRunHint')} ${t('wouldFree')} ${formatBytes(bytes)} · ${files} ${t('files')}`,
945
+ });
946
+ return;
947
+ }
860
948
  // A run that left files behind is not a success banner: an all-ok green line over
861
949
  // "12 skipped" reads as done when it is not.
862
950
  const kind = failed > 0 ? 'warn' : 'ok';
@@ -864,9 +952,9 @@ window.__ModuleLoader__.load({
864
952
  if (failed > 0) parts.push(`${t('failed')} ${failed} (${t('failedHint')})`);
865
953
  if (truncated) parts.push(t('truncatedHint').replace('{n}', String(results.length)));
866
954
  setNotice({ kind, text: parts.join(' · ') });
867
- await scan();
955
+ await scan({ keepNotice: true });
868
956
  } catch (err) {
869
- setError(`${t('cleanError')}: ${err instanceof Error ? err.message : String(err)}`);
957
+ setError(`${t('cleanError')}: ${describeError(t, err)}`);
870
958
  } finally {
871
959
  setBusy('');
872
960
  }
@@ -977,13 +1065,26 @@ window.__ModuleLoader__.load({
977
1065
  picked.size > 0 ? ` · ${t('selected')} ${formatBytes(pickedBytes)}` : '',
978
1066
  )
979
1067
  : null,
1068
+ rows
1069
+ ? h(
1070
+ 'button',
1071
+ {
1072
+ type: 'button',
1073
+ className: 'dsc-btn',
1074
+ onClick: () => runClean(true),
1075
+ disabled: busy !== '' || picked.size === 0,
1076
+ title: t('dryRunHint'),
1077
+ },
1078
+ busy === 'clean' ? t('dryRunBusy') : t('dryRunBtn'),
1079
+ )
1080
+ : null,
980
1081
  rows
981
1082
  ? h(
982
1083
  'button',
983
1084
  {
984
1085
  type: 'button',
985
1086
  className: 'dsc-btn dsc-btn-danger',
986
- onClick: runClean,
1087
+ onClick: () => runClean(false),
987
1088
  disabled: busy !== '' || picked.size === 0,
988
1089
  },
989
1090
  busy === 'clean' ? t('cleaning') : t('clean'),
@@ -1088,10 +1189,110 @@ window.__ModuleLoader__.load({
1088
1189
  ),
1089
1190
  ),
1090
1191
  h(Audit, { t }),
1192
+ h(History, { t }),
1091
1193
  ],
1092
1194
  );
1093
1195
  }
1094
1196
 
1197
+ /**
1198
+ * Every real clean appends one line to a JSONL file under $DSH_HOME. The panel reads it
1199
+ * back, so "what did I actually clean, and when" has an answer that outlives the tab —
1200
+ * and the CSV copy takes that answer out of the app.
1201
+ */
1202
+ function History({ t }) {
1203
+ const [runs, setRuns] = useState(null);
1204
+ const [busy, setBusy] = useState(false);
1205
+ const [error, setError] = useState('');
1206
+ const [copied, setCopied] = useState(false);
1207
+
1208
+ const load = async () => {
1209
+ setBusy(true);
1210
+ setError('');
1211
+ try {
1212
+ const data = await call('history');
1213
+ setRuns(Array.isArray(data.runs) ? data.runs : []);
1214
+ } catch (err) {
1215
+ setError(`${t('historyTitle')}: ${describeError(t, err)}`);
1216
+ } finally {
1217
+ setBusy(false);
1218
+ }
1219
+ };
1220
+
1221
+ /** Quote every cell: a target list contains commas, and Excel must not split it. */
1222
+ const copyCsv = async () => {
1223
+ const rows = [['when', 'bytes', 'files', 'failed', 'truncated', 'targets', 'source']];
1224
+ for (const run of runs || []) {
1225
+ rows.push([
1226
+ run.at || '',
1227
+ String(run.bytes || 0),
1228
+ String(run.files || 0),
1229
+ String(run.failed || 0),
1230
+ run.truncated === true ? 'yes' : 'no',
1231
+ (Array.isArray(run.ids) ? run.ids : []).join(' '),
1232
+ run.source === 'agent' ? 'agent' : 'panel',
1233
+ ]);
1234
+ }
1235
+ const csv = rows
1236
+ .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
1237
+ .join('\r\n');
1238
+ try {
1239
+ await navigator.clipboard.writeText(csv);
1240
+ setCopied(true);
1241
+ } catch {
1242
+ setError(t('historyCopy'));
1243
+ }
1244
+ };
1245
+
1246
+ return h(
1247
+ 'section',
1248
+ { className: 'dsc-card' },
1249
+ h(
1250
+ 'div',
1251
+ { className: 'dsc-card-title' },
1252
+ t('historyTitle'),
1253
+ h('span', { className: 'dsc-spacer' }),
1254
+ runs && runs.length > 0
1255
+ ? h('button', { type: 'button', className: 'dsc-btn', onClick: copyCsv }, copied ? t('historyCopied') : t('historyCopy'))
1256
+ : null,
1257
+ h(
1258
+ 'button',
1259
+ { type: 'button', className: 'dsc-btn', onClick: load, disabled: busy },
1260
+ busy ? t('historyLoading') : t('historyOpen'),
1261
+ ),
1262
+ ),
1263
+ error ? h('div', { className: 'dsc-notice dsc-err' }, error) : null,
1264
+ runs === null
1265
+ ? null
1266
+ : runs.length === 0
1267
+ ? h('div', { className: 'dsc-empty' }, t('historyEmpty'))
1268
+ : runs.map((run, index) =>
1269
+ h(
1270
+ 'div',
1271
+ { className: 'dsc-audit-row', key: `${run.at || ''}/${index}` },
1272
+ h(
1273
+ 'span',
1274
+ { className: 'dsc-audit-name' },
1275
+ h(
1276
+ 'span',
1277
+ { className: 'dsc-audit-label' },
1278
+ t('historyRun')
1279
+ .replace('{time}', run.at ? new Date(run.at).toLocaleString() : '')
1280
+ .replace('{bytes}', formatBytes(run.bytes || 0))
1281
+ .replace('{files}', String(run.files || 0)),
1282
+ ),
1283
+ h(
1284
+ 'span',
1285
+ { className: 'dsc-audit-why' },
1286
+ run.source === 'agent'
1287
+ ? t('historySourceAgent')
1288
+ : (Array.isArray(run.ids) ? run.ids : []).map((id) => targetName(t, id)).join(t('listSep')),
1289
+ ),
1290
+ ),
1291
+ ),
1292
+ ),
1293
+ );
1294
+ }
1295
+
1095
1296
  /**
1096
1297
  * The plan turns the numbers into an order of operations. Steps that this plugin can
1097
1298
  * actually perform get a button; the rest show the exact command, because "compact
@@ -1100,7 +1301,17 @@ window.__ModuleLoader__.load({
1100
1301
  function Plan({ plan, t, onRan, busy }) {
1101
1302
  const [copied, setCopied] = useState('');
1102
1303
 
1103
- if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return null;
1304
+ if (!plan || !Array.isArray(plan.steps)) return null;
1305
+ // An empty plan is an answer — "there is nothing worth doing" — not a reason to
1306
+ // render a hole where the plan was.
1307
+ if (plan.steps.length === 0) {
1308
+ return h(
1309
+ 'div',
1310
+ { className: 'dsc-plan' },
1311
+ h('div', { className: 'dsc-audit-group-title' }, t('planTitle')),
1312
+ h('div', { className: 'dsc-plan-detail' }, t('planEmpty')),
1313
+ );
1314
+ }
1104
1315
 
1105
1316
  const copy = (code, command) => {
1106
1317
  const done = () => {
@@ -1125,7 +1336,7 @@ window.__ModuleLoader__.load({
1125
1336
  const detailKey = `plan_code_${step.code}_detail`;
1126
1337
  const detail = t(detailKey)
1127
1338
  .replace('{n}', String(step.count || 0))
1128
- .replace('{ids}', (step.ids || []).join(', '))
1339
+ .replace('{ids}', (step.ids || []).map((id) => targetName(t, id)).join(t('listSep')))
1129
1340
  .replace('{name}', step.name || '')
1130
1341
  .replace('{scope}', step.scope === 'local' ? t('planScopeLocal') : t('planScopeRoaming'))
1131
1342
  .replace('{folder}', step.folder || '')
package/lib/history.js ADDED
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * What each real run actually freed, appended as one JSON line per run.
7
+ *
8
+ * The panel is the only writer and it writes after the deletion has already happened, so a
9
+ * failed append must never turn a successful clean into an error: every function here answers
10
+ * with a value instead of throwing. Nothing else reads this file, which is why it is a plain
11
+ * JSONL log rather than a database.
12
+ */
13
+
14
+ const MAX_BYTES = 512 * 1024;
15
+
16
+ export function historyDir(env = process.env) {
17
+ const home =
18
+ typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== ''
19
+ ? env.DSH_HOME.trim()
20
+ : path.join(os.homedir(), '.dsh');
21
+ return path.join(home, 'dsh-disk-cleaner');
22
+ }
23
+
24
+ export function historyFile(env = process.env) {
25
+ return path.join(historyDir(env), 'history.jsonl');
26
+ }
27
+
28
+ const trim = (file) => {
29
+ try {
30
+ const stat = fs.statSync(file);
31
+ if (stat.size <= MAX_BYTES) return;
32
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
33
+ // Keep the newest half, which is what a reader ever asks about.
34
+ const kept = lines.slice(Math.floor(lines.length / 2));
35
+ fs.writeFileSync(file, `${kept.join('\n')}\n`, 'utf8');
36
+ } catch {
37
+ /* a log that cannot be trimmed is still a log */
38
+ }
39
+ };
40
+
41
+ export function appendRun(record, env = process.env) {
42
+ try {
43
+ const dir = historyDir(env);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ const file = historyFile(env);
46
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
47
+ trim(file);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ export function readRuns(limit = 50, env = process.env) {
55
+ try {
56
+ const text = fs.readFileSync(historyFile(env), 'utf8');
57
+ const lines = text.split('\n').filter((line) => line.trim() !== '');
58
+ const out = [];
59
+ for (const line of lines.slice(-limit)) {
60
+ try {
61
+ const parsed = JSON.parse(line);
62
+ if (parsed !== null && typeof parsed === 'object') out.push(parsed);
63
+ } catch {
64
+ /* a torn last line is not a reason to lose the rest */
65
+ }
66
+ }
67
+ return out.reverse();
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ import { spawn } from 'node:child_process';
11
11
 
12
12
  import { areaPaths, audit, invalidateAudit } from './audit.js';
13
13
  import { clean, drives, hasTarget, resolveTargets, scan } from './catalog.js';
14
+ import { appendRun, readRuns } from './history.js';
14
15
 
15
16
  export const name = 'dsh-disk-cleaner';
16
17
 
@@ -20,6 +21,16 @@ const ROUTE_PREFIX = '/api/disk-cleaner/';
20
21
  const MAX_BODY = 4096;
21
22
  const MAX_IDS = 32;
22
23
 
24
+ /**
25
+ * Every rejection carries a code beside its message. The message is for logs and curl; the
26
+ * code is what the panel translates, so a Chinese user never reads "origin rejected".
27
+ */
28
+ const coded = (code, message) => {
29
+ const error = new Error(message);
30
+ error.code = code;
31
+ return error;
32
+ };
33
+
23
34
  const sendJson = (res, status, value) => {
24
35
  const body = JSON.stringify(value);
25
36
  res.writeHead(status, {
@@ -30,11 +41,18 @@ const sendJson = (res, status, value) => {
30
41
  res.end(body);
31
42
  };
32
43
 
33
- /** A same-origin page is the only caller that can reach a state-changing route. */
44
+ /**
45
+ * A same-origin page is the only caller that can reach a state-changing route.
46
+ *
47
+ * An absent Origin used to pass, which let any local process drive clean/set-key by simply
48
+ * not sending the header. Browsers always send it on a cross-context POST, so requiring it
49
+ * costs the panel nothing and shuts the door; a missing Host is treated the same way.
50
+ */
34
51
  const sameOrigin = (req) => {
35
52
  const origin = req.headers.origin;
36
53
  const host = req.headers.host;
37
- return origin === undefined || host === undefined || origin === `http://${host}` || origin === `https://${host}`;
54
+ if (typeof origin !== 'string' || typeof host !== 'string') return false;
55
+ return origin === `http://${host}` || origin === `https://${host}`;
38
56
  };
39
57
 
40
58
  const readBody = (req) =>
@@ -74,10 +92,10 @@ const readBody = (req) =>
74
92
  /** Only catalog ids are accepted; an unknown or malformed id rejects the request. */
75
93
  const readIds = (value) => {
76
94
  if (value === undefined || value === null) return undefined;
77
- if (!Array.isArray(value)) throw new Error('ids must be an array');
78
- if (value.length > MAX_IDS) throw new Error('too many ids');
95
+ if (!Array.isArray(value)) throw coded('bad-ids', 'ids must be an array');
96
+ if (value.length > MAX_IDS) throw coded('too-many-ids', 'too many ids');
79
97
  for (const id of value) {
80
- if (typeof id !== 'string' || !hasTarget(id)) throw new Error(`unknown target: ${String(id)}`);
98
+ if (typeof id !== 'string' || !hasTarget(id)) throw coded('unknown-target', `unknown target: ${String(id)}`);
81
99
  }
82
100
  return value;
83
101
  };
@@ -91,10 +109,10 @@ const readDrive = (value) => {
91
109
  if (value === undefined || value === null || value === '') return undefined;
92
110
  const raw = typeof value === 'string' ? value.trim() : '';
93
111
  const match = /^([A-Za-z])(?::[\\/]?)?$/.exec(raw);
94
- if (match === null) throw new Error(`unknown drive: ${String(value)}`);
112
+ if (match === null) throw coded('unknown-drive', `unknown drive: ${String(value)}`);
95
113
  const letter = match[1].toUpperCase();
96
114
  const mounted = drives().find((drive) => drive.root[0] === letter);
97
- if (!mounted) throw new Error(`drive not mounted: ${letter}:`);
115
+ if (!mounted) throw coded('drive-not-mounted', `drive not mounted: ${letter}:`);
98
116
  return mounted.root;
99
117
  };
100
118
 
@@ -173,24 +191,38 @@ async function run(action, payload) {
173
191
  if (action === 'reveal') return reveal(payload);
174
192
  if (action === 'clean') {
175
193
  const ids = readIds(payload.ids);
176
- if (ids === undefined || ids.length === 0) throw new Error('clean needs at least one target id');
194
+ if (ids === undefined || ids.length === 0) throw coded('no-targets', 'clean needs at least one target id');
177
195
  const dryRun = payload.confirm !== true;
178
196
  const results = await clean(ids, { dryRun, drive: readDrive(payload.drive) });
179
197
  // A real deletion changes the numbers the assessment reported, so the cached plan is
180
198
  // no longer the truth. A dry run removed nothing and leaves it valid.
181
- if (!dryRun) invalidateAudit();
199
+ if (!dryRun) {
200
+ invalidateAudit();
201
+ // Written after the fact and never allowed to fail the request: the bytes are already
202
+ // gone by the time this runs, so a broken log must not read as a broken clean.
203
+ appendRun({
204
+ at: new Date().toISOString(),
205
+ bytes: results.reduce((sum, item) => sum + (item.bytes || 0), 0),
206
+ files: results.reduce((sum, item) => sum + (item.removed || 0), 0),
207
+ failed: results.reduce((sum, item) => sum + (item.failed || 0), 0),
208
+ truncated: results.some((item) => item.truncated === true),
209
+ ids: results.map((item) => item.id),
210
+ results,
211
+ });
212
+ }
182
213
  return { dryRun, results };
183
214
  }
184
- throw new Error(`unknown action: ${String(action)}`);
215
+ if (action === 'history') return { runs: readRuns(50) };
216
+ throw coded('unknown-action', `unknown action: ${String(action)}`);
185
217
  }
186
218
 
187
219
  const handle = async (req, res) => {
188
220
  if (req.method !== 'POST') {
189
- sendJson(res, 405, { ok: false, error: 'method not allowed' });
221
+ sendJson(res, 405, { ok: false, code: 'method-not-allowed', error: 'method not allowed' });
190
222
  return;
191
223
  }
192
224
  if (!sameOrigin(req)) {
193
- sendJson(res, 403, { ok: false, error: 'origin rejected' });
225
+ sendJson(res, 403, { ok: false, code: 'origin-rejected', error: 'origin rejected' });
194
226
  return;
195
227
  }
196
228
  try {
@@ -198,7 +230,11 @@ const handle = async (req, res) => {
198
230
  const action = typeof payload.action === 'string' ? payload.action : '';
199
231
  sendJson(res, 200, { ok: true, ...(await run(action, payload)) });
200
232
  } catch (error) {
201
- sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
233
+ sendJson(res, 400, {
234
+ ok: false,
235
+ code: typeof error?.code === 'string' ? error.code : 'internal',
236
+ error: error instanceof Error ? error.message : String(error),
237
+ });
202
238
  }
203
239
  };
204
240
 
@@ -413,10 +449,22 @@ export function apply(ctx) {
413
449
  }
414
450
  if (action === 'clean') {
415
451
  const ids = readIds(args.ids);
416
- if (ids === undefined || ids.length === 0) throw new Error('clean needs at least one target id');
452
+ if (ids === undefined || ids.length === 0) throw coded('no-targets', 'clean needs at least one target id');
417
453
  const dryRun = args.confirm !== true;
418
454
  const results = await clean(ids, { dryRun, drive: readDrive(args.drive) });
419
- if (!dryRun) invalidateAudit();
455
+ if (!dryRun) {
456
+ invalidateAudit();
457
+ appendRun({
458
+ at: new Date().toISOString(),
459
+ bytes: results.reduce((sum, item) => sum + (item.bytes || 0), 0),
460
+ files: results.reduce((sum, item) => sum + (item.removed || 0), 0),
461
+ failed: results.reduce((sum, item) => sum + (item.failed || 0), 0),
462
+ truncated: results.some((item) => item.truncated === true),
463
+ ids: results.map((item) => item.id),
464
+ source: 'agent',
465
+ results,
466
+ });
467
+ }
420
468
  const freed = results.reduce((sum, item) => sum + item.bytes, 0);
421
469
  const files = results.reduce((sum, item) => sum + item.removed, 0);
422
470
  const failed = results.reduce((sum, item) => sum + item.failed, 0);
@@ -426,7 +474,7 @@ export function apply(ctx) {
426
474
  : `Freed ${formatBytes(freed)} across ${files} file(s).`;
427
475
  return `${head}\n${detail}\nLocked or denied entries skipped: ${failed}`;
428
476
  }
429
- throw new Error(`unknown action: ${action}`);
477
+ throw coded('unknown-action', `unknown action: ${action}`);
430
478
  },
431
479
  }),
432
480
  'dsh-disk-cleaner: tool',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-disk-cleaner",
3
- "version": "1.6.4",
3
+ "version": "1.6.6",
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": [
@@ -23,6 +23,7 @@
23
23
  "lib/catalog.js",
24
24
  "lib/audit.js",
25
25
  "lib/plan.js",
26
+ "lib/history.js",
26
27
  "cordis.patch.yml",
27
28
  "locale/*.json",
28
29
  "icon.svg",
package/smoke.mjs CHANGED
@@ -229,6 +229,7 @@ const index = read('lib/index.js');
229
229
  const catalog = read('lib/catalog.js');
230
230
  const plan = read('lib/plan.js');
231
231
  const auditSrc = read('lib/audit.js');
232
+ const historySrc = read('lib/history.js');
232
233
 
233
234
  const { apply } = await import(pathToFileURL(path.join(here, 'lib/index.js')).href);
234
235
  check('host exports apply()', typeof apply === 'function');
@@ -264,7 +265,7 @@ check('tool needs no id for overview', JSON.stringify(definition.parameters.requ
264
265
  check('tool description warns audit is read-only', /read-only: it never removes anything/.test(definition.description));
265
266
  check('every effect is labelled', applied.every((label) => typeof label === 'string' && label.startsWith('dsh-disk-cleaner:')));
266
267
  check('tool output schema matches its string reply', definition.output.schema.type === 'string' && typeof (await definition.execute({ action: 'overview' })) === 'string');
267
- check('the read-only agent clean keeps the audit cache in sync', /if \(!dryRun\) invalidateAudit\(\);[\s\S]*const freed = results\.reduce/.test(index));
268
+ check('the read-only agent clean keeps the audit cache in sync', /if \(!dryRun\) \{[\s\S]*?invalidateAudit\(\);[\s\S]*?const freed = results\.reduce/.test(index));
268
269
  check('orphan target descriptions state the real 30-day rule, not four signals / two years',
269
270
  /30 days/.test(catalog) && /30 days/.test(index) && /QQGuild data folder/.test(source) &&
270
271
  !/four independent signals|over two years|no Start-menu shortcut|two years/.test(catalog + index + source));
@@ -412,7 +413,106 @@ check(
412
413
  source.includes('QQ') ,
413
414
  'it only ever measures the QQGuild folder',
414
415
  );
415
- check('the version badge and package agree', pkg.version === '1.6.4');
416
+ check('the version badge and package agree', pkg.version === '1.6.6');
417
+
418
+ check(
419
+ 'a dry run is one click away and never sends confirm',
420
+ /const runClean = async \(dry\) => \{/.test(source) &&
421
+ /const body = isDry \? \{ ids \} : \{ ids, confirm: true \};/.test(source) &&
422
+ /onClick: \(\) => runClean\(true\)/.test(source) &&
423
+ /onClick: \(\) => runClean\(false\)/.test(source),
424
+ 'the panel must be able to ask what a delete would free before doing it',
425
+ );
426
+
427
+ check(
428
+ 'a dry run never claims it freed anything',
429
+ /if \(isDry\) \{[\s\S]{0,320}t\('dryRunHint'\)[\s\S]{0,320}return;/.test(source) &&
430
+ /dryRunHint: '/.test(source),
431
+ 'the dry-run banner must say nothing was deleted',
432
+ );
433
+
434
+ check(
435
+ 'the notice a clean sets survives the rescan that follows it',
436
+ /await scan\(\{ keepNotice: true \}\);/.test(source) &&
437
+ /if \(!keepNotice\) setNotice\(null\);/.test(source),
438
+ 'rescanning used to erase the result the user just asked for',
439
+ );
440
+
441
+ check(
442
+ 'plan steps name targets in words, never catalog ids',
443
+ !source.includes("(step.ids || []).join(', ')") &&
444
+ source.includes('(step.ids || []).map((id) => targetName(t, id))'),
445
+ 'the panel must not print npm-cache, chrome-cache, pip-cache at the user',
446
+ );
447
+
448
+ check(
449
+ 'an empty plan says so instead of rendering nothing',
450
+ /if \(plan.steps.length === 0\)/.test(source) &&
451
+ /planEmpty: '/.test(source) &&
452
+ /t\('planEmpty'\)/.test(source),
453
+ 'nothing worth doing is a conclusion, not a blank',
454
+ );
455
+
456
+ check(
457
+ 'route errors carry a stable code the panel can translate',
458
+ /const coded = \(code, message\) => \{/.test(index) &&
459
+ /coded\('unknown-target'/.test(index) &&
460
+ /coded\('no-targets'/.test(index) &&
461
+ /code: typeof error\?\.code === 'string' \? error\.code : 'internal'/.test(index) &&
462
+ /function describeError\(t, err\) \{/.test(source) &&
463
+ /if \(data && typeof data\.code === 'string'\) error\.code = data\.code;/.test(source),
464
+ 'a Chinese panel must not print "origin rejected" or "unknown target: x"',
465
+ );
466
+
467
+ check(
468
+ 'every route error code has both a Chinese and an English sentence',
469
+ ['method-not-allowed', 'origin-rejected', 'bad-ids', 'too-many-ids', 'unknown-target', 'unknown-drive', 'drive-not-mounted', 'no-targets', 'unknown-action', 'internal'].every(
470
+ (code) => source.includes(`'err_${code}': `),
471
+ ),
472
+ 'an untranslated code falls back to the English message',
473
+ );
474
+
475
+ check(
476
+ 'a real clean writes one history line and the panel can read it back',
477
+ /import \{ appendRun, readRuns \} from '\.\/history\.js';/.test(index) &&
478
+ /if \(action === 'history'\) return \{ runs: readRuns\(50\) \};/.test(index) &&
479
+ /appendRun\(\{/.test(index) &&
480
+ /export function appendRun/.test(historySrc) &&
481
+ /export function readRuns/.test(historySrc) &&
482
+ /historyTitle: '/.test(source) &&
483
+ /function History\(\{ t \}\)/.test(source) &&
484
+ /h\(History, \{ t \}\)/.test(source),
485
+ 'history must survive the tab, and be exportable',
486
+ );
487
+
488
+ check(
489
+ 'history never throws into the clean path',
490
+ /try \{/.test(historySrc) &&
491
+ /catch/.test(historySrc) &&
492
+ !/throw /.test(historySrc),
493
+ 'the bytes are already gone; failing to log them must not look like a failed clean',
494
+ );
495
+
496
+ check(
497
+ 'scan bounds its own concurrency instead of walking every target at once',
498
+ /const SCAN_CONCURRENCY = \d+;/.test(catalog) &&
499
+ /const worker = async \(\) => \{/.test(catalog) &&
500
+ !/await Promise\.all\(targets\.map/.test(catalog),
501
+ '32 volumes at once stalls a spinning disk and the antivirus',
502
+ );
503
+
504
+ check(
505
+ 'a system target under SystemRoot is read from SystemRoot, not from C:',
506
+ (catalog.match(/env\('SystemRoot'\)/g) ?? []).length === 2 &&
507
+ !/C:\\+Windows\\+Temp/.test(catalog),
508
+ 'the boundary check already trusted SystemRoot; the paths have to agree',
509
+ );
510
+
511
+ check(
512
+ 'a request without an Origin is refused',
513
+ /if \(typeof origin !== 'string' \|\| typeof host !== 'string'\) return false;/.test(index),
514
+ 'any local process could otherwise drive a destructive route',
515
+ );
416
516
 
417
517
  check(
418
518
  'the panel carries dsh-design-baseline (full mode: css inlined, root tagged, tokens only)',