Mac-utlity-app / MUA / Views / SpaceLens / SpaceLensContainer.swift Blame
335 lines
6dacfa4 Mitchel Jan 17, 2026
//
//  SpaceLensContainer.swift
//  MUA
//
//  Created by Mitchel Volkering on 21/12/2025.
//
import SwiftUI
import UniformTypeIdentifiers
/// Container view managing SpaceLens navigation and scanning
struct SpaceLensContainer: View {
    @State private var analyzer = DiskAnalyzer()
    @State private var navigationStack: [FileItem] = []
    @State private var selectedItem: FileItem?
    @State private var showFolderPicker = false
    @State private var currentSecurityScopedURL: URL?
    private var currentItem: FileItem? {
        navigationStack.last ?? analyzer.rootItem
    }
    private var displayItems: [FileItem] {
        currentItem?.children ?? []
    }
    private var breadcrumbs: [FileItem] {
        var crumbs: [FileItem] = []
        if let root = analyzer.rootItem {
            crumbs.append(root)
        }
        crumbs.append(contentsOf: navigationStack)
        return crumbs
    }
    var body: some View {
        VStack(spacing: 0) {
            // Toolbar area
            toolbarArea
            // Main content
            Group {
                if analyzer.isScanning {
                    scanningView
                } else if analyzer.rootItem != nil {
                    contentView
                } else {
                    welcomeView
                }
            }
        }
        .background(Color(nsColor: .windowBackgroundColor))
        .fileImporter(
            isPresented: $showFolderPicker,
            allowedContentTypes: [.folder],
            allowsMultipleSelection: false
        ) { result in
            switch result {
            case .success(let urls):
                if let url = urls.first {
                    // Stop accessing previous security-scoped resource if any
                    if let previousURL = currentSecurityScopedURL {
                        previousURL.stopAccessingSecurityScopedResource()
                    }
                    // For sandboxed apps, we need to access the security-scoped resource
                    let didStart = url.startAccessingSecurityScopedResource()
                    if didStart {
                        currentSecurityScopedURL = url
                    }
                    startScan(url: url)
                }
            case .failure(let error):
                analyzer.error = "Could not access folder: \(error.localizedDescription)"
            }
        }
        .onDisappear {
            // Clean up security-scoped resource access
            currentSecurityScopedURL?.stopAccessingSecurityScopedResource()
        }
    }
    private var toolbarArea: some View {
        HStack(spacing: 12) {
            // Breadcrumbs
            if !breadcrumbs.isEmpty {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 4) {
                        ForEach(Array(breadcrumbs.enumerated()), id: \.element.id) { index, item in
                            if index > 0 {
                                Image(systemName: "chevron.right")
                                    .font(.caption2)
                                    .foregroundStyle(.tertiary)
                            }
                            Button(item.name) {
                                navigateTo(index: index)
                            }
                            .buttonStyle(.plain)
                            .font(.subheadline)
                            .foregroundStyle(index == breadcrumbs.count - 1 ? .primary : .secondary)
                        }
                    }
                    .padding(.horizontal, 12)
                }
            }
            Spacer()
            // Error indicator
            if let error = analyzer.error {
                HStack(spacing: 4) {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundStyle(.orange)
                    Text(error)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                .padding(.horizontal, 8)
                .padding(.vertical, 4)
                .background(.orange.opacity(0.1), in: Capsule())
            }
            // Scan entire disk button
            Button {
                startScan(url: URL(fileURLWithPath: "/"))
            } label: {
                Label("Entire Disk", systemImage: "internaldrive")
            }
            .buttonStyle(.bordered)
            .controlSize(.small)
            // Scan button
            Button {
                showFolderPicker = true
            } label: {
                Label("Scan Folder", systemImage: "folder.badge.gearshape")
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.small)
            // Quick scan home
            Button {
                startScan(url: FileManager.default.homeDirectoryForCurrentUser)
            } label: {
                Label("Home", systemImage: "house")
            }
            .buttonStyle(.bordered)
            .controlSize(.small)
        }
        .padding(.horizontal)
        .padding(.vertical, 10)
        .background(.ultraThinMaterial)
    }
    private var scanningView: some View {
        VStack(spacing: 24) {
            // Animated scanning indicator
            ZStack {
                Circle()
                    .stroke(.quaternary, lineWidth: 8)
                    .frame(width: 100, height: 100)
                Circle()
                    .trim(from: 0, to: max(0.05, analyzer.scanProgress)) // Always show at least a small arc
                    .stroke(
                        .linearGradient(
                            colors: [.blue, .purple],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        ),
                        style: StrokeStyle(lineWidth: 8, lineCap: .round)
                    )
                    .frame(width: 100, height: 100)
                    .rotationEffect(.degrees(-90))
                    .animation(.easeInOut(duration: 0.3), value: analyzer.scanProgress)
                Image(systemName: "magnifyingglass")
                    .font(.system(size: 32))
                    .foregroundStyle(.secondary)
            }
            VStack(spacing: 8) {
                Text("Analyzing...")
                    .font(.title2)
                    .fontWeight(.medium)
                Text("\(analyzer.scannedCount) folders scanned")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
                    .monospacedDigit()
                Text(analyzer.currentPath)
                    .font(.caption)
                    .foregroundStyle(.tertiary)
                    .lineLimit(1)
                    .truncationMode(.middle)
                    .frame(maxWidth: 400)
            }
            Button("Cancel") {
                analyzer.cancel()
            }
            .buttonStyle(.bordered)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
    private var welcomeView: some View {
        VStack(spacing: 24) {
            // Glass orb icon
            ZStack {
                Circle()
                    .fill(
                        .linearGradient(
                            colors: [.blue.opacity(0.6), .purple.opacity(0.4)],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
                    )
                    .frame(width: 120, height: 120)
                    .overlay(
                        Circle()
                            .fill(
                                .linearGradient(
                                    colors: [.white.opacity(0.4), .clear],
                                    startPoint: .topLeading,
                                    endPoint: .center
                                )
                            )
                    )
                    .overlay(
                        Circle()
                            .strokeBorder(.white.opacity(0.3), lineWidth: 2)
                    )
                    .shadow(color: .blue.opacity(0.3), radius: 20)
                Image(systemName: "circle.hexagongrid.fill")
                    .font(.system(size: 48))
                    .foregroundStyle(.white)
            }
            VStack(spacing: 8) {
                Text("Space Lens")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                Text("Visualize what's taking up space on your disk")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
            HStack(spacing: 16) {
                Button {
                    startScan(url: FileManager.default.homeDirectoryForCurrentUser)
                } label: {
                    Label("Scan Home Folder", systemImage: "house.fill")
                }
                .buttonStyle(.borderedProminent)
                .controlSize(.large)
                Button {
                    showFolderPicker = true
                } label: {
                    Label("Choose Folder", systemImage: "folder")
                }
                .buttonStyle(.bordered)
                .controlSize(.large)
            }
            // Quick tips
            VStack(alignment: .leading, spacing: 8) {
                Label("Double-click bubbles to dive into folders", systemImage: "cursorarrow.click.2")
                Label("Click a bubble to see details", systemImage: "info.circle")
                Label("Larger bubbles = more space used", systemImage: "arrow.up.left.and.arrow.down.right")
            }
            .font(.caption)
            .foregroundStyle(.secondary)
            .padding(.top, 20)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
    private var contentView: some View {
        HSplitView {
            SpaceLensView(
                items: displayItems,
                parentItem: navigationStack.isEmpty ? nil : currentItem,
                selectedItem: $selectedItem,
                onNavigate: navigateInto,
                onNavigateUp: navigateUp
            )
            .frame(minWidth: 400)
            DetailPanelView(item: selectedItem)
        }
    }
    // MARK: - Navigation
    private func startScan(url: URL) {
        navigationStack = []
        selectedItem = nil
        Task {
            await analyzer.analyze(url: url)
        }
    }
    private func navigateInto(_ item: FileItem) {
        guard item.isDirectory else { return }
        navigationStack.append(item)
        selectedItem = nil
    }
    private func navigateUp() {
        guard !navigationStack.isEmpty else { return }
        _ = navigationStack.popLast()
        selectedItem = nil
    }
    private func navigateTo(index: Int) {
        if index == 0 {
            navigationStack = []
        } else {
            navigationStack = Array(navigationStack.prefix(index))
        }
        selectedItem = nil
    }
}
#Preview {
    SpaceLensContainer()
        .frame(width: 900, height: 600)
}