fix: unify navigation context and feedback across tools
This commit is contained in:
@@ -79,7 +79,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
recorder.capture = { [weak self] event in self?.capture(event) }
|
||||
recorder.cancel = { [weak self] in self?.cancelRecording() }
|
||||
recorder.modifiersChanged = { [weak self] in
|
||||
self?.errorLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels."
|
||||
self?.errorLabel.stringValue = ""
|
||||
self?.statusLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels."
|
||||
}
|
||||
let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace))
|
||||
commandSpaceButton.bezelStyle = .rounded
|
||||
@@ -175,6 +176,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
recoveryLabel.stringValue = report.needsRecovery
|
||||
? "An interrupted uninstall needs attention. Files have been retained for recovery."
|
||||
: "Interrupted uninstall recovery completed."
|
||||
recoveryLabel.textColor = report.needsRecovery ? .systemRed : SunsetPalette.secondaryText
|
||||
recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…"
|
||||
}
|
||||
|
||||
@@ -197,6 +199,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
|
||||
private func refreshLogin() {
|
||||
let status = SMAppService.mainApp.status
|
||||
loginStatus.textColor = SunsetPalette.secondaryText
|
||||
loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off
|
||||
loginSettings.isHidden = status != .requiresApproval
|
||||
switch status {
|
||||
@@ -210,6 +213,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login."
|
||||
@unknown default:
|
||||
loginStatus.stringValue = "Launch at login status is unavailable."
|
||||
loginStatus.textColor = .systemRed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +229,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
} catch {
|
||||
refreshLogin()
|
||||
loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)"
|
||||
loginStatus.textColor = .systemRed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import ConjureKit
|
||||
import Security
|
||||
|
||||
@MainActor
|
||||
final class TranslationController: NSViewController {
|
||||
final class TranslationController: NSViewController, NSTextFieldDelegate, NSTextViewDelegate {
|
||||
private let keyField = NSSecureTextField()
|
||||
private let provider = NSPopUpButton()
|
||||
private let credentials = NSStackView()
|
||||
private var usesMyMemory: Bool { provider.indexOfSelectedItem == 0 }
|
||||
private let output = NSTextView()
|
||||
private let disclosure = NSTextField(wrappingLabelWithString: "")
|
||||
private let status = NSTextField(wrappingLabelWithString: "")
|
||||
private let translateButton = NSButton(title: "Translate", target: nil, action: nil)
|
||||
private var task: Task<Void, Never>?
|
||||
@@ -24,7 +25,16 @@ final class TranslationController: NSViewController {
|
||||
view = NSView()
|
||||
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
|
||||
title.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
let heading = NSStackView(views: [title, NSView()])
|
||||
let back = NSButton(title: "", target: self, action: #selector(goBack))
|
||||
back.image = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil)
|
||||
back.imageScaling = .scaleProportionallyDown
|
||||
back.isBordered = false
|
||||
back.contentTintColor = SunsetPalette.secondaryText
|
||||
back.setAccessibilityLabel("Back to applications")
|
||||
back.toolTip = "Back to applications (Escape)"
|
||||
let heading = NSStackView(views: [back, title, NSView()])
|
||||
heading.alignment = .centerY
|
||||
heading.spacing = 12
|
||||
provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"])
|
||||
provider.selectItem(at: UserDefaults.standard.string(forKey: "translationProvider") == "openai" ? 1 : 0)
|
||||
provider.setAccessibilityLabel("Translation provider")
|
||||
@@ -32,6 +42,7 @@ final class TranslationController: NSViewController {
|
||||
provider.action = #selector(providerChanged)
|
||||
keyField.placeholderString = "OpenAI API key (saved in Keychain)"
|
||||
keyField.setAccessibilityLabel("OpenAI API key")
|
||||
keyField.delegate = self
|
||||
keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true
|
||||
let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey))
|
||||
let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey))
|
||||
@@ -73,6 +84,7 @@ final class TranslationController: NSViewController {
|
||||
output.textColor = SunsetPalette.text
|
||||
output.font = .systemFont(ofSize: 20)
|
||||
output.setAccessibilityLabel("Japanese translation")
|
||||
output.delegate = self
|
||||
let scroll = NSScrollView()
|
||||
scroll.documentView = output
|
||||
scroll.hasVerticalScroller = true
|
||||
@@ -80,15 +92,21 @@ final class TranslationController: NSViewController {
|
||||
output.isVerticallyResizable = true
|
||||
output.autoresizingMask = [.width]
|
||||
output.textContainer?.widthTracksTextView = true
|
||||
status.textColor = SunsetPalette.secondaryText
|
||||
disclosure.textColor = SunsetPalette.secondaryText
|
||||
disclosure.font = .systemFont(ofSize: 12)
|
||||
disclosure.setAccessibilityLabel("Translation provider, privacy, and limits")
|
||||
status.font = .systemFont(ofSize: 12)
|
||||
let stack = NSStackView(views: [heading, provider, credentials, scroll, status])
|
||||
status.setAccessibilityLabel("Translation status and errors")
|
||||
let stack = NSStackView(views: [heading, provider, disclosure, credentials, scroll, status])
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 12
|
||||
stack.setCustomSpacing(4, after: provider)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
back.widthAnchor.constraint(equalToConstant: 22),
|
||||
back.heightAnchor.constraint(equalToConstant: 22),
|
||||
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||
stack.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
@@ -98,6 +116,7 @@ final class TranslationController: NSViewController {
|
||||
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
|
||||
footer.heightAnchor.constraint(equalToConstant: 36),
|
||||
credentials.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
||||
disclosure.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
||||
scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
||||
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
|
||||
status.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
||||
@@ -105,6 +124,22 @@ final class TranslationController: NSViewController {
|
||||
updateProvider()
|
||||
}
|
||||
|
||||
override func cancelOperation(_ sender: Any?) { goBack() }
|
||||
|
||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
||||
guard selector == #selector(NSResponder.cancelOperation(_:)) else { return false }
|
||||
goBack()
|
||||
return true
|
||||
}
|
||||
|
||||
func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
||||
guard selector == #selector(NSResponder.cancelOperation(_:)) else { return false }
|
||||
goBack()
|
||||
return true
|
||||
}
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) { inputChanged() }
|
||||
|
||||
@objc func goBack() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
@@ -116,9 +151,7 @@ final class TranslationController: NSViewController {
|
||||
task = nil
|
||||
translateButton.isEnabled = true
|
||||
output.string = ""
|
||||
status.stringValue = usesMyMemory
|
||||
? "MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory; avoid sensitive content."
|
||||
: "OpenAI · gpt-6-luna · Text is sent to OpenAI only when you translate."
|
||||
setStatus("Press Return to translate.")
|
||||
}
|
||||
|
||||
@objc private func providerChanged() {
|
||||
@@ -129,13 +162,16 @@ final class TranslationController: NSViewController {
|
||||
|
||||
private func updateProvider() {
|
||||
credentials.isHidden = usesMyMemory
|
||||
disclosure.stringValue = usesMyMemory
|
||||
? "MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory; avoid sensitive content."
|
||||
: "OpenAI · gpt-6-luna · Text is sent to OpenAI only when you translate."
|
||||
inputChanged()
|
||||
}
|
||||
|
||||
@objc private func saveKey() {
|
||||
let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty, !key.contains(where: { $0.isNewline }) else {
|
||||
status.stringValue = "Enter a valid API key before saving."
|
||||
setStatus("Enter a valid API key before saving.", isError: true)
|
||||
return
|
||||
}
|
||||
let data = Data(key.utf8)
|
||||
@@ -148,7 +184,7 @@ final class TranslationController: NSViewController {
|
||||
}
|
||||
if result == errSecSuccess {
|
||||
keyField.stringValue = ""
|
||||
status.stringValue = "API key saved in Keychain. Press Return to translate."
|
||||
setStatus("API key saved in Keychain. Press Return to translate.")
|
||||
} else { showKeychainError(result) }
|
||||
}
|
||||
|
||||
@@ -159,19 +195,24 @@ final class TranslationController: NSViewController {
|
||||
let result = SecItemDelete(Self.credential as CFDictionary)
|
||||
if result == errSecSuccess || result == errSecItemNotFound {
|
||||
keyField.stringValue = ""
|
||||
status.stringValue = "API key removed."
|
||||
setStatus("API key removed.")
|
||||
} else { showKeychainError(result) }
|
||||
}
|
||||
|
||||
private func showKeychainError(_ code: OSStatus) {
|
||||
status.stringValue = "Keychain: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))"
|
||||
setStatus("Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))", isError: true)
|
||||
}
|
||||
|
||||
private func setStatus(_ message: String, isError: Bool = false) {
|
||||
status.stringValue = message
|
||||
status.textColor = isError ? .systemRed : SunsetPalette.secondaryText
|
||||
}
|
||||
|
||||
@objc func translate() {
|
||||
guard task == nil else { return }
|
||||
let text = input?() ?? ""
|
||||
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
status.stringValue = "Enter English text in the search bar."
|
||||
setStatus("Enter English text in the search bar.", isError: true)
|
||||
return
|
||||
}
|
||||
let myMemory = usesMyMemory
|
||||
@@ -183,14 +224,14 @@ final class TranslationController: NSViewController {
|
||||
var item: CFTypeRef?
|
||||
let result = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard result == errSecSuccess, let data = item as? Data, let savedKey = String(data: data, encoding: .utf8) else {
|
||||
if result == errSecItemNotFound { status.stringValue = "Enter and save your OpenAI API key first." }
|
||||
if result == errSecItemNotFound { setStatus("Enter and save your OpenAI API key first.", isError: true) }
|
||||
else { showKeychainError(result) }
|
||||
return
|
||||
}
|
||||
key = savedKey
|
||||
}
|
||||
output.string = ""
|
||||
status.stringValue = "Translating…"
|
||||
setStatus("Translating…")
|
||||
translateButton.isEnabled = false
|
||||
task = Task { [weak self] in
|
||||
do {
|
||||
@@ -199,10 +240,10 @@ final class TranslationController: NSViewController {
|
||||
: TranslationAPI.translate(text, apiKey: key)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.output.string = translation
|
||||
self.status.stringValue = "Japanese · Select text to copy (⌘C)."
|
||||
self.setStatus("Japanese · Select text to copy (⌘C).")
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.status.stringValue = error.localizedDescription
|
||||
self.setStatus("Translation failed: \(error.localizedDescription)", isError: true)
|
||||
}
|
||||
guard let self else { return }
|
||||
self.task = nil
|
||||
|
||||
@@ -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