fix: unify navigation context and feedback across tools
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user