// // 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) }