Internal tooling for Mac utility for storage management.
Swift
98.7%
JSON
1.1%
Markdown
0.2%
//
// CleanupView.swift
// MUA
//
// Created by Mitchel Volkering on 21/12/2025.
//
import SwiftUI
/// Categories of cleanable items
enum CleanupCategory: String, CaseIterable, Identifiable {
case caches = "Caches"
case logs = "Logs"
case trash = "Trash"
case downloads = "Old Downloads"
var id: String { rawValue }
var icon: String {
switch self {
case .caches: return "memorychip"
case .logs: return "doc.text"
case .trash: return "trash"
case .downloads: return "arrow.down.circle"
}
}
var color: Color {
switch self {
case .caches: return .purple
case .logs: return .orange
case .trash: return .red
case .downloads: return .blue
}
}
var description: String {
switch self {
case .caches: return "Application caches that can be safely removed"
case .logs: return "System and app log files"
case .trash: return "Items in your Trash"
case .downloads: return "Files older than 30 days in Downloads"
}
}
var paths: [URL] {
let home = FileManager.default.homeDirectoryForCurrentUser
switch self {
case .caches:
return [
home.appending(path: "Library/Caches"),
]
case .logs:
return [
home.appending(path: "Library/Logs"),
]
case .trash:
return [
home.appending(path: ".Trash"),
]
case .downloads:
return [
home.appending(path: "Downloads"),
]
}
}
}
struct CleanupItem: Identifiable {
let id = UUID()
let category: CleanupCategory
var size: Int64
var items: [FileItem]
var isSelected: Bool = true
}
/// Cleanup view for removing unnecessary files
struct CleanupView: View {
@State private var cleanupItems: [CleanupItem] = []
@State private var isScanning = false
@State private var isCleaning = false
@State private var showConfirmation = false
private var totalSize: Int64 {
cleanupItems.filter(\.isSelected).reduce(0) { $0 + $1.size }
}
var body: some View {
VStack(spacing: 0) {
// Header
headerView
// Categories
if isScanning {
scanningView
} else if cleanupItems.isEmpty {
emptyView
} else {
categoriesList
}
}
.background(Color(nsColor: .windowBackgroundColor))
.alert("Clean Selected Items?", isPresented: $showConfirmation) {
Button("Cancel", role: .cancel) {}
Button("Clean", role: .destructive) {
Task { await performCleanup() }
}
} message: {
Text("This will permanently delete \(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)) of data. This cannot be undone.")
}
}
private var headerView: some View {
HStack(spacing: 20) {
// Total savings indicator
VStack(alignment: .leading, spacing: 4) {
Text("Potential Savings")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file))
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
}
Spacer()
// Actions
Button {
Task { await scanForCleanup() }
} label: {
Label("Scan", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(isScanning || isCleaning)
Button {
showConfirmation = true
} label: {
Label("Clean", systemImage: "trash")
}
.buttonStyle(.borderedProminent)
.tint(.red)
.disabled(totalSize == 0 || isScanning || isCleaning)
}
.padding()
.background(.ultraThinMaterial)
}
private var scanningView: some View {
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
Text("Scanning for cleanable items...")
.font(.headline)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var emptyView: some View {
VStack(spacing: 20) {
ZStack {
Circle()
.fill(
.linearGradient(
colors: [.green.opacity(0.6), .green.opacity(0.3)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 100, height: 100)
.overlay(
Circle()
.strokeBorder(.white.opacity(0.3), lineWidth: 2)
)
.shadow(color: .green.opacity(0.3), radius: 15)
Image(systemName: "checkmark")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.white)
}
Text("Ready to Clean")
.font(.title2)
.fontWeight(.medium)
Text("Scan your system to find cleanable items")
.font(.subheadline)
.foregroundStyle(.secondary)
Button {
Task { await scanForCleanup() }
} label: {
Label("Start Scan", systemImage: "magnifyingglass")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var categoriesList: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach($cleanupItems) { $item in
CleanupCategoryCard(item: $item)
}
}
.padding()
}
}
private func scanForCleanup() async {
isScanning = true
cleanupItems = []
// Get all category paths on main thread first
let categoryPaths: [(CleanupCategory, [URL])] = CleanupCategory.allCases.map { ($0, $0.paths) }
// Scan all categories on background thread
let results = await Task.detached(priority: .userInitiated) {
var categoryResults: [CleanupItem] = []
for (category, paths) in categoryPaths {
var size: Int64 = 0
var files: [FileItem] = []
for path in paths {
let (pathSize, pathFiles) = CleanupView.scanPathSync(path, category: category)
size += pathSize
files.append(contentsOf: pathFiles)
}
if size > 0 {
categoryResults.append(CleanupItem(
category: category,
size: size,
items: files
))
}
}
return categoryResults
}.value
cleanupItems = results
isScanning = false
}
private nonisolated static func scanPathSync(_ url: URL, category: CleanupCategory) -> (Int64, [FileItem]) {
let fileManager = FileManager.default
var totalSize: Int64 = 0
var items: [FileItem] = []
guard let enumerator = fileManager.enumerator(
at: url,
includingPropertiesForKeys: [.fileSizeKey, .creationDateKey],
options: [.skipsHiddenFiles]
) else { return (0, []) }
let thirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60)
while let fileURL = enumerator.nextObject() as? URL {
do {
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey, .creationDateKey, .isDirectoryKey])
// For downloads, only include old files
if category == .downloads {
if let creationDate = resourceValues.creationDate, creationDate > thirtyDaysAgo {
continue
}
}
if resourceValues.isDirectory == false {
let size = Int64(resourceValues.fileSize ?? 0)
totalSize += size
if size > 1_000_000 { // Only track files > 1MB
items.append(FileItem(url: fileURL, isDirectory: false, size: size))
}
}
} catch {
continue
}
}
return (totalSize, items.sorted { $0.size > $1.size }.prefix(20).map { $0 })
}
private func performCleanup() async {
isCleaning = true
for item in cleanupItems where item.isSelected {
for file in item.items {
do {
try FileManager.default.trashItem(at: file.url, resultingItemURL: nil)
} catch {
print("Failed to clean \(file.url): \(error)")
}
}
}
// Rescan after cleanup
await scanForCleanup()
isCleaning = false
}
}
struct CleanupCategoryCard: View {
@Binding var item: CleanupItem
@State private var isExpanded = false
@State private var isHovered = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 16) {
// Toggle
Toggle(isOn: $item.isSelected) {
EmptyView()
}
.toggleStyle(.checkbox)
// Icon
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(
.linearGradient(
colors: [
item.category.color.opacity(0.6),
item.category.color.opacity(0.3)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 44, height: 44)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.white.opacity(0.2), lineWidth: 1)
)
Image(systemName: item.category.icon)
.font(.system(size: 20))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(item.category.rawValue)
.font(.headline)
Text(item.category.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(ByteCountFormatter.string(fromByteCount: item.size, countStyle: .file))
.font(.system(.title3, design: .rounded, weight: .semibold))
.foregroundStyle(item.isSelected ? .primary : .secondary)
Button {
withAnimation(.spring(response: 0.3)) {
isExpanded.toggle()
}
} label: {
Image(systemName: "chevron.right")
.rotationEffect(.degrees(isExpanded ? 90 : 0))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding()
// Expanded items
if isExpanded && !item.items.isEmpty {
Divider()
.padding(.horizontal)
VStack(spacing: 0) {
ForEach(item.items.prefix(10)) { file in
HStack {
Image(systemName: file.icon)
.foregroundStyle(file.color)
.frame(width: 20)
Text(file.name)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Text(file.formattedSize)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 6)
}
if item.items.count > 10 {
Text("And \(item.items.count - 10) more files...")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.vertical, 8)
}
}
.padding(.bottom)
}
}
.background(
RoundedRectangle(cornerRadius: 14)
.fill(.regularMaterial)
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(
item.isSelected ? item.category.color.opacity(0.3) : Color.clear,
lineWidth: 1
)
)
)
.shadow(color: .black.opacity(0.05), radius: 5, y: 2)
.scaleEffect(isHovered ? 1.005 : 1.0)
.animation(.spring(response: 0.3), value: isHovered)
.onHover { hovering in
isHovered = hovering
}
}
}
#Preview {
CleanupView()
.frame(width: 700, height: 600)
}
About
Internal tooling for Mac utility for storage management.
0 stars
0 forks