Internal tooling for Mac utility for storage management.

Swift 98.7% JSON 1.1% Markdown 0.2%
CleanupView.swift 438 lines (14 KB)
//
//  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