fix: unify navigation context and feedback across tools

This commit is contained in:
Valdemar Lorenzen
2026-10-15 12:13:45 +02:00
parent 999cd73891
commit 6fd3931f3a
6 changed files with 238 additions and 44 deletions
+72 -21
View File
@@ -3,6 +3,12 @@ import ConjureKit
@MainActor
final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private enum DiscoveryState {
case loading
case ready
case failed(String)
}
private let application: Application
private let uninstaller: ApplicationUninstaller
private let search = NSSearchField()
@@ -13,6 +19,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
private var candidates: [UninstallCandidate] = []
private var visible: [UninstallCandidate] = []
private var selected: Set<URL> = []
private var discoveryState = DiscoveryState.loading
private var busy = false
private var scanTask: Task<Void, Never>?
private var window: NSWindow? { view.window }
@@ -30,6 +37,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
func present() {
guard let window else { return }
discoveryState = .loading
updateStatus()
window.makeFirstResponder(search)
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
scanTask = Task { [weak self, application] in
@@ -38,12 +47,10 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
try UninstallDiscovery.scan(application: application)
}.value
guard let self, !Task.isCancelled else { return }
candidates = found
selected = Set(found.filter { $0.isSelectable && $0.defaultSelected }.map(\.url))
reload()
applyDiscoveryResult(.success(found))
} catch {
guard let self, !Task.isCancelled else { return }
status.stringValue = "Could not inspect application files: \(error.localizedDescription)"
applyDiscoveryResult(.failure(error))
}
}
}
@@ -52,6 +59,12 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
let content = UninstallReviewContent()
content.wantsLayer = true
view = content
let context = NSTextField(wrappingLabelWithString: "Uninstall \(application.name)")
context.font = .systemFont(ofSize: 14, weight: .medium)
context.textColor = SunsetPalette.text
let guidance = NSTextField(wrappingLabelWithString: "Return toggles file inclusion · Command-Return opens confirmation")
guidance.font = .systemFont(ofSize: 12)
guidance.textColor = SunsetPalette.secondaryText
search.placeholderAttributedString = NSAttributedString(
string: "Filter files and folders by name or path…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)]
@@ -62,7 +75,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
}
search.delegate = self
search.sendsSearchStringImmediately = true
search.setAccessibilityLabel("Filter uninstall files")
search.setAccessibilityLabel("Filter uninstall files for \(application.name)")
search.setAccessibilityHelp(guidance.stringValue)
search.font = .systemFont(ofSize: 20)
search.textColor = SunsetPalette.text
search.isBordered = false
@@ -101,7 +115,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
table.dataSource = self
table.delegate = self
table.allowsEmptySelection = false
table.setAccessibilityLabel("Files to uninstall")
table.setAccessibilityLabel("Files to uninstall for \(application.name)")
table.toggle = { [weak self] in self?.toggleHighlighted() }
table.dismiss = { [weak self] in self?.goBack() }
let scroll = NSScrollView()
@@ -134,7 +148,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
let footer = NSStackView(views: [NSView(), pill])
footer.orientation = .horizontal
footer.spacing = 12
for view in [header, status, scroll, footer] {
for view in [header, context, status, guidance, scroll, footer] {
view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(view)
}
@@ -146,10 +160,16 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
back.widthAnchor.constraint(equalToConstant: 22),
back.heightAnchor.constraint(equalToConstant: 22),
search.widthAnchor.constraint(equalTo: header.widthAnchor, constant: -34),
context.leadingAnchor.constraint(equalTo: search.leadingAnchor),
context.trailingAnchor.constraint(equalTo: search.trailingAnchor),
context.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 12),
status.leadingAnchor.constraint(equalTo: search.leadingAnchor),
status.trailingAnchor.constraint(equalTo: search.trailingAnchor),
status.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 12),
scroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 12),
status.topAnchor.constraint(equalTo: context.bottomAnchor, constant: 6),
guidance.leadingAnchor.constraint(equalTo: search.leadingAnchor),
guidance.trailingAnchor.constraint(equalTo: search.trailingAnchor),
guidance.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 6),
scroll.topAnchor.constraint(equalTo: guidance.bottomAnchor, constant: 12),
scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12),
scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12),
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -16),
@@ -160,6 +180,23 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
])
}
func applyDiscoveryResult(_ result: Result<[UninstallCandidate], Error>, preservingSelection: Bool = false) {
switch result {
case .success(let found):
candidates = found
if preservingSelection {
selected.formIntersection(Set(found.filter(\.isSelectable).map(\.url)))
} else {
selected = Set(found.filter { $0.isSelectable && $0.defaultSelected }.map(\.url))
}
discoveryState = .ready
reload()
case .failure(let error):
discoveryState = .failed("Could not inspect application files: \(error.localizedDescription)")
updateStatus()
}
}
private func reload() {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visible = candidates.filter { query.isEmpty || $0.url.path.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil }
@@ -169,12 +206,22 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
}
private func updateStatus() {
let chosen = candidates.filter { selected.contains($0.url) }
let size = chosen.compactMap(\.size).reduce(Int64(0), +)
let unknown = chosen.contains { $0.size == nil } ? " (some sizes unavailable)" : ""
status.stringValue = "\(chosen.count) of \(candidates.count) files selected · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))\(unknown)"
if visible.isEmpty && !candidates.isEmpty { status.stringValue += " · No matching files" }
remove.isEnabled = !busy && !chosen.isEmpty
status.textColor = SunsetPalette.secondaryText
remove.isEnabled = false
switch discoveryState {
case .loading:
status.stringValue = "Finding associated files…"
case .failed(let message):
status.stringValue = message
status.textColor = .systemRed
case .ready:
let chosen = candidates.filter { selected.contains($0.url) }
let size = chosen.compactMap(\.size).reduce(Int64(0), +)
let unknown = chosen.contains { $0.size == nil } ? " (some sizes unavailable)" : ""
status.stringValue = "\(chosen.count) of \(candidates.count) files selected · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))\(unknown)"
if visible.isEmpty && !candidates.isEmpty { status.stringValue += " · No matching files" }
remove.isEnabled = !busy && !chosen.isEmpty
}
}
func controlTextDidChange(_ notification: Notification) { reload() }
@@ -256,7 +303,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
}
@objc private func confirmUninstall() {
guard !busy, !selected.isEmpty, let window else { return }
guard case .ready = discoveryState, !busy, !selected.isEmpty, let window else { return }
let urls = candidates.filter { $0.isSelectable && selected.contains($0.url) }.map(\.url)
guard !urls.isEmpty else { return }
let running = runningApplications()
@@ -274,6 +321,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
return
}
do {
status.textColor = SunsetPalette.secondaryText
status.stringValue = "Waiting for application to quit…"
for app in running where !app.isTerminated {
guard app.terminate() else { throw ReviewError("\(app.localizedName ?? application.name) could not quit. No files were moved.") }
@@ -296,15 +344,17 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
changed?()
await showFeedback("Could not uninstall \(application.name)", error.localizedDescription,
allowRecovery: !(error is ReviewError))
discoveryState = .loading
setBusy(false)
// Refresh identities and availability after a failed operation before another review.
do {
candidates = try await Task.detached { [application] in
let found = try await Task.detached { [application] in
try UninstallDiscovery.scan(application: application)
}.value
selected.formIntersection(Set(candidates.filter(\.isSelectable).map(\.url)))
reload()
} catch { status.stringValue = error.localizedDescription; remove.isEnabled = false }
applyDiscoveryResult(.success(found), preservingSelection: true)
} catch {
applyDiscoveryResult(.failure(error))
}
}
}
}
@@ -321,6 +371,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
alert.addButton(withTitle: "OK")
if canRecover { alert.addButton(withTitle: "Retry recovery") }
guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return }
status.textColor = SunsetPalette.secondaryText
status.stringValue = "Recovering pending uninstall…"
let report = await uninstaller.recover()
changed?()
@@ -334,7 +385,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
busy = value
search.isEnabled = !value
back.isEnabled = !value
remove.isEnabled = !value && !selected.isEmpty
remove.isEnabled = false
table.reloadData()
if !value {
updateStatus()