163 lines
6.1 KiB
Swift
163 lines
6.1 KiB
Swift
import SwiftUI
|
|
|
|
struct AppListView: View {
|
|
@EnvironmentObject var appState: AppState
|
|
@State private var searchText = ""
|
|
@State private var selection: InstalledApp.ID?
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var sortOrder: [KeyPathComparator<InstalledApp>] = [
|
|
.init(\.appName, order: .forward)
|
|
]
|
|
|
|
private var filteredApps: [InstalledApp] {
|
|
let base: [InstalledApp]
|
|
if searchText.isEmpty {
|
|
base = appState.installedApps
|
|
} else {
|
|
let query = searchText.lowercased()
|
|
base = appState.installedApps.filter {
|
|
$0.appName.lowercased().contains(query) ||
|
|
$0.bundleIdentifier.lowercased().contains(query)
|
|
}
|
|
}
|
|
return base.sorted(using: sortOrder)
|
|
}
|
|
|
|
var body: some View {
|
|
HSplitView {
|
|
// Cap the left pane's maxWidth so dragging the splitter cannot
|
|
// push it past half the window and break the layout (#60).
|
|
appTable
|
|
.frame(minWidth: 300, idealWidth: 380, maxWidth: 600)
|
|
|
|
fileDetail
|
|
.frame(minWidth: 300)
|
|
}
|
|
.searchable(text: $searchText, prompt: "Search apps")
|
|
.navigationTitle(installedAppsTitle)
|
|
.toolbar {
|
|
ToolbarItemGroup {
|
|
Button {
|
|
appState.loadInstalledApps()
|
|
} label: {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
}
|
|
|
|
// ToolbarItems can't run insertion transitions on macOS 13 —
|
|
// keep the button mounted and fade it with the selection.
|
|
Button(uninstallLabel(count: appState.selectedFiles.count), role: .destructive) {
|
|
appState.removeSelectedFiles()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(.red)
|
|
.opacity(appState.selectedFiles.isEmpty ? 0 : 1)
|
|
.disabled(appState.selectedFiles.isEmpty)
|
|
.animation(reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.8),
|
|
value: appState.selectedFiles.isEmpty)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var installedAppsTitle: String {
|
|
String(format: String(localized: "Installed Apps (%lld)"), Int64(appState.installedApps.count))
|
|
}
|
|
|
|
private func uninstallLabel(count: Int) -> String {
|
|
String(format: String(localized: "Uninstall (%lld files)"), Int64(count))
|
|
}
|
|
|
|
// MARK: - App Table (left side)
|
|
|
|
private var appTable: some View {
|
|
Group {
|
|
if appState.isLoadingApps {
|
|
VStack(spacing: 12) {
|
|
ProgressView(LocalizedStringKey("Loading installed apps..."))
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if appState.installedApps.isEmpty {
|
|
EmptyStateView(
|
|
"No Apps Found",
|
|
systemImage: "square.grid.2x2",
|
|
description: "Could not find any installed applications.",
|
|
action: { appState.loadInstalledApps() },
|
|
actionLabel: "Retry"
|
|
)
|
|
} else {
|
|
Table(filteredApps, selection: $selection, sortOrder: $sortOrder) {
|
|
TableColumn("Application", value: \.appName) { app in
|
|
HStack(spacing: 8) {
|
|
HoverScaleIcon(icon: app.icon)
|
|
Text(app.appName)
|
|
}
|
|
}
|
|
.width(min: 150)
|
|
|
|
TableColumn("Size", value: \.size) { app in
|
|
Text(app.formattedSize)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.width(ideal: 70)
|
|
}
|
|
.onChange(of: selection) { newValue in
|
|
guard let id = newValue,
|
|
let app = appState.installedApps.first(where: { $0.id == id })
|
|
else { return }
|
|
// Skip when the selection was just synced from an external
|
|
// (Finder Services) hand-off that already scanned this app,
|
|
// so we don't fire a redundant second scan.
|
|
guard appState.selectedApp?.id != app.id else { return }
|
|
appState.selectedApp = app
|
|
appState.scanForAppFiles(app)
|
|
}
|
|
.onChange(of: appState.selectedApp) { app in
|
|
// Reflect an externally-driven selection (Finder Services)
|
|
// in the table highlight.
|
|
if selection != app?.id { selection = app?.id }
|
|
}
|
|
.onAppear {
|
|
// Sync the highlight when this view mounts already pointed
|
|
// at an externally-selected app.
|
|
if selection != appState.selectedApp?.id {
|
|
selection = appState.selectedApp?.id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - File Detail (right side)
|
|
|
|
@ViewBuilder
|
|
private var fileDetail: some View {
|
|
if let app = appState.selectedApp {
|
|
AppFilesView(app: app)
|
|
} else {
|
|
EmptyStateView(
|
|
"Select an App",
|
|
systemImage: "cursorarrow.click.2",
|
|
description: "Select an app from the list to see all its related files across your system.",
|
|
tint: Tint.purple
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// App icon that scales up slightly on hover inside a Table cell.
|
|
private struct HoverScaleIcon: View {
|
|
let icon: NSImage
|
|
|
|
@State private var hovering = false
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
var body: some View {
|
|
Image(nsImage: icon)
|
|
.resizable()
|
|
.frame(width: 20, height: 20)
|
|
.scaleEffect(hovering && !reduceMotion ? 1.12 : 1)
|
|
.animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering)
|
|
.onHover { hovering = $0 }
|
|
}
|
|
}
|