Internal tooling for Mac utility for storage management.
Swift
98.7%
JSON
1.1%
Markdown
0.2%
//
// 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)
}
About
Internal tooling for Mac utility for storage management.
0 stars
0 forks