Mac-utlity-app / MUA / Views / LargeFiles / LargeFilesView.swift Blame
268 lines
6dacfa4 Mitchel Jan 17, 2026
//
//  LargeFilesView.swift
//  MUA
//
//  Created by Mitchel Volkering on 21/12/2025.
//
import SwiftUI
/// View for finding large files quickly
struct LargeFilesView: View {
    @State private var files: [FileItem] = []
    @State private var isScanning = false
    @State private var scanProgress: Double = 0
    @State private var selectedFile: FileItem?
    @State private var minimumSize: Int64 = 100_000_000 // 100 MB
    @State private var searchPath: URL = FileManager.default.homeDirectoryForCurrentUser
    private let sizeOptions: [(String, Int64)] = [
        ("10 MB", 10_000_000),
        ("50 MB", 50_000_000),
        ("100 MB", 100_000_000),
        ("500 MB", 500_000_000),
        ("1 GB", 1_000_000_000),
    ]
    var body: some View {
        VStack(spacing: 0) {
            // Toolbar
            toolbar
            // Content
            if isScanning {
                scanningView
            } else if files.isEmpty {
                emptyView
            } else {
                filesList
            }
        }
        .background(Color(nsColor: .windowBackgroundColor))
    }
    private var toolbar: some View {
        HStack(spacing: 16) {
            // Size picker
            Picker("Minimum size", selection: $minimumSize) {
                ForEach(sizeOptions, id: \.1) { option in
                    Text(option.0).tag(option.1)
                }
            }
            .pickerStyle(.segmented)
            .frame(maxWidth: 400)
            Spacer()
            // Scan button
            Button {
                Task { await scanForLargeFiles() }
            } label: {
                Label("Scan Home", systemImage: "magnifyingglass")
            }
            .buttonStyle(.borderedProminent)
            .disabled(isScanning)
            // Total size badge
            if !files.isEmpty {
                let totalSize = files.reduce(0) { $0 + $1.size }
                Text("Total: \(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file))")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 6)
                    .background(.ultraThinMaterial, in: Capsule())
            }
        }
        .padding()
        .background(.ultraThinMaterial)
    }
    private var scanningView: some View {
        VStack(spacing: 20) {
            ProgressView(value: scanProgress)
                .progressViewStyle(.circular)
                .scaleEffect(1.5)
            Text("Searching for large files...")
                .font(.headline)
            Text("\(files.count) files found")
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
    private var emptyView: some View {
        VStack(spacing: 16) {
            Image(systemName: "doc.badge.arrow.up")
                .font(.system(size: 56))
                .foregroundStyle(.quaternary)
            Text("Find Large Files")
                .font(.title2)
                .fontWeight(.medium)
            Text("Scan to find files larger than \(ByteCountFormatter.string(fromByteCount: minimumSize, countStyle: .file))")
                .font(.subheadline)
                .foregroundStyle(.secondary)
            Button {
                Task { await scanForLargeFiles() }
            } label: {
                Label("Start Scan", systemImage: "magnifyingglass")
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.large)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
    private var filesList: some View {
        HSplitView {
            // File list
            List(selection: $selectedFile) {
                ForEach(files) { file in
                    LargeFileRow(file: file, isSelected: selectedFile?.id == file.id)
                        .tag(file)
                        .listRowSeparator(.hidden)
                        .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
                }
            }
            .listStyle(.plain)
            .frame(minWidth: 400)
            // Detail panel
            DetailPanelView(item: selectedFile)
        }
    }
    private func scanForLargeFiles() async {
        isScanning = true
        files = []
        scanProgress = 0
        let searchURL = searchPath
        let minSize = minimumSize
        // Perform file enumeration on background thread using a static helper
        let foundFiles = await Task.detached(priority: .userInitiated) {
            Self.scanForFilesSync(at: searchURL, minSize: minSize)
        }.value
        files = foundFiles
        isScanning = false
        scanProgress = 1.0
    }
    /// Synchronous file scanning helper - runs on background thread
    private nonisolated static func scanForFilesSync(at url: URL, minSize: Int64) -> [FileItem] {
        let fileManager = FileManager.default
        var results: [FileItem] = []
        guard let enumerator = fileManager.enumerator(
            at: url,
            includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey],
            options: [.skipsHiddenFiles, .skipsPackageDescendants]
        ) else {
            return results
        }
        while let fileURL = enumerator.nextObject() as? URL {
            do {
                let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
                guard resourceValues.isDirectory == false else { continue }
                let size = Int64(resourceValues.fileSize ?? 0)
                if size >= minSize {
                    let item = FileItem(url: fileURL, isDirectory: false, size: size)
                    results.append(item)
                }
            } catch {
                continue
            }
        }
        return results.sorted { $0.size > $1.size }
    }
}
struct LargeFileRow: View {
    let file: FileItem
    let isSelected: Bool
    @State private var isHovered = false
    var body: some View {
        HStack(spacing: 12) {
            // Icon with glass effect
            ZStack {
                RoundedRectangle(cornerRadius: 8)
                    .fill(
                        .linearGradient(
                            colors: [
                                file.color.opacity(0.6),
                                file.color.opacity(0.3)
                            ],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
                    )
                    .frame(width: 40, height: 40)
                    .overlay(
                        RoundedRectangle(cornerRadius: 8)
                            .strokeBorder(.white.opacity(0.2), lineWidth: 1)
                    )
                Image(systemName: file.icon)
                    .foregroundStyle(.white)
            }
            VStack(alignment: .leading, spacing: 2) {
                Text(file.name)
                    .lineLimit(1)
                    .truncationMode(.middle)
                Text(file.url.deletingLastPathComponent().path)
                    .font(.caption)
                    .foregroundStyle(.tertiary)
                    .lineLimit(1)
                    .truncationMode(.middle)
            }
            Spacer()
            Text(file.formattedSize)
                .font(.system(.body, design: .rounded, weight: .medium))
                .foregroundStyle(.secondary)
        }
        .padding(8)
        .background(
            RoundedRectangle(cornerRadius: 10)
                .fill(isSelected ? Color.accentColor.opacity(0.1) : (isHovered ? Color.primary.opacity(0.03) : Color.clear))
        )
        .onHover { hovering in
            isHovered = hovering
        }
        .contextMenu {
            Button("Show in Finder") {
                NSWorkspace.shared.selectFile(file.url.path, inFileViewerRootedAtPath: file.url.deletingLastPathComponent().path)
            }
            Button("Move to Trash", role: .destructive) {
                do {
                    try FileManager.default.trashItem(at: file.url, resultingItemURL: nil)
                } catch {
                    print("Failed to trash: \(error)")
                }
            }
        }
    }
}
#Preview {
    LargeFilesView()
        .frame(width: 800, height: 600)
}