feat(cleaner): 将清理功能设为可选并校正确认提示

This commit is contained in:
ysicing committed 2026-09-28 13:42:21 +08:00
1 parent b618a06d2b
commit 6d1561c6bf
22 files changed
+163 -23

No files matched your search

@@ -185,8 +185,7 @@ public enum CleanEngine {
await onProgress?(currentProgress())
continue
}
// 废纸篓规则本身必须永久删除;其他可再生内容按用户偏好决定
let useTrash = scan.rule.id != RuleCatalog.trash.id && (scan.rule.policy == .trash || preferTrash)
let useTrash = !scan.rule.deletesPermanently(preferTrash: preferTrash)
for item in scan.items {
if Task.isCancelled { report.wasCancelled = true; return report }
@@ -111,6 +111,15 @@ public struct CleanRule: Sendable, Identifiable {
}
public var usesToolCleaner: Bool { cleanWithTool != nil }
/// 确认提示与清理执行共用的删除方式;工具清理和清空废纸篓始终不可恢复。
public func deletesPermanently(preferTrash: Bool) -> Bool {
if usesToolCleaner || id == RuleCatalog.trash.id { return true }
switch policy {
case .delete: return !preferTrash
case .trash: return false
}
}
}
public struct CleanItem: Sendable, Identifiable, Hashable {
@@ -1026,7 +1026,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P
继续运行 مواصلة التشغيل
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) أخضر: طبيعي؛ برتقالي: مرتفع (فوق 60%)؛ أحمر: مرتفع جدًا (فوق 85%)
缓存 الذاكرات المؤقتة
缓存与日志直接删除,下载内容移到废纸篓。 تُحذف الذاكرات المؤقتة والسجلات؛ وتُنقل التنزيلات إلى سلة المهملات.
缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。 ستُحذف الذاكرات المؤقتة والسجلات نهائيًا ولا يمكن استعادتها؛ وتُنقل التنزيلات إلى سلة المهملات.
缓存也先移到废纸篓 نقل الذاكرات المؤقتة إلى السلة أيضًا
编辑 تعديل
网站 الموقع
@@ -1568,3 +1568,5 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
已停止,已清理部分不会恢复 توقف التنظيف. لا يمكن استعادة العناصر التي نُظفت بالفعل.
扫描大小约 {} الحجم المفحوص: نحو {}
正在取消… جارٍ الإلغاء…
按需扫描缓存、日志与项目产物,清理前逐项确认。 افحص الذاكرات المؤقتة والسجلات ومخرجات المشاريع عند الحاجة، وراجع العناصر قبل تنظيفها.
“{}”只剩 {} 可用。请检查不再需要的文件。 لم يتبقَّ على «{}» سوى {}. راجع الملفات التي لم تعد بحاجة إليها.
1 # Copyright (C) 2026 ysicing
1026 继续运行
1027 绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)
1028 缓存
1029 缓存与日志直接删除,下载内容移到废纸篓。 缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。
1030 缓存也先移到废纸篓
1031 编辑
1032 网站
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
1571 按需扫描缓存、日志与项目产物,清理前逐项确认。
1572 “{}”只剩 {} 可用。请检查不再需要的文件。
@@ -1026,7 +1026,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py
继续运行 Weiterlaufen lassen
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) Grün: normal, Orange: hoch (über 60 %), Rot: sehr hoch (über 85 %)
缓存 Caches
缓存与日志直接删除,下载内容移到废纸篓。 Caches und Protokolle werden direkt gelöscht, Downloads in den Papierkorb verschoben.
缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。 Caches und Protokolle werden endgültig gelöscht und können nicht wiederhergestellt werden; Downloads werden in den Papierkorb verschoben.
缓存也先移到废纸篓 Auch Caches in den Papierkorb verschieben
编辑 Bearbeiten
网站 Website
@@ -1568,3 +1568,5 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
已停止,已清理部分不会恢复 Gestoppt. Bereits bereinigte Inhalte können nicht wiederhergestellt werden.
扫描大小约 {} Erfasste Größe: ca. {}
正在取消… Wird abgebrochen…
按需扫描缓存、日志与项目产物,清理前逐项确认。 Caches, Protokolle und Projektartefakte bei Bedarf scannen. Elemente vor dem Bereinigen einzeln prüfen.
“{}”只剩 {} 可用。请检查不再需要的文件。 Auf „{}“ sind nur noch {} verfügbar. Prüfe Dateien, die du nicht mehr benötigst.
1 # Copyright (C) 2026 ysicing
1026 继续运行
1027 绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)
1028 缓存
1029 缓存与日志直接删除,下载内容移到废纸篓。 缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。
1030 缓存也先移到废纸篓
1031 编辑
1032 网站
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
1571 按需扫描缓存、日志与项目产物,清理前逐项确认。
1572 “{}”只剩 {} 可用。请检查不再需要的文件。
@@ -1026,7 +1026,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes
继续运行 Seguir funcionando
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) Verde: normal; naranja: alto (más del 60%); rojo: muy alto (más del 85%)
缓存 Cachés
缓存与日志直接删除,下载内容移到废纸篓。 Las cachés y registros se eliminan; las descargas van a la Papelera.
缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。 Las cachés y registros se eliminarán de forma permanente y no se podrán recuperar; las descargas van a la Papelera.
缓存也先移到废纸篓 Mover también las cachés a la Papelera
编辑 Editar
网站 Sitio
@@ -1568,3 +1568,5 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
已停止,已清理部分不会恢复 Detenido. Los elementos ya eliminados no se pueden restaurar.
扫描大小约 {} Tamaño analizado: aprox. {}
正在取消… Cancelando…
按需扫描缓存、日志与项目产物,清理前逐项确认。 Analiza cachés, registros y artefactos de proyectos cuando lo necesites. Revisa cada elemento antes de limpiar.
“{}”只剩 {} 可用。请检查不再需要的文件。 En «{}» solo quedan {} disponibles. Revisa los archivos que ya no necesitas.
1 # Copyright (C) 2026 ysicing
1026 继续运行
1027 绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)
1028 缓存
1029 缓存与日志直接删除,下载内容移到废纸篓。 缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。
1030 缓存也先移到废纸篓
1031 编辑
1032 网站
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
1571 按需扫描缓存、日志与项目产物,清理前逐项确认。
1572 “{}”只剩 {} 可用。请检查不再需要的文件。
@@ -1026,7 +1026,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P
继续运行 Continuer l’exécution
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) Vert : normal, orange : élevé (au-dessus de 60 %), rouge : très élevé (au-dessus de 85 %)
缓存 Caches
缓存与日志直接删除,下载内容移到废纸篓。 Les caches et journaux sont supprimés ; les téléchargements sont déplacés vers la Corbeille.
缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。 Les caches et journaux seront supprimés définitivement, sans possibilité de récupération ; les téléchargements sont déplacés vers la Corbeille.
缓存也先移到废纸篓 Déplacer aussi les caches vers la Corbeille
编辑 Édition
网站 Site
@@ -1568,3 +1568,5 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
已停止,已清理部分不会恢复 Arrêté. Les éléments déjà supprimés ne peuvent pas être restaurés.
扫描大小约 {} Taille analysée : environ {}
正在取消… Annulation en cours…
按需扫描缓存、日志与项目产物,清理前逐项确认。 Analysez les caches, journaux et artefacts de projet à la demande. Vérifiez les éléments avant le nettoyage.
“{}”只剩 {} 可用。请检查不再需要的文件。 « {} » : il ne reste que {} disponibles. Vérifiez les fichiers dont vous n’avez plus besoin.
1 # Copyright (C) 2026 ysicing
1026 继续运行
1027 绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)
1028 缓存
1029 缓存与日志直接删除,下载内容移到废纸篓。 缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。
1030 缓存也先移到废纸篓
1031 编辑
1032 网站
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
1571 按需扫描缓存、日志与项目产物,清理前逐项确认。
1572 “{}”只剩 {} 可用。请检查不再需要的文件。
@@ -1026,7 +1026,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ
继续运行 実行を続ける
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) 緑は正常、オレンジは高め(60%超)、赤は非常に高い(85%超)
缓存 キャッシュ
缓存与日志直接删除,下载内容移到废纸篓。 キャッシュとログは直接削除し、ダウンロードはゴミ箱に移動します。
缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。 キャッシュとログは直接削除され、復元できません。ダウンロードはゴミ箱に移動します。
缓存也先移到废纸篓 キャッシュもゴミ箱に移動
编辑 編集
网站 サイト
@@ -1568,3 +1568,5 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
已停止,已清理部分不会恢复 停止しました。すでに削除した内容は復元できません
扫描大小约 {} スキャン時のサイズ:約{}
正在取消… キャンセル中…
按需扫描缓存、日志与项目产物,清理前逐项确认。 キャッシュ、ログ、プロジェクトの生成物を必要なときにスキャンし、削除前に項目を確認します。
“{}”只剩 {} 可用。请检查不再需要的文件。 「{}」の空き容量は残り {} です。不要なファイルを確認してください。
1 # Copyright (C) 2026 ysicing
1026 继续运行
1027 绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)
1028 缓存
1029 缓存与日志直接删除,下载内容移到废纸篓。 缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。
1030 缓存也先移到废纸篓
1031 编辑
1032 网站
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
1571 按需扫描缓存、日志与项目产物,清理前逐项确认。
1572 “{}”只剩 {} 可用。请检查不再需要的文件。
@@ -1026,7 +1026,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키
继续运行 계속 실행
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) 초록색은 정상, 주황색은 높음(60% 초과), 빨간색은 매우 높음(85% 초과)
缓存 캐시
缓存与日志直接删除,下载内容移到废纸篓。 캐시와 로그는 바로 삭제하고 다운로드는 휴지통으로 이동합니다.
缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。 캐시와 로그는 영구 삭제되며 복구할 수 없습니다. 다운로드는 휴지통으로 이동합니다.
缓存也先移到废纸篓 캐시도 휴지통으로 이동
编辑 편집
网站 사이트
@@ -1568,3 +1568,5 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
已停止,已清理部分不会恢复 중지됨. 이미 정리된 항목은 복원할 수 없습니다.
扫描大小约 {} 스캔 크기 약 {}
正在取消… 취소하는 중…
按需扫描缓存、日志与项目产物,清理前逐项确认。 필요할 때 캐시, 로그 및 프로젝트 빌드 산출물을 검사하고 정리 전에 항목을 확인합니다.
“{}”只剩 {} 可用。请检查不再需要的文件。 “{}”의 남은 공간은 {}입니다. 더 이상 필요하지 않은 파일을 확인하세요.
1 # Copyright (C) 2026 ysicing
1026 继续运行
1027 绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)
1028 缓存
1029 缓存与日志直接删除,下载内容移到废纸篓。 缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。
1030 缓存也先移到废纸篓
1031 编辑
1032 网站
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
1571 按需扫描缓存、日志与项目产物,清理前逐项确认。
1572 “{}”只剩 {} 可用。请检查不再需要的文件。
@@ -1027,7 +1027,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python
继续运行 繼續運行
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) 綠色正常、橙色偏高(60% 以上)、紅色很高(85% 以上)
缓存 緩存
缓存与日志直接删除,下载内容移到废纸篓。 緩存與日誌直接刪除,下載內容移到廢紙簍。
缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。 緩存與日誌將直接刪除且無法復原,下載內容移到廢紙簍。
缓存也先移到废纸篓 緩存也先移到廢紙簍
编辑 編輯
网站 網站
@@ -1569,3 +1569,5 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
已停止,已清理部分不会恢复 已停止,已清理部分無法復原
扫描大小约 {} 掃描大小約 {}
正在取消… 正在取消…
按需扫描缓存、日志与项目产物,清理前逐项确认。 按需掃描快取、日誌與專案產物,清理前逐項確認。
“{}”只剩 {} 可用。请检查不再需要的文件。 「{}」只剩 {} 可用。請檢查不再需要的檔案。
1 # Copyright (c) 2026 GiantAccel, LLC
1027 继续运行
1028 绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)
1029 缓存
1030 缓存与日志直接删除,下载内容移到废纸篓。 缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。
1031 缓存也先移到废纸篓
1032 编辑
1033 网站
1569 已停止,已清理部分不会恢复
1570 扫描大小约 {}
1571 正在取消…
1572 按需扫描缓存、日志与项目产物,清理前逐项确认。
1573 “{}”只剩 {} 可用。请检查不再需要的文件。
@@ -1149,7 +1149,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and
继续运行 Keep running
绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上) Green is normal, orange is high (above 60%), red is very high (above 85%)
缓存 Caches
缓存与日志直接删除,下载内容移到废纸篓。 Caches and logs are deleted; downloads go to the Trash.
缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。 Caches and logs will be permanently deleted and cannot be recovered; downloads go to the Trash.
缓存也先移到废纸篓 Move caches to Trash too
编辑 Edit
网站 Site
@@ -1419,5 +1419,7 @@ Agent 工作树 Agent worktree
Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依赖。 Homebrew will clean old versions and caches based on their state at execution time, without automatically removing dependencies.
尚未扫描 Not scanned yet
点击重新扫描以计算可清理空间 Click Rescan to calculate reclaimable space
按需扫描缓存、日志与项目产物,清理前逐项确认。 Scan caches, logs, and project artifacts on demand. Review items before cleaning.
“{}”只剩 {} 可用。请检查不再需要的文件。 “{}” has only {} available. Check files you no longer need.
"""#
}
@@ -101,9 +101,10 @@ public final class AppController: NSObject, NSApplicationDelegate {
self?.speedTestWindow.show()
}
model.openProjectPurgeWindow = { [weak self] in
self?.menuBar.dismissPopovers()
self?.calendarMenuBar.dismiss()
self?.projectPurgeWindow.show()
guard let self, self.model.settings.cleanerEnabled else { return }
self.menuBar.dismissPopovers()
self.calendarMenuBar.dismiss()
self.projectPurgeWindow.show()
}
model.quit = { NSApp.terminate(nil) }
model.helper.refreshStatus()
@@ -119,6 +120,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
model.screenTime.start()
observeScreenTimeHistorySetting()
observeRestSettings()
observeCleanerSetting()
observeRestMode()
observeRestSound()
rest.sync()
@@ -632,6 +634,26 @@ public final class AppController: NSObject, NSApplicationDelegate {
}
}
/// 关闭清理模块时停止尚未结束的扫描或清理,并关闭其独立窗口。
private func observeCleanerSetting() {
withObservationTracking {
_ = model.settings.cleanerEnabled
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
if !self.model.settings.cleanerEnabled {
self.model.cleaner.cancelClean()
self.model.cleaner.cancelOperation()
self.model.projectPurge.isConfirming = false
self.model.projectPurge.cancelScan()
self.model.projectPurge.cancelClean()
self.projectPurgeWindow.close()
}
self.observeCleanerSetting()
}
}
}
private func observeRestMode() {
withObservationTracking {
_ = model.settings.restMode
@@ -20,6 +20,8 @@ final class ProjectPurgeWindowController: NSObject, NSWindowDelegate {
func refreshLanguage() { window?.title = tr("项目产物") }
func close() { window?.close() }
func show() {
let window = self.window ?? makeWindow()
self.window = window
@@ -81,12 +81,22 @@ private struct SummaryCard: View {
}
if cleaner.isConfirming {
InfoBanner(icon: "exclamationmark.triangle.fill", text: confirmationText, tone: .warning) {
let deletesPermanently = cleaner.selectedScans.contains {
$0.rule.deletesPermanently(preferTrash: model.settings.cleanPrefersTrash)
}
InfoBanner(icon: "exclamationmark.triangle.fill", text: confirmationText,
tone: deletesPermanently ? .error : .warning) {
HStack(spacing: DS.Space.s2) {
Button(tr("取消")) { cleaner.cancelClean() }
.buttonStyle(DSButtonStyle(kind: .secondary))
Button(tr("确认清理")) { cleaner.confirmClean() }
.buttonStyle(DSButtonStyle(kind: .primary))
if deletesPermanently {
Button(tr("确认清理"), role: .destructive) { cleaner.confirmClean() }
.buttonStyle(.borderedProminent)
.tint(DS.Palette.error)
} else {
Button(tr("确认清理")) { cleaner.confirmClean() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
}
}
} else {
@@ -152,7 +162,7 @@ private struct SummaryCard: View {
let count = cleaner.selectedScans.reduce(0) { $0 + $1.items.count }
let trashNote = model.settings.cleanPrefersTrash
? tr("支持的内容先移到废纸篓;工具缓存由对应命令直接清理。")
: tr("缓存与日志直接删除,下载内容移到废纸篓。")
: tr("缓存与日志将直接删除且无法恢复,下载内容移到废纸篓。")
var text = tr("将清理 \(count) 个项目,共 \(Format.bytes(cleaner.selectedBytes, base: .decimal))。\(trashNote)")
if cleaner.selectedScans.contains(where: { $0.id == "developer.homebrew" }) {
text += " " + tr("Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依赖。")
@@ -196,7 +196,13 @@ private struct MainSidebar: View {
private var navigation: some View {
VStack(alignment: .leading, spacing: DS.Space.s1) {
group(tr("监控"), PanelTab.monitors.filter { $0 != .aiUsage || model.settings.aiUsageEnabled })
group(tr("工具"), PanelTab.tools.filter { $0 != .rest || model.settings.restEnabled })
group(tr("工具"), PanelTab.tools.filter { tab in
switch tab {
case .rest: model.settings.restEnabled
case .cleaner: model.settings.cleanerEnabled
default: true
}
})
group(tr("设置"), PanelTab.settings)
}
.sidebarGlider()
@@ -122,6 +122,13 @@ struct GeneralSettings: View {
DSToggle(isOn: $settings.aiUsageEnabled, label: tr("AI 用量与额度"))
}
}
GroupRow(showsDivider: false) {
SettingRow(title: tr("清理"),
subtitle: tr("按需扫描缓存、日志与项目产物,清理前逐项确认。"),
icon: "eraser") {
DSToggle(isOn: $settings.cleanerEnabled, label: tr("清理"))
}
}
GroupRow {
SettingRow(title: tr("菜单栏日历"),
subtitle: tr("独立显示日期,点击打开月历;不受指标合并布局影响"),
@@ -28,6 +28,7 @@ enum SnapshotRenderer {
settings.language = L10n.language
settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature, .battery, .aiUsage]
settings.aiUsageEnabled = true
settings.cleanerEnabled = true
// 历史页用示例数据:最近 24 小时每分钟一条,中间留一段“睡眠”空档
let historyURL = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-snapshot-history.sqlite")
try? FileManager.default.removeItem(at: historyURL)
@@ -199,8 +199,10 @@ public final class AlertController: NSObject {
let enabled = settings.enabledAlerts
if enabled.contains(.diskSpace), let disk = DiskSampler.sample() {
let low = disk.total > 0 && (Double(disk.available) / Double(disk.total) < 0.1 || disk.available < 10_000_000_000)
fireIfNeeded(.diskSpace, isActive: low, now: now,
body: tr("“\(disk.volumeName)”只剩 \(Format.bytes(disk.available, base: .decimal)) 可用。可以用 XStats 的清理功能释放缓存。"))
let body = settings.cleanerEnabled
? tr("“\(disk.volumeName)”只剩 \(Format.bytes(disk.available, base: .decimal)) 可用。可以用 XStats 的清理功能释放缓存。")
: tr("“\(disk.volumeName)”只剩 \(Format.bytes(disk.available, base: .decimal)) 可用。请检查不再需要的文件。")
fireIfNeeded(.diskSpace, isActive: low, now: now, body: body)
}
if enabled.contains(.batteryHealth), let health = BatterySampler.sample()?.health {
// 冷却时间跨越重启,记在偏好设置里
@@ -514,6 +514,13 @@ public final class AppSettings {
if !aiUsageEnabled && panelTab == .aiUsage { panelTab = .settingsGeneral }
}
}
/// 清理工具默认关闭;扫描只在打开页面后按需执行。
public var cleanerEnabled: Bool {
didSet {
defaults.set(cleanerEnabled, forKey: Keys.cleanerEnabled)
if !cleanerEnabled && panelTab == .cleaner { panelTab = .settingsGeneral }
}
}
/// 来源开关只保存在本机;关闭后不扫描,也不显示其历史缓存。
public var aiUsageSources: Set<AIProviderID> {
didSet { defaults.set(aiUsageSources.map(\.rawValue).sorted(), forKey: Keys.aiUsageSources) }
@@ -708,6 +715,8 @@ public final class AppSettings {
? defaults.integer(forKey: Keys.refreshSeconds) : 2
let isAIUsageEnabled = defaults.bool(forKey: Keys.aiUsageEnabled)
aiUsageEnabled = isAIUsageEnabled
let isCleanerEnabled = defaults.bool(forKey: Keys.cleanerEnabled)
cleanerEnabled = isCleanerEnabled
aiUsageSources = defaults.stringArray(forKey: Keys.aiUsageSources)
.map { Set($0.compactMap(AIProviderID.init(rawValue:))) } ?? Set(AIProviderID.allCases)
aiUsageShowsLocalUsage = defaults.object(forKey: Keys.aiUsageShowsLocalUsage) as? Bool ?? true
@@ -726,6 +735,7 @@ public final class AppSettings {
panelTab = savedPanelTabValue == "settingsAI" || savedPanelTab == .settingsAccount
|| (savedPanelTab == .aiUsage && !isAIUsageEnabled)
|| (savedPanelTab == .rest && !isRestEnabled)
|| (savedPanelTab == .cleaner && !isCleanerEnabled)
? .settingsGeneral : savedPanelTab
appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system
showDockIcon = defaults.bool(forKey: Keys.showDockIcon)
@@ -817,6 +827,7 @@ public final class AppSettings {
static let styleOverrides = "styleOverrides"
static let refreshSeconds = "refreshSeconds"
static let aiUsageEnabled = "aiUsageEnabled"
static let cleanerEnabled = "cleanerEnabled"
static let aiUsageSources = "aiUsageSources"
static let aiUsageShowsLocalUsage = "aiUsageShowsLocalUsage"
static let aiUsageRefreshMinutes = "aiUsageRefreshMinutes"
@@ -36,7 +36,7 @@ public final class CleanerController {
var isBusy: Bool { phase == .scanning || phase == .cleaning }
func scan(ifNeeded: Bool = false) {
guard !isBusy, !(ifNeeded && lastScan != nil) else { return }
guard settings.cleanerEnabled, !isBusy, !(ifNeeded && lastScan != nil) else { return }
phase = .scanning
isConfirming = false
let environment = Self.environment()
@@ -51,7 +51,7 @@ public final class CleanerController {
}
func requestClean() {
guard phase == .ready || phase == .finished, !selectedScans.isEmpty else { return }
guard settings.cleanerEnabled, (phase == .ready || phase == .finished), !selectedScans.isEmpty else { return }
isConfirming = true
}
@@ -66,7 +66,7 @@ public final class CleanerController {
}
func confirmClean() {
guard isConfirming else { return }
guard settings.cleanerEnabled, isConfirming else { return }
isConfirming = false
report = nil
cleanProgress = nil
@@ -55,6 +55,7 @@ public struct SettingsDocument: Codable, Equatable, Sendable {
public var historyEnabled: Bool?
public var autoCheckUpdates: Bool?
public var updateCheckSchedule: String?
public var cleanerEnabled: Bool?
public var cleanPrefersTrash: Bool?
public init() {}
@@ -103,6 +104,7 @@ extension AppSettings {
doc.historyEnabled = historyEnabled
doc.autoCheckUpdates = autoCheckUpdates
doc.updateCheckSchedule = updateCheckSchedule.rawValue
doc.cleanerEnabled = cleanerEnabled
doc.cleanPrefersTrash = cleanPrefersTrash
return doc
}
@@ -172,6 +174,7 @@ extension AppSettings {
} else {
assign(\.autoCheckUpdates, doc.autoCheckUpdates)
}
assign(\.cleanerEnabled, doc.cleanerEnabled)
assign(\.cleanPrefersTrash, doc.cleanPrefersTrash)
}
@@ -49,6 +49,19 @@ private struct FakeHome {
func remove() { try? FileManager.default.removeItem(at: root) }
}
@Suite("清理方式")
struct CleanDeletionPolicyTests {
@Test func confirmationMatchesActualDeletionPolicy() {
for preferTrash in [false, true] {
#expect(RuleCatalog.trash.deletesPermanently(preferTrash: preferTrash))
#expect(RuleCatalog.uvCache.deletesPermanently(preferTrash: preferTrash))
#expect(!RuleCatalog.incompleteDownloads.deletesPermanently(preferTrash: preferTrash))
#expect(!RuleCatalog.xcodeArchives.deletesPermanently(preferTrash: preferTrash))
#expect(RuleCatalog.userCaches.deletesPermanently(preferTrash: preferTrash) == !preferTrash)
}
}
}
@Suite("安全守卫")
struct SafetyGuardTests {
let guardian = SafetyGuard(home: "/Users/tester")
@@ -271,6 +271,45 @@ private func isolatedDefaults() -> UserDefaults {
}
}
@MainActor
@Suite struct CleanerFeatureTests {
@Test func defaultsOffAndHidesSavedPageWhenDisabled() {
let defaults = isolatedDefaults()
defaults.set("cleaner", forKey: "panelTab")
let settings = AppSettings(defaults: defaults)
#expect(!settings.cleanerEnabled)
#expect(settings.panelTab == .settingsGeneral)
let cleaner = CleanerController(settings: settings)
cleaner.scan()
#expect(cleaner.phase == .idle)
settings.cleanerEnabled = true
settings.panelTab = .cleaner
#expect(AppSettings(defaults: defaults).panelTab == .cleaner)
settings.cleanerEnabled = false
#expect(settings.panelTab == .settingsGeneral)
#expect(!AppSettings(defaults: defaults).cleanerEnabled)
defaults.set("cleaner", forKey: "panelTab")
#expect(AppSettings(defaults: defaults).panelTab == .settingsGeneral)
}
@Test func backupRestoresModulePreferenceAndOldBackupKeepsCurrentValue() throws {
let source = AppSettings(defaults: isolatedDefaults())
source.cleanerEnabled = true
let data = try JSONEncoder().encode(source.exportDocument())
let document = try JSONDecoder().decode(SettingsDocument.self, from: data)
#expect(document.cleanerEnabled == true)
let target = AppSettings(defaults: isolatedDefaults())
target.apply(document)
#expect(target.cleanerEnabled)
target.apply(SettingsDocument())
#expect(target.cleanerEnabled)
}
}
@MainActor
@Suite struct CleanerSelectionTests {
private let toolRuleIDs: Set<String> = [