dsh-disk-cleaner 1.6.8 → 1.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/lib/client.js +167 -36
- package/package.json +1 -1
- package/smoke.mjs +45 -8
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<!-- badges -->
|
|
6
6
|
| | |
|
|
7
7
|
| --- | --- |
|
|
8
|
-
| **version** | `1.6.
|
|
8
|
+
| **version** | `1.6.9` |
|
|
9
9
|
| **DSH** | `0.2.1-rc.5`(开发与实测版本;用到 `plugins.bundle.config`、`settings.section`) |
|
|
10
10
|
| **license** | [MIT](LICENSE) |
|
|
11
11
|
| **Node** | `>= 22`(仅运行冒烟测试需要) |
|
|
@@ -166,16 +166,16 @@ These are enforced in `lib/catalog.js` and must not be relaxed:
|
|
|
166
166
|
|
|
167
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.
|
|
169
|
+
Published on npm as [`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner) — latest `1.6.9`:
|
|
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.
|
|
175
|
+
A prebuilt tarball is attached to the [v1.6.9 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.9); 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.
|
|
178
|
+
dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.9/dsh-disk-cleaner-1.6.9.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.
|
|
186
|
+
"dsh-disk-cleaner": "^1.6.9"
|
|
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 #
|
|
207
|
+
node smoke.mjs # 114 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
|
|
|
@@ -233,6 +233,7 @@ What the smoke test deliberately does **not** cover: anything that walks a real
|
|
|
233
233
|
| 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 |
|
|
234
234
|
| 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 |
|
|
235
235
|
| Visual layout | Checked by hand in the running Web UI. No browser automation here, so this one is a human check, not a test. |
|
|
236
|
+
| The confirmation follows the panel theme | `node smoke.mjs` regression assertion: both confirm sites render `ConfirmDialog`, `window.confirm` appears nowhere, and the dialog is styled with the baseline tokens |
|
|
236
237
|
| Agent tool returns a string matching its output schema | `node smoke.mjs` regression assertion (the 1.6.1 fix) |
|
|
237
238
|
| A real agent-tool delete invalidates the assessment cache | `node smoke.mjs` regression assertion (the 1.6.1 fix) |
|
|
238
239
|
|
|
@@ -258,23 +259,24 @@ What the smoke test deliberately does **not** cover: anything that walks a real
|
|
|
258
259
|
```
|
|
259
260
|
├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
|
|
260
261
|
├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
|
|
261
|
-
├─ lib/index.js (
|
|
262
|
+
├─ lib/index.js (525) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
|
|
262
263
|
├─ lib/catalog.js (1242) The 32 targets, measurePath / purgePath / resolveTargets / drives
|
|
263
264
|
├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
|
|
264
265
|
├─ lib/plan.js (150) Pure: measurements → ordered steps
|
|
265
266
|
├─ lib/history.js (71) Append-only run log in $DSH_HOME/dsh-disk-cleaner; never throws
|
|
266
|
-
├─ lib/client.js (
|
|
267
|
+
├─ lib/client.js (1939) Client half: slots, panel, preferences, zh/en dictionaries, CSS
|
|
267
268
|
├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
|
|
268
269
|
├─ icon.svg Plugin icon
|
|
269
270
|
├─ README.md This file
|
|
270
271
|
├─ LICENSE MIT
|
|
271
|
-
└─ smoke.mjs
|
|
272
|
+
└─ smoke.mjs 114 assertions, zero dependencies
|
|
272
273
|
```
|
|
273
274
|
|
|
274
275
|
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).
|
|
275
276
|
|
|
276
277
|
### What's new
|
|
277
278
|
|
|
279
|
+
- **1.6.9** — The one white box left in a dark panel is gone. The confirmation was `window.confirm`, which the browser draws outside the document: it cannot see `color-scheme`, the baseline tokens or the host theme, so a dark panel got a light rectangle in the middle of it. It is now a dialog the panel draws itself — same information (how many, how much, every target with its size, anything irreversible), painted with `--dsb-*`, dismissible with Escape, the overlay or Cancel, and the destructive button deliberately holds no focus so a stray Enter cannot delete. The plan-step confirmation goes through the same component. No behaviour change beyond the painting.
|
|
278
280
|
- **1.6.8** — Fixes the other half of the 1.6.7 story: the panel still answered "origin rejected" when the desktop window sent `Origin: http://localhost:<port>` while the request itself arrived at `127.0.0.1`, because the check compared those strings literally. The gate now decides on the **peer address** of the connection — something a remote page cannot influence — and only then looks at `Origin`: the app's own scheme, no `Origin` at all, or any loopback host name is accepted, everything else is still refused. An `https://evil.example` origin is rejected even from a loopback socket. The three assessment error paths (evaluate, reveal, run-a-plan-step) now go through the same translated-code path as the rest of the panel, so a Chinese user sees "请求来源不被接受" rather than the raw `origin rejected` string.
|
|
279
281
|
- **1.6.7** — Fixes a regression 1.6.6 shipped: refusing every request without an `Origin` header locked the plugin's own panel out of its API ("请求来源不被接受"), because the desktop window posts from `dsh-app://app`, not from `http://127.0.0.1:<port>`. The check now accepts the desktop scheme and a header the desktop forwarder stripped, but only when the request already arrived on a loopback address; a plain web page still cannot reach a loopback port without sending an `Origin`, so the door stays shut. No other behaviour changed.
|
|
280
282
|
- **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 unless it arrived on loopback (see 1.6.7), and a refused directory removal is counted separately from a legitimately full one.
|
|
@@ -441,16 +443,16 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
441
443
|
|
|
442
444
|
已收录进精选目录 [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** 即可一键安装。下面的直接安装方式就是手动做同一件事。
|
|
443
445
|
|
|
444
|
-
已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner),最新版 `1.6.
|
|
446
|
+
已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner),最新版 `1.6.9`。
|
|
445
447
|
|
|
446
448
|
```bash
|
|
447
449
|
npm i dsh-disk-cleaner
|
|
448
450
|
```
|
|
449
451
|
|
|
450
|
-
[v1.6.
|
|
452
|
+
[v1.6.9 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.9) 还附带一个预构建 tarball,从它安装可跳过 DSH 的构建授权步骤:
|
|
451
453
|
|
|
452
454
|
```bash
|
|
453
|
-
dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.
|
|
455
|
+
dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.9/dsh-disk-cleaner-1.6.9.tgz
|
|
454
456
|
```
|
|
455
457
|
|
|
456
458
|
然后在 profile 里启用 —— 在 `$DSH_PROFILE_DIR/package.json`:
|
|
@@ -458,7 +460,7 @@ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner
|
|
|
458
460
|
```json
|
|
459
461
|
{
|
|
460
462
|
"dependencies": {
|
|
461
|
-
"dsh-disk-cleaner": "^1.6.
|
|
463
|
+
"dsh-disk-cleaner": "^1.6.9"
|
|
462
464
|
},
|
|
463
465
|
"dsh": {
|
|
464
466
|
"profile": {
|
|
@@ -479,7 +481,7 @@ bundle 自带的 `cordis.patch.yml` 会插入插件行,无需改 profile 的 p
|
|
|
479
481
|
### 开发
|
|
480
482
|
|
|
481
483
|
```bash
|
|
482
|
-
node smoke.mjs #
|
|
484
|
+
node smoke.mjs # 114 条断言,零依赖
|
|
483
485
|
node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
|
|
484
486
|
```
|
|
485
487
|
|
|
@@ -508,6 +510,7 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
|
|
|
508
510
|
| 预演报告的字节数等于真删能释放的字节数 | 30 天的回滚窗口报 263 KB 而不是 283 MB,且扫描结果与预演一致 |
|
|
509
511
|
| 分类与折叠 | 从 bundle 里取出真正的 `groupRows` 跑一组混合数据:顺序固定、按类合计、未知类别落到「其他」、空的类别是收起来的那个。点某一类的标题只折叠这一类,折叠里的行不参与批量选择 |
|
|
510
512
|
| 视觉排版 | 在运行中的 Web UI 里手工确认过。本环境没有浏览器自动化,所以这条是人眼检查,不是测试。 |
|
|
513
|
+
| 确认框跟随面板主题 | `node smoke.mjs` 回归断言:两处确认都渲染 `ConfirmDialog`、代码里不再出现 `window.confirm`、弹层只用基线令牌上色 |
|
|
511
514
|
| agent 工具返回字符串且与 output.schema 一致 | `node smoke.mjs` 回归断言(1.6.1 修复) |
|
|
512
515
|
| agent 工具真删后评估缓存失效 | `node smoke.mjs` 回归断言(1.6.1 修复) |
|
|
513
516
|
|
|
@@ -533,23 +536,24 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
|
|
|
533
536
|
```
|
|
534
537
|
├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
|
|
535
538
|
├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
|
|
536
|
-
├─ lib/index.js (
|
|
539
|
+
├─ lib/index.js (525) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
|
|
537
540
|
├─ lib/catalog.js (1242) 32 个目标,measurePath / purgePath / resolveTargets / drives
|
|
538
541
|
├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
|
|
539
542
|
├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
|
|
540
543
|
├─ lib/history.js (71) 只追加的运行日志,落在 $DSH_HOME/dsh-disk-cleaner;永不抛错
|
|
541
|
-
├─ lib/client.js (
|
|
544
|
+
├─ lib/client.js (1939) Client 半面:slot、面板、偏好、zh/en 字典、CSS
|
|
542
545
|
├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
|
|
543
546
|
├─ icon.svg 插件图标
|
|
544
547
|
├─ README.md 本文件
|
|
545
548
|
├─ LICENSE MIT
|
|
546
|
-
└─ smoke.mjs
|
|
549
|
+
└─ smoke.mjs 114 条断言,零依赖
|
|
547
550
|
```
|
|
548
551
|
|
|
549
552
|
Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
|
|
550
553
|
|
|
551
554
|
### 更新记录
|
|
552
555
|
|
|
556
|
+
- **1.6.9** — 深色面板里最后一块白框没了。确认框原来是 `window.confirm`,它由浏览器画在文档之外:读不到 `color-scheme`、读不到基线令牌、也不跟宿主主题,于是深色面板中间出现一个浅色矩形。现在改成面板自己画的弹层:信息一条不少(几项、多少字节、每个目标的名称与大小、哪些不可恢复),用 `--dsb-*` 上色,Escape、点遮罩、点取消都能退出,危险按钮刻意不自动聚焦,免得顺手一个回车就删了。计划步骤的确认走同一个组件。除绘制方式外没有行为变化。
|
|
553
557
|
- **1.6.8** — 补上 1.6.7 的另一半:桌面窗口如果发的是 `Origin: http://localhost:<端口>`、而请求实际到达 `127.0.0.1`,1.6.7 仍然会报「origin rejected」——因为它逐字比较这两个字符串。现在判定改为看**连接的来源地址**(远端网页无法影响这个值),再看 `Origin`:桌面自有协议、压根没有 `Origin`、或任何 loopback 主机名都放行,其余照旧拒绝;即便是 loopback 连接,`https://evil.example` 也进不来。空间评估的三条错误路径(评估、打开位置、跑计划步骤)统一走面板已有的「稳定 code → 中文文案」通道,不会再直接显示原始英文串。
|
|
554
558
|
- **1.6.7** — 修掉 1.6.6 带进来的一个回归:把「没有 Origin 头的请求」一律拒绝,连插件自己的面板都进不了接口(界面报「请求来源不被接受」)——桌面窗口发请求时 Origin 是 `dsh-app://app`,不是 `http://127.0.0.1:<端口>`。现在的判定接受桌面协议,也接受被桌面转发层剥掉的头,但前提是请求本身已经到达 loopback 地址;普通网页不发 Origin 就到不了 loopback 端口,所以门仍然是关的。其余行为没变。
|
|
555
559
|
- **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` 头的请求会被拒绝(这项判定在 1.6.7 收紧到只放行 loopback,见下),目录删除被拒与非空目录被跳过分别计数。
|
package/lib/client.js
CHANGED
|
@@ -99,8 +99,11 @@ window.__ModuleLoader__.load({
|
|
|
99
99
|
scanError: '扫描失败',
|
|
100
100
|
cleanError: '清理失败',
|
|
101
101
|
cleanConfirm: '删除选中缓存目录里的文件?正在使用的文件会被跳过。',
|
|
102
|
-
cleanConfirmList: '即将清理 {n} 项,合计 {bytes}
|
|
103
|
-
cleanConfirmIrreversible: '
|
|
102
|
+
cleanConfirmList: '即将清理 {n} 项,合计 {bytes}:',
|
|
103
|
+
cleanConfirmIrreversible: '其中「回收站」清空后无法恢复。',
|
|
104
|
+
confirmOk: '确认清理',
|
|
105
|
+
confirmCancel: '取消',
|
|
106
|
+
confirmTitle: '清理前确认',
|
|
104
107
|
truncatedHint: '这个目录太大,只统计/清理了前 {n} 条,实际释放可能少于这个数。',
|
|
105
108
|
dryRunHint: '预演完成:没有删除任何文件。',
|
|
106
109
|
wouldFree: '可释放',
|
|
@@ -301,8 +304,11 @@ window.__ModuleLoader__.load({
|
|
|
301
304
|
scanError: 'Scan failed',
|
|
302
305
|
cleanError: 'Clean failed',
|
|
303
306
|
cleanConfirm: 'Delete the files in the selected cache directories? Files in use are skipped.',
|
|
304
|
-
cleanConfirmList: 'About to clean {n} target(s), {bytes} in total
|
|
305
|
-
cleanConfirmIrreversible: '
|
|
307
|
+
cleanConfirmList: 'About to clean {n} target(s), {bytes} in total:',
|
|
308
|
+
cleanConfirmIrreversible: 'Emptying the Recycle Bin cannot be undone.',
|
|
309
|
+
confirmOk: 'Clean these',
|
|
310
|
+
confirmCancel: 'Cancel',
|
|
311
|
+
confirmTitle: 'Confirm before cleaning',
|
|
306
312
|
truncatedHint: 'This folder is too large: only the first {n} entries were measured or removed, so less may be freed.',
|
|
307
313
|
dryRunHint: 'Dry run finished: nothing was deleted.',
|
|
308
314
|
wouldFree: 'Could free',
|
|
@@ -547,6 +553,17 @@ window.__ModuleLoader__.load({
|
|
|
547
553
|
.dsc-audit-label { font-weight: 600; }
|
|
548
554
|
.dsc-audit-why { color: var(--dsb-text-secondary); font-size: 11px; line-height: 16px; }
|
|
549
555
|
.dsc-audit-size { flex: none; min-width: 72px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
556
|
+
.dsc-overlay { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 24px; background: color-mix(in srgb, var(--dsb-text) 42%, transparent); }
|
|
557
|
+
.dsc-dialog { box-sizing: border-box; width: min(520px, 100%); max-height: min(78vh, 720px); display: flex; flex-direction: column; background: var(--dsb-surface); color: var(--dsb-text); border: 1px solid var(--dsb-line-strong); border-radius: var(--dsb-radius); box-shadow: 0 18px 48px color-mix(in srgb, var(--dsb-text) 28%, transparent); }
|
|
558
|
+
.dsc-dialog-head { display: flex; align-items: center; gap: 8px; padding: 14px 16px 10px; border-bottom: 1px solid var(--dsb-line-strong); }
|
|
559
|
+
.dsc-dialog-title { flex: 1; font-size: 14px; font-weight: 600; }
|
|
560
|
+
.dsc-dialog-x { width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: var(--dsb-radius-sm); background: transparent; color: var(--dsb-text-secondary); font-size: 18px; line-height: 1; cursor: pointer; }
|
|
561
|
+
.dsc-dialog-x:hover { background: var(--dsb-hover); color: var(--dsb-text); }
|
|
562
|
+
.dsc-dialog-body { padding: 12px 16px; overflow-y: auto; }
|
|
563
|
+
.dsc-dialog-lead { margin: 0 0 8px; font-size: 13px; line-height: 1.6; }
|
|
564
|
+
.dsc-dialog-list { margin: 0; padding: 0 0 0 2px; list-style: none; font-size: 12px; line-height: 1.9; color: var(--dsb-text-secondary); }
|
|
565
|
+
.dsc-dialog-warn { margin: 10px 0 0; font-size: 12px; line-height: 1.6; color: var(--dsb-accent-error); }
|
|
566
|
+
.dsc-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px 14px; border-top: 1px solid var(--dsb-line-strong); }
|
|
550
567
|
.dsc-settings { display: flex; flex-direction: column; gap: 12px; }
|
|
551
568
|
.dsc-settings .dsc-card { margin: 0; }
|
|
552
569
|
.dsc-embedded { border-top: 1px solid var(--dsb-line-strong); padding-top: 12px; }
|
|
@@ -818,6 +835,74 @@ window.__ModuleLoader__.load({
|
|
|
818
835
|
);
|
|
819
836
|
}
|
|
820
837
|
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* A confirmation the page draws, not the platform.
|
|
841
|
+
*
|
|
842
|
+
* `window.confirm` renders outside the document: it cannot see `color-scheme`, the baseline
|
|
843
|
+
* tokens or the host's dark theme, so a dark panel got a white box in the middle of it. This
|
|
844
|
+
* is the same information — how many, how much, which targets, what cannot be undone — drawn
|
|
845
|
+
* with the panel's own tokens. Escape cancels; the confirm button holds no focus by default,
|
|
846
|
+
* so a stray Enter cannot delete anything.
|
|
847
|
+
*/
|
|
848
|
+
function ConfirmDialog({ t, count, bytes, list, irreversible, onYes, onNo }) {
|
|
849
|
+
useEffect(() => {
|
|
850
|
+
const onKey = (event) => {
|
|
851
|
+
if (event.key === 'Escape') {
|
|
852
|
+
event.preventDefault();
|
|
853
|
+
onNo();
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
window.addEventListener('keydown', onKey);
|
|
857
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
858
|
+
}, [onNo]);
|
|
859
|
+
return h(
|
|
860
|
+
'div',
|
|
861
|
+
{ className: 'dsc-overlay', role: 'presentation', onClick: onNo },
|
|
862
|
+
h(
|
|
863
|
+
'div',
|
|
864
|
+
{
|
|
865
|
+
className: 'dsc-dialog',
|
|
866
|
+
role: 'dialog',
|
|
867
|
+
'aria-modal': 'true',
|
|
868
|
+
'aria-label': t('confirmTitle'),
|
|
869
|
+
onClick: (event) => event.stopPropagation(),
|
|
870
|
+
},
|
|
871
|
+
h(
|
|
872
|
+
'div',
|
|
873
|
+
{ className: 'dsc-dialog-head' },
|
|
874
|
+
h('span', { className: 'dsc-dialog-title' }, t('confirmTitle')),
|
|
875
|
+
h(
|
|
876
|
+
'button',
|
|
877
|
+
{ type: 'button', className: 'dsc-dialog-x', onClick: onNo, 'aria-label': t('confirmCancel') },
|
|
878
|
+
'\u00d7',
|
|
879
|
+
),
|
|
880
|
+
),
|
|
881
|
+
h(
|
|
882
|
+
'div',
|
|
883
|
+
{ className: 'dsc-dialog-body' },
|
|
884
|
+
h(
|
|
885
|
+
'p',
|
|
886
|
+
{ className: 'dsc-dialog-lead' },
|
|
887
|
+
t('cleanConfirmList').replace('{n}', String(count)).replace('{bytes}', formatBytes(bytes)),
|
|
888
|
+
),
|
|
889
|
+
h(
|
|
890
|
+
'ul',
|
|
891
|
+
{ className: 'dsc-dialog-list' },
|
|
892
|
+
list.map((line, index) => h('li', { key: `${index}/${line}` }, line)),
|
|
893
|
+
),
|
|
894
|
+
irreversible ? h('p', { className: 'dsc-dialog-warn' }, t('cleanConfirmIrreversible')) : null,
|
|
895
|
+
),
|
|
896
|
+
h(
|
|
897
|
+
'div',
|
|
898
|
+
{ className: 'dsc-dialog-actions' },
|
|
899
|
+
h('button', { type: 'button', className: 'dsc-btn', onClick: onNo }, t('confirmCancel')),
|
|
900
|
+
h('button', { type: 'button', className: 'dsc-btn dsc-btn-danger', onClick: onYes }, t('confirmOk')),
|
|
901
|
+
),
|
|
902
|
+
),
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
821
906
|
function Cleaner({ locale, embedded }) {
|
|
822
907
|
const t = useTranslate(locale);
|
|
823
908
|
// Settings live behind a header button, the way dsh-context does it: one obvious
|
|
@@ -829,6 +914,10 @@ window.__ModuleLoader__.load({
|
|
|
829
914
|
const [rows, setRows] = usePersistent('rows', null);
|
|
830
915
|
const [picked, setPicked] = usePersistent('picked', () => new Set());
|
|
831
916
|
const [busy, setBusy] = useState('');
|
|
917
|
+
// A delete that is waiting for the user to say yes. Not window.confirm: that dialog is
|
|
918
|
+
// drawn by Chromium outside the page and ignores the theme, so it shows up white in a
|
|
919
|
+
// dark panel. `pending` holds the exact work that runs on confirm.
|
|
920
|
+
const [pending, setPending] = useState(null);
|
|
832
921
|
const [notice, setNotice] = usePersistent('notice', null);
|
|
833
922
|
const [error, setError] = usePersistent('error', '');
|
|
834
923
|
|
|
@@ -909,24 +998,8 @@ window.__ModuleLoader__.load({
|
|
|
909
998
|
* is the number the delete would achieve. It never asks for confirmation: it deletes
|
|
910
999
|
* nothing. A real run keeps asking.
|
|
911
1000
|
*/
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
if (!rows || picked.size === 0) return;
|
|
915
|
-
const chosen = shown.filter((row) => picked.has(row.id));
|
|
916
|
-
if (!isDry && confirmFirst.get() === 'always') {
|
|
917
|
-
// A confirm box that does not say what it deletes is not a confirmation. Name the
|
|
918
|
-
// targets and the total, and call out anything that cannot be undone.
|
|
919
|
-
const bytes = chosen.reduce((sum, row) => sum + (row.bytes || 0), 0);
|
|
920
|
-
const list = chosen.map((row) => `· ${targetName(t, row.id)} — ${formatBytes(row.bytes)}`).join('\n');
|
|
921
|
-
const irreversible = chosen.some((row) => IRREVERSIBLE.has(row.id));
|
|
922
|
-
const text =
|
|
923
|
-
t('cleanConfirmList')
|
|
924
|
-
.replace('{n}', String(chosen.length))
|
|
925
|
-
.replace('{bytes}', formatBytes(bytes))
|
|
926
|
-
.replace('{list}', list) + (irreversible ? t('cleanConfirmIrreversible') : '');
|
|
927
|
-
if (!window.confirm(text)) return;
|
|
928
|
-
}
|
|
929
|
-
const ids = chosen.map((row) => row.id);
|
|
1001
|
+
/** The delete itself, after any confirmation has been answered. */
|
|
1002
|
+
const cleanNow = async (ids, isDry) => {
|
|
930
1003
|
setBusy('clean');
|
|
931
1004
|
setError('');
|
|
932
1005
|
setNotice(null);
|
|
@@ -960,6 +1033,29 @@ window.__ModuleLoader__.load({
|
|
|
960
1033
|
}
|
|
961
1034
|
};
|
|
962
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* A confirm box that does not say what it deletes is not a confirmation, so the dialog names
|
|
1038
|
+
* every target with its size and calls out anything that cannot be undone. A dry run asks
|
|
1039
|
+
* nothing: it deletes nothing.
|
|
1040
|
+
*/
|
|
1041
|
+
const runClean = (dry) => {
|
|
1042
|
+
const isDry = dry === true;
|
|
1043
|
+
if (!rows || picked.size === 0) return;
|
|
1044
|
+
const chosen = shown.filter((row) => picked.has(row.id));
|
|
1045
|
+
const ids = chosen.map((row) => row.id);
|
|
1046
|
+
if (isDry || confirmFirst.get() !== 'always') {
|
|
1047
|
+
void cleanNow(ids, isDry);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
setPending({
|
|
1051
|
+
count: chosen.length,
|
|
1052
|
+
bytes: chosen.reduce((sum, row) => sum + (row.bytes || 0), 0),
|
|
1053
|
+
list: chosen.map((row) => `${targetName(t, row.id)} — ${formatBytes(row.bytes)}`),
|
|
1054
|
+
irreversible: chosen.some((row) => IRREVERSIBLE.has(row.id)),
|
|
1055
|
+
run: () => void cleanNow(ids, false),
|
|
1056
|
+
});
|
|
1057
|
+
};
|
|
1058
|
+
|
|
963
1059
|
const shown = rows && drive !== '' ? rows.filter((row) => row.pathCount > 0) : rows;
|
|
964
1060
|
|
|
965
1061
|
// Only what the user toggled is stored; every other section falls back to "folded
|
|
@@ -1188,6 +1284,21 @@ window.__ModuleLoader__.load({
|
|
|
1188
1284
|
),
|
|
1189
1285
|
),
|
|
1190
1286
|
),
|
|
1287
|
+
pending
|
|
1288
|
+
? h(ConfirmDialog, {
|
|
1289
|
+
t,
|
|
1290
|
+
count: pending.count,
|
|
1291
|
+
bytes: pending.bytes,
|
|
1292
|
+
list: pending.list,
|
|
1293
|
+
irreversible: pending.irreversible,
|
|
1294
|
+
onNo: () => setPending(null),
|
|
1295
|
+
onYes: () => {
|
|
1296
|
+
const action = pending;
|
|
1297
|
+
setPending(null);
|
|
1298
|
+
action.run();
|
|
1299
|
+
},
|
|
1300
|
+
})
|
|
1301
|
+
: null,
|
|
1191
1302
|
h(Audit, { t }),
|
|
1192
1303
|
h(History, { t }),
|
|
1193
1304
|
],
|
|
@@ -1390,6 +1501,9 @@ window.__ModuleLoader__.load({
|
|
|
1390
1501
|
// Where the last click opened: 24px of feedback beats a silent no-op on Windows,
|
|
1391
1502
|
// which sometimes takes a second to raise the Explorer window.
|
|
1392
1503
|
const [opened, setOpened] = useState('');
|
|
1504
|
+
// A plan step waiting for a yes. Same dialog as the main button: a plan step is still a
|
|
1505
|
+
// delete, and `reclaim-caution` can hold a target that cannot be undone.
|
|
1506
|
+
const [pending, setPending] = useState(null);
|
|
1393
1507
|
|
|
1394
1508
|
const run = useCallback(async (force) => {
|
|
1395
1509
|
setBusy(true);
|
|
@@ -1451,20 +1565,7 @@ window.__ModuleLoader__.load({
|
|
|
1451
1565
|
};
|
|
1452
1566
|
|
|
1453
1567
|
/** Run a plan step the plugin is allowed to run, then re-measure so the plan is honest. */
|
|
1454
|
-
const
|
|
1455
|
-
if (confirmFirst.get() === 'always') {
|
|
1456
|
-
// The same confirmation as the main button: a plan step is still a delete, and
|
|
1457
|
-
// `reclaim-caution` can hold an irreversible target.
|
|
1458
|
-
const ids = Array.isArray(step.ids) ? step.ids : [];
|
|
1459
|
-
const list = ids.map((id) => `· ${targetName(t, id)}`).join('\n');
|
|
1460
|
-
const irreversible = ids.some((id) => IRREVERSIBLE.has(id));
|
|
1461
|
-
const text =
|
|
1462
|
-
t('cleanConfirmList')
|
|
1463
|
-
.replace('{n}', String(ids.length))
|
|
1464
|
-
.replace('{bytes}', formatBytes(step.bytes || 0))
|
|
1465
|
-
.replace('{list}', list) + (irreversible ? t('cleanConfirmIrreversible') : '');
|
|
1466
|
-
if (!window.confirm(text)) return;
|
|
1467
|
-
}
|
|
1568
|
+
const cleanStep = async (step) => {
|
|
1468
1569
|
setBusy(true);
|
|
1469
1570
|
setError('');
|
|
1470
1571
|
try {
|
|
@@ -1477,6 +1578,21 @@ window.__ModuleLoader__.load({
|
|
|
1477
1578
|
setBusy(false);
|
|
1478
1579
|
}
|
|
1479
1580
|
};
|
|
1581
|
+
|
|
1582
|
+
const runStep = (step) => {
|
|
1583
|
+
if (confirmFirst.get() !== 'always') {
|
|
1584
|
+
void cleanStep(step);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
const ids = Array.isArray(step.ids) ? step.ids : [];
|
|
1588
|
+
setPending({
|
|
1589
|
+
count: ids.length,
|
|
1590
|
+
bytes: step.bytes || 0,
|
|
1591
|
+
list: ids.map((id) => targetName(t, id)),
|
|
1592
|
+
irreversible: ids.some((id) => IRREVERSIBLE.has(id)),
|
|
1593
|
+
run: () => void cleanStep(step),
|
|
1594
|
+
});
|
|
1595
|
+
};
|
|
1480
1596
|
const GROUPS = [
|
|
1481
1597
|
['system', 'auditGroupSystem'],
|
|
1482
1598
|
['app', 'auditGroupApp'],
|
|
@@ -1556,6 +1672,21 @@ window.__ModuleLoader__.load({
|
|
|
1556
1672
|
)
|
|
1557
1673
|
: null,
|
|
1558
1674
|
h(Plan, { plan: data.plan, t, busy, onRan: runStep }),
|
|
1675
|
+
pending
|
|
1676
|
+
? h(ConfirmDialog, {
|
|
1677
|
+
t,
|
|
1678
|
+
count: pending.count,
|
|
1679
|
+
bytes: pending.bytes,
|
|
1680
|
+
list: pending.list,
|
|
1681
|
+
irreversible: pending.irreversible,
|
|
1682
|
+
onNo: () => setPending(null),
|
|
1683
|
+
onYes: () => {
|
|
1684
|
+
const action = pending;
|
|
1685
|
+
setPending(null);
|
|
1686
|
+
action.run();
|
|
1687
|
+
},
|
|
1688
|
+
})
|
|
1689
|
+
: null,
|
|
1559
1690
|
]
|
|
1560
1691
|
: null,
|
|
1561
1692
|
);
|
package/package.json
CHANGED
package/smoke.mjs
CHANGED
|
@@ -211,7 +211,7 @@ check('preselect choice persisted', storage.get('dsc.pick') === 'none');
|
|
|
211
211
|
check('confirm choice persisted', storage.get('dsc.confirm') === 'never');
|
|
212
212
|
|
|
213
213
|
check('scan honours the preselect preference', source.includes('const want = defaultPick.get();'));
|
|
214
|
-
check('clean honours the confirm preference', source.includes("confirmFirst.get()
|
|
214
|
+
check('clean honours the confirm preference', source.includes("confirmFirst.get() !== 'always'"));
|
|
215
215
|
check('the settings page reuses the same rows', /function SettingsOptions\(\{ t \}\)[\s\S]{0,400}h\(ConfigRows, \{ t \}\)/.test(source));
|
|
216
216
|
check('re-run asks the host to skip its cache', source.includes('onClick: () => run(true)') && source.includes("force === true ? { force: true } : {}"));
|
|
217
217
|
check('an assessment row opens its folder', source.includes("call('reveal'") && source.includes('dsc-audit-row dsc-openable'));
|
|
@@ -377,11 +377,14 @@ check(
|
|
|
377
377
|
(source.match(/!IRREVERSIBLE\.has\(row\.id\)/g) ?? []).length >= 2,
|
|
378
378
|
);
|
|
379
379
|
check(
|
|
380
|
-
'every confirm box names the targets and the total',
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
380
|
+
'every confirm box names the targets and the total, in a dialog the page draws',
|
|
381
|
+
source.includes("t('cleanConfirmList')") &&
|
|
382
|
+
source.includes("t('cleanConfirmIrreversible')") &&
|
|
383
|
+
source.includes('function ConfirmDialog(') &&
|
|
384
|
+
!/window\.confirm\(/.test(source) &&
|
|
385
|
+
(source.match(/h\(ConfirmDialog, \{/g) ?? []).length === 2,
|
|
386
|
+
'window.confirm is drawn by the browser outside the page, so it ignores the dark theme; ' +
|
|
387
|
+
'both the main button and a plan step must ask through the panel dialog',
|
|
385
388
|
);
|
|
386
389
|
check(
|
|
387
390
|
'a clean that left files behind is not reported as a green success',
|
|
@@ -413,17 +416,51 @@ check(
|
|
|
413
416
|
source.includes('QQ') ,
|
|
414
417
|
'it only ever measures the QQGuild folder',
|
|
415
418
|
);
|
|
416
|
-
check('the version badge and package agree', pkg.version === '1.6.
|
|
419
|
+
check('the version badge and package agree', pkg.version === '1.6.9');
|
|
417
420
|
|
|
418
421
|
check(
|
|
419
422
|
'a dry run is one click away and never sends confirm',
|
|
420
|
-
/const runClean =
|
|
423
|
+
/const runClean = \(dry\) => \{/.test(source) &&
|
|
421
424
|
/const body = isDry \? \{ ids \} : \{ ids, confirm: true \};/.test(source) &&
|
|
422
425
|
/onClick: \(\) => runClean\(true\)/.test(source) &&
|
|
423
426
|
/onClick: \(\) => runClean\(false\)/.test(source),
|
|
424
427
|
'the panel must be able to ask what a delete would free before doing it',
|
|
425
428
|
);
|
|
426
429
|
|
|
430
|
+
check(
|
|
431
|
+
'the dialog lists the targets itself, so the sentence holds no catalog ids or bullet text',
|
|
432
|
+
source.includes("className: 'dsc-dialog-list'") &&
|
|
433
|
+
source.includes("list.map((line, index) => h('li',") &&
|
|
434
|
+
!source.includes('{list}'),
|
|
435
|
+
'the template used to splice a newline-joined blob into one sentence; the list is markup now',
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
check(
|
|
439
|
+
'the confirmation is painted with the panel tokens, not by the platform',
|
|
440
|
+
source.includes('.dsc-overlay { position: fixed;') &&
|
|
441
|
+
source.includes('.dsc-dialog { box-sizing: border-box;') &&
|
|
442
|
+
/background: var\(--dsb-surface\)/.test(source) &&
|
|
443
|
+
/border: 1px solid var\(--dsb-line-strong\)/.test(source) &&
|
|
444
|
+
!/window\.confirm\(/.test(source),
|
|
445
|
+
'window.confirm ignores color-scheme and the baseline tokens, so a dark panel gets a white box',
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
check(
|
|
449
|
+
'the confirmation can be dismissed without deleting anything',
|
|
450
|
+
/if \(event\.key === 'Escape'\)/.test(source) &&
|
|
451
|
+
source.includes("className: 'dsc-overlay', role: 'presentation', onClick: onNo") &&
|
|
452
|
+
/className: 'dsc-btn dsc-btn-danger', onClick: onYes/.test(source),
|
|
453
|
+
'Escape, the overlay and the cancel button must all leave the files alone',
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
check(
|
|
457
|
+
'the destructive button does not take focus, so a stray Enter cannot delete',
|
|
458
|
+
source.includes('function ConfirmDialog(') &&
|
|
459
|
+
!/autoFocus/.test(source) &&
|
|
460
|
+
source.includes("role: 'dialog'") &&
|
|
461
|
+
source.includes("'aria-modal': 'true'"),
|
|
462
|
+
);
|
|
463
|
+
|
|
427
464
|
check(
|
|
428
465
|
'a dry run never claims it freed anything',
|
|
429
466
|
/if \(isDry\) \{[\s\S]{0,320}t\('dryRunHint'\)[\s\S]{0,320}return;/.test(source) &&
|