-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCacheoutViewModel.swift
More file actions
301 lines (250 loc) · 10.9 KB
/
CacheoutViewModel.swift
File metadata and controls
301 lines (250 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
/// # CacheoutViewModel — Main Application State
///
/// The central `@MainActor` view model that manages all application state and
/// coordinates between the scanning, cleaning, and UI layers.
///
/// ## State Management
///
/// All `@Published` properties trigger SwiftUI view updates automatically:
/// - `scanResults`: Current scan results for all cache categories
/// - `nodeModulesItems`: Discovered node_modules directories
/// - `diskInfo`: Current disk space information
/// - `isScanning` / `isCleaning` / `isNodeModulesScanning`: Loading states
/// - `scanGeneration`: Monotonic counter incremented on each scan completion,
/// used by views with `.task(id:)` to react to new data
///
/// ## Persistence
///
/// User preferences are stored in `UserDefaults` via `didSet` observers:
/// - `scanIntervalMinutes`: How often to auto-rescan (default: 30)
/// - `lowDiskThresholdGB`: Notification threshold (default: 10)
/// - `launchAtLogin`: Whether to start at login
/// - `moveToTrash`: Deletion mode preference
///
/// ## Scanning
///
/// The `scan()` method runs `CacheScanner` and `NodeModulesScanner` in parallel
/// using `async let`. Cache scanning completes first (typically 2-5s), then
/// node_modules scanning finishes (can take 10-30s depending on project count).
///
/// ## Smart Clean
///
/// `smartClean()` auto-selects all "Safe" categories and runs cleanup — a one-tap
/// operation from the menubar for quick disk recovery without decision fatigue.
///
/// ## Docker Prune
///
/// `dockerPrune()` runs `docker system prune -f` and parses the output for the
/// "Total reclaimed space" line. Handles Docker not running or not installed gracefully.
import Foundation
import SwiftUI
@MainActor
class CacheoutViewModel: ObservableObject {
@Published var scanResults: [ScanResult] = []
@Published var isScanning = false
@Published var isCleaning = false
@Published var diskInfo: DiskInfo?
@Published var showCleanConfirmation = false
@Published var showCleanupReport = false
@Published var lastReport: CleanupReport?
@Published var moveToTrash = true
@Published var nodeModulesItems: [NodeModulesItem] = []
@Published var isNodeModulesScanning = false
/// Increments on every completed scan — views can use .task(id:) to react
@Published var scanGeneration: Int = 0
/// Whether at least one scan has completed. Unlike `hasResults`, this
/// stays `true` even if the scan found zero items, preventing redundant
/// re-scans when switching tabs.
@Published var hasScanned = false
/// When the last scan completed
@Published var lastScanDate: Date?
/// User-configurable scan interval in minutes (persisted in UserDefaults)
@Published var scanIntervalMinutes: Double {
didSet { UserDefaults.standard.set(scanIntervalMinutes, forKey: "cacheout.scanIntervalMinutes") }
}
/// Low-disk notification threshold in GB (persisted in UserDefaults)
@Published var lowDiskThresholdGB: Double {
didSet { UserDefaults.standard.set(lowDiskThresholdGB, forKey: "cacheout.lowDiskThresholdGB") }
}
/// Whether to launch at login (persisted in UserDefaults)
@Published var launchAtLogin: Bool {
didSet { UserDefaults.standard.set(launchAtLogin, forKey: "cacheout.launchAtLogin") }
}
/// Whether the menubar should trigger an auto-rescan (no results or stale data)
var shouldAutoRescan: Bool {
if !hasResults && !isScanning { return true }
guard let last = lastScanDate else { return true }
return Date().timeIntervalSince(last) > scanIntervalMinutes * 60
}
private let scanner = CacheScanner()
private let nodeModulesScanner = NodeModulesScanner()
private let cleaner = CacheCleaner()
init() {
let storedInterval = UserDefaults.standard.double(forKey: "cacheout.scanIntervalMinutes")
self.scanIntervalMinutes = storedInterval > 0 ? storedInterval : 30
let storedThreshold = UserDefaults.standard.double(forKey: "cacheout.lowDiskThresholdGB")
self.lowDiskThresholdGB = storedThreshold > 0 ? storedThreshold : 10
self.launchAtLogin = UserDefaults.standard.bool(forKey: "cacheout.launchAtLogin")
}
var selectedResults: [ScanResult] {
scanResults.filter { $0.isSelected }
}
var selectedSize: Int64 {
selectedResults.reduce(0) { $0 + $1.sizeBytes }
}
var formattedSelectedSize: String {
ByteCountFormatter.string(fromByteCount: selectedSize, countStyle: .file)
}
var totalRecoverable: Int64 {
scanResults.filter { !$0.isEmpty }.reduce(0) { $0 + $1.sizeBytes }
}
var hasResults: Bool { !scanResults.isEmpty || !nodeModulesItems.isEmpty }
var hasSelection: Bool { !selectedResults.isEmpty || selectedNodeModulesSize > 0 }
// MARK: - Node Modules computed properties
var nodeModulesTotal: Int64 {
nodeModulesItems.reduce(0) { $0 + $1.sizeBytes }
}
var formattedNodeModulesTotal: String {
ByteCountFormatter.string(fromByteCount: nodeModulesTotal, countStyle: .file)
}
var selectedNodeModulesSize: Int64 {
nodeModulesItems.filter(\.isSelected).reduce(0) { $0 + $1.sizeBytes }
}
var formattedSelectedNodeModulesSize: String {
ByteCountFormatter.string(fromByteCount: selectedNodeModulesSize, countStyle: .file)
}
var totalSelectedSize: Int64 { selectedSize + selectedNodeModulesSize }
var formattedTotalSelectedSize: String {
ByteCountFormatter.string(fromByteCount: totalSelectedSize, countStyle: .file)
}
func scan() async {
isScanning = true
isNodeModulesScanning = true
// ⚡ Bolt: Offload synchronous file system call to prevent blocking the @MainActor
// Impact: Keeps the UI responsive (avoiding a ~5-10ms hitch) during initial scan
diskInfo = await Task.detached { DiskInfo.current() }.value
// Scan caches and node_modules in parallel
async let cacheResults = scanner.scanAll(CacheCategory.allCategories)
async let nmResults = nodeModulesScanner.scan()
scanResults = await cacheResults
isScanning = false
nodeModulesItems = await nmResults
isNodeModulesScanning = false
// Track scan completion for reactive UI updates
lastScanDate = Date()
scanGeneration += 1
hasScanned = true
}
func toggleSelection(for id: UUID) {
if let index = scanResults.firstIndex(where: { $0.id == id }) {
scanResults[index].isSelected.toggle()
}
}
func selectAllSafe() {
for i in scanResults.indices where scanResults[i].category.riskLevel == .safe && !scanResults[i].isEmpty {
scanResults[i].isSelected = true
}
}
func deselectAll() {
for i in scanResults.indices {
scanResults[i].isSelected = false
}
deselectAllNodeModules()
}
// MARK: - Node Modules selection
func toggleNodeModulesSelection(for id: UUID) {
if let i = nodeModulesItems.firstIndex(where: { $0.id == id }) {
nodeModulesItems[i].isSelected.toggle()
}
}
func selectStaleNodeModules() {
for i in nodeModulesItems.indices where nodeModulesItems[i].isStale {
nodeModulesItems[i].isSelected = true
}
}
func selectAllNodeModules() {
for i in nodeModulesItems.indices { nodeModulesItems[i].isSelected = true }
}
func deselectAllNodeModules() {
for i in nodeModulesItems.indices { nodeModulesItems[i].isSelected = false }
}
/// Menu bar label: show free GB in the tray
var menuBarTitle: String {
guard let disk = diskInfo else { return "💾" }
let freeGB = Double(disk.freeSpace) / (1024 * 1024 * 1024)
return String(format: "%.0fGB", freeGB)
}
/// Quick clean: auto-select all safe categories, clean, deselect
func smartClean() async {
selectAllSafe()
await clean()
// Re-scan updates are handled inside clean()
}
// MARK: - Docker Management
@Published var isDockerPruning = false
@Published var lastDockerPruneResult: String?
func dockerPrune() async {
isDockerPruning = true
defer { isDockerPruning = false }
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/bin/bash")
process.arguments = ["-c", "docker system prune -f 2>&1"]
process.standardOutput = pipe
process.standardError = pipe
process.environment = [
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin",
"HOME": FileManager.default.homeDirectoryForCurrentUser.path
]
do {
// ⚡ Bolt: Offload blocking I/O (process.run, waitUntilExit, readDataToEndOfFile)
// Impact: Prevents the @MainActor from freezing during the entire Docker prune execution,
// which can take several seconds to complete. Keeps the UI completely responsive.
let (output, terminationStatus) = try await Task.detached {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
return (output, process.terminationStatus)
}.value
if terminationStatus == 0 {
// Extract "Total reclaimed space:" line
if let line = output.components(separatedBy: "\n")
.first(where: { $0.contains("reclaimed") }) {
lastDockerPruneResult = line.trimmingCharacters(in: .whitespaces)
} else {
lastDockerPruneResult = "Docker pruned successfully"
}
} else {
let lowerOutput = output.lowercased()
if lowerOutput.contains("cannot connect") ||
lowerOutput.contains("is the docker daemon running") ||
lowerOutput.contains("connection refused") ||
lowerOutput.contains("no such file or directory") {
lastDockerPruneResult = "Docker must be running to prune"
} else {
lastDockerPruneResult = "Docker prune failed — is Docker running?"
}
}
} catch {
lastDockerPruneResult = "Docker not found"
}
// Refresh disk info after prune
// ⚡ Bolt: Offload synchronous file system call
diskInfo = await Task.detached { DiskInfo.current() }.value
}
func clean() async {
isCleaning = true
let selectedNM = nodeModulesItems.filter(\.isSelected)
let report = await cleaner.clean(
results: selectedResults,
nodeModules: selectedNM,
moveToTrash: moveToTrash
)
lastReport = report
isCleaning = false
showCleanupReport = true
// Rescan to update sizes
await scan()
}
}