505 lines
18 KiB
Swift
505 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
/// View-side grouping of discovered leftovers into CleanMyMac-style buckets.
|
|
/// Purely presentational — AppState's flat `discoveredFiles` stays the source
|
|
/// of truth so the removal/selection logic is untouched.
|
|
enum LeftoverGroup: String, CaseIterable, Identifiable {
|
|
case application = "Application"
|
|
case caches = "Caches"
|
|
case appSupport = "Application Support"
|
|
case preferences = "Preferences"
|
|
case logs = "Logs"
|
|
case containers = "Containers"
|
|
case launchAgents = "Launch Agents"
|
|
case other = "Other Files"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .application: return "app.fill"
|
|
case .caches: return "internaldrive.fill"
|
|
case .appSupport: return "shippingbox.fill"
|
|
case .preferences: return "gearshape.fill"
|
|
case .logs: return "doc.text.fill"
|
|
case .containers: return "cube.box.fill"
|
|
case .launchAgents: return "bolt.fill"
|
|
case .other: return "doc.fill"
|
|
}
|
|
}
|
|
|
|
var tint: Color {
|
|
switch self {
|
|
case .application: return Tint.blue
|
|
case .caches: return Tint.orange
|
|
case .appSupport: return Tint.purple
|
|
case .preferences: return Tint.cyan
|
|
case .logs: return Tint.yellow
|
|
case .containers: return Tint.pink
|
|
case .launchAgents: return Tint.red
|
|
case .other: return Tint.green
|
|
}
|
|
}
|
|
|
|
static func categorize(_ url: URL) -> LeftoverGroup {
|
|
let path = url.path
|
|
if path.hasSuffix(".app") { return .application }
|
|
if path.contains("/Caches/") { return .caches }
|
|
if path.contains("/Application Support/") { return .appSupport }
|
|
if path.contains("/Preferences/") { return .preferences }
|
|
if path.contains("/Logs/") || path.contains("/DiagnosticReports/") { return .logs }
|
|
if path.contains("/Containers/") || path.contains("/Group Containers/") { return .containers }
|
|
if path.contains("/LaunchAgents/") || path.contains("/LaunchDaemons/") { return .launchAgents }
|
|
return .other
|
|
}
|
|
}
|
|
|
|
struct AppFilesView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
let app: InstalledApp
|
|
|
|
@State private var collapsedGroups: Set<LeftoverGroup> = []
|
|
@State private var iconHovering = false
|
|
/// One-pass size cache so group headers and the selected-size counter
|
|
/// don't re-stat the disk on every render.
|
|
@State private var sizeCache: [URL: Int64] = [:]
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
private var totalSelectedSize: Int64 {
|
|
appState.selectedFiles.reduce(Int64(0)) { total, url in
|
|
total + (cachedSize(url) ?? 0)
|
|
}
|
|
}
|
|
|
|
/// Discovered files bucketed for display, preserving the sorted order
|
|
/// inside each bucket. Only non-empty groups are shown.
|
|
private var groupedFiles: [(group: LeftoverGroup, urls: [URL])] {
|
|
let buckets = Dictionary(grouping: appState.discoveredFiles, by: LeftoverGroup.categorize)
|
|
return LeftoverGroup.allCases.compactMap { group in
|
|
guard let urls = buckets[group], !urls.isEmpty else { return nil }
|
|
return (group, urls)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
|
|
Divider()
|
|
|
|
// Content
|
|
if appState.isScanningAppFiles {
|
|
scanningState
|
|
} else if appState.discoveredFiles.isEmpty {
|
|
EmptyStateView(
|
|
"No Related Files",
|
|
systemImage: "checkmark.circle",
|
|
description: LocalizedStringKey(
|
|
String(format: String(localized: "No additional files found for %@."), app.appName)
|
|
),
|
|
tint: Tint.green
|
|
)
|
|
} else {
|
|
fileGroupsList
|
|
|
|
actionBar
|
|
}
|
|
}
|
|
.onAppear { rebuildSizeCache() }
|
|
.onChange(of: appState.discoveredFiles) { _ in rebuildSizeCache() }
|
|
.onChange(of: appState.removalNeedsFullDiskAccess) { needs in
|
|
// FDA-fixable removals jump straight into the rich sheet, the
|
|
// same flow cleanup uses. The user grants permission once and we
|
|
// re-fire the failed batch — using the frozen snapshot from
|
|
// AppState so a mid-sheet selection change or app switch doesn't
|
|
// re-trash the wrong files.
|
|
guard needs else { return }
|
|
let toRetry = appState.lastFailedRemovalURLs
|
|
let items = toRetry.map { appState.makeUninstallCleanableItem(for: $0) }
|
|
appState.removalError = nil
|
|
appState.removalNeedsFullDiskAccess = false
|
|
appState.lastFailedRemovalURLs = []
|
|
appState.requestFullDiskAccessAndRetry(
|
|
items: items,
|
|
context: .uninstall(appName: app.appName, failedCount: items.count)
|
|
)
|
|
}
|
|
.alert("Removal Failed", isPresented: Binding(
|
|
get: { appState.removalError != nil && !appState.removalNeedsFullDiskAccess },
|
|
set: {
|
|
if !$0 {
|
|
appState.removalError = nil
|
|
appState.removalNeedsFullDiskAccess = false
|
|
}
|
|
}
|
|
)) {
|
|
Button("OK", role: .cancel) {
|
|
appState.removalError = nil
|
|
appState.removalNeedsFullDiskAccess = false
|
|
}
|
|
} message: {
|
|
Text(appState.removalError ?? "")
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 12) {
|
|
Image(nsImage: app.icon)
|
|
.resizable()
|
|
.frame(width: 48, height: 48)
|
|
.scaleEffect(iconHovering && !reduceMotion ? 1.06 : 1)
|
|
.animation(reduceMotion ? nil : MotionTokens.snappy, value: iconHovering)
|
|
.onHover { iconHovering = $0 }
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(app.appName)
|
|
.font(.title3.bold())
|
|
Text(app.bundleIdentifier)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if !appState.discoveredFiles.isEmpty {
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
Text(filesCountText(count: appState.discoveredFiles.count))
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
.contentTransition(.numericText())
|
|
CountUpBytes(bytes: totalSelectedSize)
|
|
.font(.callout.bold())
|
|
}
|
|
.animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.9),
|
|
value: appState.discoveredFiles.count)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
// MARK: - Scanning state
|
|
|
|
private var scanningState: some View {
|
|
VStack(spacing: 14) {
|
|
Spacer()
|
|
if reduceMotion {
|
|
ProgressView(LocalizedStringKey("Scanning for related files..."))
|
|
} else {
|
|
SearchPulse()
|
|
Text("Scanning for related files...")
|
|
.font(.system(size: 13, weight: .medium))
|
|
}
|
|
Text(checkingLocationsText(count: appState.currentAppFileSearchLocationCount))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.monospacedDigit()
|
|
.contentTransition(.numericText())
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
// MARK: - Grouped list
|
|
|
|
private var fileGroupsList: some View {
|
|
List {
|
|
ForEach(groupedFiles, id: \.group) { entry in
|
|
DisclosureGroup(isExpanded: groupExpansionBinding(entry.group)) {
|
|
// No .staggered() inside the lazy List — a delayed reveal
|
|
// would blank rows as they scroll in. The removal
|
|
// transition still sweeps deleted rows out.
|
|
ForEach(entry.urls, id: \.self) { fileURL in
|
|
FileRow(
|
|
fileURL: fileURL,
|
|
isSelected: fileSelectionBinding(for: fileURL),
|
|
fileSize: cachedSize(fileURL),
|
|
onRemove: { removeSingleFile(fileURL) }
|
|
)
|
|
.transition(
|
|
reduceMotion
|
|
? .opacity
|
|
: .asymmetric(
|
|
insertion: .opacity,
|
|
removal: .move(edge: .leading).combined(with: .opacity)
|
|
)
|
|
)
|
|
}
|
|
} label: {
|
|
groupHeader(entry.group, urls: entry.urls)
|
|
}
|
|
}
|
|
}
|
|
.id(app.id)
|
|
}
|
|
|
|
private func groupHeader(_ group: LeftoverGroup, urls: [URL]) -> some View {
|
|
let groupSize = urls.reduce(Int64(0)) { $0 + (cachedSize($1) ?? 0) }
|
|
let allSelected = urls.allSatisfy { appState.selectedFiles.contains($0) }
|
|
|
|
return HStack(spacing: 10) {
|
|
Toggle(isOn: Binding(
|
|
get: { allSelected },
|
|
set: { selected in
|
|
if selected {
|
|
appState.selectedFiles.formUnion(urls)
|
|
} else {
|
|
appState.selectedFiles.subtract(urls)
|
|
}
|
|
}
|
|
)) {
|
|
EmptyView()
|
|
}
|
|
.toggleStyle(AnimatedCheckboxStyle())
|
|
.labelsHidden()
|
|
|
|
IconTile(systemName: group.icon, tint: group.tint, size: 22, corner: 6)
|
|
Text(LocalizedStringKey(group.rawValue))
|
|
.font(.system(size: 12.5, weight: .semibold))
|
|
Text("\(urls.count)")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 1)
|
|
.background(Capsule().fill(Color.primary.opacity(0.06)))
|
|
Spacer()
|
|
Text(ByteCountFormatter.string(fromByteCount: groupSize, countStyle: .file))
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
private func groupExpansionBinding(_ group: LeftoverGroup) -> Binding<Bool> {
|
|
Binding(
|
|
get: { !collapsedGroups.contains(group) },
|
|
set: { expanded in
|
|
let change = {
|
|
if expanded {
|
|
collapsedGroups.remove(group)
|
|
} else {
|
|
collapsedGroups.insert(group)
|
|
}
|
|
}
|
|
if reduceMotion {
|
|
change()
|
|
} else {
|
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { change() }
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// MARK: - Action bar
|
|
|
|
private var actionBar: some View {
|
|
HStack {
|
|
Button("Select All") {
|
|
appState.selectedFiles = Set(appState.discoveredFiles)
|
|
}
|
|
Button("Deselect All") {
|
|
appState.selectedFiles.removeAll()
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if !appState.selectedFiles.isEmpty {
|
|
Button(role: .destructive) {
|
|
appState.removeSelectedFiles()
|
|
} label: {
|
|
Text(removeFilesLabel)
|
|
}
|
|
.buttonStyle(GlowProminentButtonStyle(tint: Tint.red, gradient: TintGradient.destructive))
|
|
.transition(
|
|
reduceMotion
|
|
? .opacity
|
|
: .move(edge: .trailing).combined(with: .opacity)
|
|
)
|
|
}
|
|
}
|
|
.animation(reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.8),
|
|
value: appState.selectedFiles.isEmpty)
|
|
.padding()
|
|
.background(.bar)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func filesCountText(count: Int) -> String {
|
|
String(format: String(localized: "%lld files"), Int64(count))
|
|
}
|
|
|
|
private func checkingLocationsText(count: Int) -> String {
|
|
String(format: String(localized: "Checking %lld locations..."), Int64(count))
|
|
}
|
|
|
|
private var removeFilesLabel: String {
|
|
String(
|
|
format: String(localized: "Remove %lld files (%@)"),
|
|
Int64(appState.selectedFiles.count),
|
|
ByteCountFormatter.string(fromByteCount: totalSelectedSize, countStyle: .file)
|
|
)
|
|
}
|
|
|
|
private func fileSelectionBinding(for url: URL) -> Binding<Bool> {
|
|
Binding(
|
|
get: { appState.selectedFiles.contains(url) },
|
|
set: { selected in
|
|
if selected {
|
|
appState.selectedFiles.insert(url)
|
|
} else {
|
|
appState.selectedFiles.remove(url)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func cachedSize(_ url: URL) -> Int64? {
|
|
if let cached = sizeCache[url] { return cached }
|
|
return fileSize(url)
|
|
}
|
|
|
|
private func rebuildSizeCache() {
|
|
var cache: [URL: Int64] = [:]
|
|
for url in appState.discoveredFiles {
|
|
cache[url] = fileSize(url) ?? 0
|
|
}
|
|
sizeCache = cache
|
|
}
|
|
|
|
private func fileSize(_ url: URL) -> Int64? {
|
|
FileSizeCalculator.size(of: url)
|
|
}
|
|
|
|
private func removeSingleFile(_ url: URL) {
|
|
appState.selectedFiles = [url]
|
|
appState.removeSelectedFiles()
|
|
}
|
|
}
|
|
|
|
/// Magnifier over expanding sonar rings — the "actively searching" beat for
|
|
/// the related-files scan. Only built when Reduce Motion is off.
|
|
private struct SearchPulse: View {
|
|
@State private var pulse = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
ForEach(0..<2, id: \.self) { i in
|
|
Circle()
|
|
.stroke(Tint.blue.opacity(0.35), lineWidth: 1.5)
|
|
.frame(width: 44, height: 44)
|
|
.scaleEffect(pulse ? 1.8 : 1.0)
|
|
.opacity(pulse ? 0 : 0.7)
|
|
.animation(
|
|
.easeOut(duration: 1.4)
|
|
.repeatForever(autoreverses: false)
|
|
.delay(Double(i) * 0.7),
|
|
value: pulse
|
|
)
|
|
}
|
|
Circle()
|
|
.fill(Tint.blue.opacity(0.12))
|
|
.frame(width: 44, height: 44)
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(Tint.blue)
|
|
}
|
|
.frame(width: 56, height: 56)
|
|
.onAppear { pulse = true }
|
|
}
|
|
}
|
|
|
|
// MARK: - File Row with hover-to-reveal actions
|
|
|
|
struct FileRow: View {
|
|
let fileURL: URL
|
|
@Binding var isSelected: Bool
|
|
let fileSize: Int64?
|
|
let onRemove: () -> Void
|
|
|
|
@State private var isHovering = false
|
|
@State private var showConfirmation = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
Toggle(isOn: $isSelected) {
|
|
HStack {
|
|
Image(nsImage: NSWorkspace.shared.icon(forFile: fileURL.path))
|
|
.resizable()
|
|
.frame(width: 16, height: 16)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(fileURL.lastPathComponent)
|
|
.lineLimit(1)
|
|
Text(fileURL.path)
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Buttons stay in the layout permanently and fade with
|
|
// hover, so the trailing size badge never jumps sideways.
|
|
Button {
|
|
NSWorkspace.shared.selectFile(fileURL.path, inFileViewerRootedAtPath: "")
|
|
} label: {
|
|
Image(systemName: "folder")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Reveal in Finder")
|
|
.opacity(isHovering ? 1 : 0)
|
|
.scaleEffect(reduceMotion ? 1 : (isHovering ? 1 : 0.8))
|
|
.allowsHitTesting(isHovering)
|
|
|
|
Button {
|
|
showConfirmation = true
|
|
} label: {
|
|
Image(systemName: "trash")
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Remove this file")
|
|
.opacity(isHovering ? 1 : 0)
|
|
.scaleEffect(reduceMotion ? 1 : (isHovering ? 1 : 0.8))
|
|
.allowsHitTesting(isHovering)
|
|
|
|
if let size = fileSize {
|
|
Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.toggleStyle(AnimatedCheckboxStyle())
|
|
.padding(.vertical, 2)
|
|
.padding(.horizontal, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(isHovering ? Color.primary.opacity(0.06) : Color.clear)
|
|
)
|
|
.scaleEffect(isHovering && !reduceMotion ? 1.01 : 1)
|
|
.animation(reduceMotion ? nil : MotionTokens.snappy, value: isHovering)
|
|
.onHover { isHovering = $0 }
|
|
.alert(
|
|
Text(
|
|
String(format: String(localized: "Remove %@?"), fileURL.lastPathComponent)
|
|
),
|
|
isPresented: $showConfirmation
|
|
) {
|
|
Button("Cancel", role: .cancel) {}
|
|
Button("Remove", role: .destructive) { onRemove() }
|
|
} message: {
|
|
Text("This will permanently delete this file. This action cannot be undone.")
|
|
}
|
|
}
|
|
}
|