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
+8
View File
@@ -61,6 +61,8 @@ Activation uses the native macOS hotkey API, without an Accessibility permission
Open Settings with `⌘,` and enable **Launch at login**. Conjure uses macOS's native login-item service and reads its actual registration status rather than storing a separate preference. If macOS requires approval, Settings explains this and offers **Open Login Items Settings…**. Registration errors are shown beside the control, and the checkbox returns to the actual system state. Install Conjure in `/Applications` before enabling it; disable the toggle to unregister it.
Shortcut modifier guidance uses secondary text rather than error red. Shortcut and login-operation failures and unresolved uninstall recovery use red feedback; ordinary instructions and completed recovery use secondary text.
Conjure recognizes the native [login-launch Apple event](https://developer.apple.com/documentation/coreservices/1556410-launch_apple_event_constants) and starts without showing a window or taking focus, while still registering its saved global shortcut. Opening it manually from Finder still shows the launcher, including when it is already running. If shortcut registration fails at login, the error is retained and shown in Settings when you next open Conjure manually. Normal manual launches continue to report startup errors immediately.
## Discovery and search
@@ -83,10 +85,14 @@ Applications with identical display names show a muted containing-folder hint be
Search for **Conjure Translate**, labeled **Command**, and open it with Return, double-click, or **Open Command**. The launcher fades into an English → Japanese translation tool; the existing search bar becomes the English input and retains the launcher's placeholder styling. Escape returns to the previous search.
The visible Back chevron and Escape return to the previous launcher query with search focused. Escape also works from the API-key field and translation output.
The command participates in the same text matching, usage ranking, and favorites as applications (`ct` matches its initials). Return, double-click, **Open Command**, numbered shortcuts, and **Actions → Open** all record an opening. Its Actions menu offers **Open**, **Favorite / Remove Favorite**, and **Reset ranking**, but not Finder, Quit, or Uninstall. Reset ranking clears usage without removing its favorite status.
Choose **MyMemory (no key)** or **OpenAI · gpt-6-luna** from the provider dropdown. MyMemory is the initial default, and the selection is saved locally. Switching providers cancels pending work and clears the previous translation; there is no automatic fallback. Press Return from the English input or click **Translate** in the bottom-right launcher-style action pill, beside its Return key badge, to send text to the selected provider.
Provider privacy information and MyMemory limits remain visible separately from transient feedback. Validation, Keychain, and service errors use red text; guidance, progress, and success use the secondary text color. Editing input or credentials clears stale output and error styling without sending a request.
[MyMemory](https://mymemory.translated.net/doc/spec.php) requires no account or key. Anonymous use allows 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php). Oversized input is rejected before sending; quota and service errors appear in the tool. Text is sent to a third-party translation service: avoid sensitive content.
If MyMemory's top translation is blank, Conjure uses the first nonblank exact English→Japanese match in that same response. Approximate matches are not substituted, and service/quota failures still surface as errors.
@@ -141,6 +147,8 @@ Select an application and open **Actions ⌘K**. **Open** is the first, default-
The red **Uninstall application** action morphs the existing launcher into a file-review screen with checkboxes, paths, sizes, and a selected-item count; it does not create another window or review sheet. A native 180-millisecond resize and content crossfade runs in both directions, or switches immediately with Reduce Motion. The filter uses a centered, intrinsic-height text field without built-in search/cancel icons, beside an explicit back chevron. Filtering files by name or path does not change file selections. Up/Down or Control-K/Control-J highlights files; Return toggles the highlighted eligible file, including from the filter input, and Space also toggles when the list has focus. Back or Escape restores the original launcher frame, query, and selection. Temporary review geometry is not saved as the launcher position. Leaving is blocked during confirmation or an uninstall operation. Command-Return or the uninstall button opens a native final confirmation dialog; nothing is moved merely by entering review. The confirmation control uses the launcher's frosted rounded pill with separate Command and Return key badges. The launcher Actions control likewise shows separate Command and K badges matching its Open Return badge.
The application's name and keyboard guidance remain visible throughout file review: Return toggles inclusion; Command-Return opens confirmation. Filtering preserves discovery progress or errors until discovery succeeds, then shows selection counts and no-match feedback without changing checked files.
Keyboard file toggles update the existing checkbox without reloading or fading the list, preserving both the highlighted row and scroll position. The launcher's footer Open and Actions labels use 12-point medium text. All custom shortcut hints—in the launcher footer, numbered application rows, uninstall footer, and actions selector—use `ShortcutIndicator` from `Sources/Conjure/ShortcutIndicator.swift` (for example, `ShortcutIndicator("⌘", "K")`). It owns 12-point medium key text, square 18×18-point containers, visible-glyph centering, border styling, and 2-point gaps between adjacent keys; callers position or hide the whole indicator, and numbered rows update their single key without rebuilding the cell. Native menus and shortcut-recording controls retain their platform styling.
Numbered row overlays use 13-point numbers in 22×22-point boxes with a lighter opaque warm-brown backing. Their label fade begins 36 points before the badge, twice the original lead-in; other shortcut hints retain their 12-point text and 18×18-point boxes.
@@ -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
}
}
+57 -16
View File
@@ -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
+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()
+6 -6
View File
@@ -4,12 +4,12 @@
### Navigation, context, and feedback
- [ ] Add a visible, accessible Back action to Translate, matching uninstall review; retain Escape and query/focus restoration.
- [ ] Keep the selected application's name visible throughout uninstall file review, not only in Actions and final confirmation.
- [ ] Preserve uninstall discovery loading/error feedback when filtering; show selection counts only after successful discovery.
- [ ] Standardize feedback severity: secondary text for guidance/progress and consistent error styling for actual failures. Shortcut modifier guidance should not appear red; login and translation failures should receive error treatment.
- [ ] Show persistent uninstall keyboard guidance: Return toggles file inclusion; Command-Return opens confirmation. Preserve the safer shortcut distinction.
- [ ] Separate Translate's persistent provider/privacy/limit disclosure from transient validation, credential, loading, success, and error feedback.
- [x] Add a visible, accessible Back action to Translate, matching uninstall review; retain Escape and query/focus restoration.
- [x] Keep the selected application's name visible throughout uninstall file review, not only in Actions and final confirmation.
- [x] Preserve uninstall discovery loading/error feedback when filtering; show selection counts only after successful discovery.
- [x] Standardize feedback severity: secondary text for guidance/progress and consistent error styling for actual failures. Shortcut modifier guidance should not appear red; login and translation failures should receive error treatment.
- [x] Show persistent uninstall keyboard guidance: Return toggles file inclusion; Command-Return opens confirmation. Preserve the safer shortcut distinction.
- [x] Separate Translate's persistent provider/privacy/limit disclosure from transient validation, credential, loading, success, and error feedback.
### Visual consistency
@@ -0,0 +1,89 @@
import AppKit
import ConjureKit
import Testing
@testable import Conjure
@MainActor
private func descendants(_ view: NSView) -> [NSView] {
[view] + view.subviews.flatMap { descendants($0) }
}
@Test @MainActor
func uninstallFilteringPreservesDiscoveryStateAndCheckedFiles() throws {
_ = NSApplication.shared
let controller = UninstallReviewController(
application: Application(url: URL(fileURLWithPath: "/Applications/Example.app"), name: "Example"),
uninstaller: ApplicationUninstaller())
let views = descendants(controller.view)
let filter = try #require(views.compactMap { $0 as? NSSearchField }.first)
let status = try #require(views.compactMap { $0 as? NSTextField }.first {
$0.accessibilityLabel() == "Uninstall selection and progress"
})
let table = try #require(views.compactMap { $0 as? NSTableView }.first)
let remove = try #require(views.compactMap { $0 as? NSButton }.first { $0.keyEquivalent == "\r" })
func filterBy(_ query: String) {
filter.stringValue = query
controller.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: filter))
}
let loading = status.stringValue
filterBy("cache")
#expect(status.stringValue == loading)
#expect(!remove.isEnabled)
struct ScanFailure: LocalizedError {
var errorDescription: String? { "Access denied by the filesystem" }
}
controller.applyDiscoveryResult(.failure(ScanFailure()))
let failure = status.stringValue
#expect(failure.contains(ScanFailure().localizedDescription))
#expect(status.textColor == .systemRed)
filterBy("different")
#expect(status.stringValue == failure)
#expect(!remove.isEnabled)
let candidate = UninstallCandidate(url: URL(fileURLWithPath: "/tmp/example-cache"), size: 1024,
isSelectable: true, defaultSelected: true, reason: nil)
controller.applyDiscoveryResult(.success([candidate]))
filterBy("")
let counts = status.stringValue
#expect(status.textColor == SunsetPalette.secondaryText)
#expect(remove.isEnabled)
#expect(table.numberOfRows == 1)
filterBy("no-match")
#expect(table.numberOfRows == 0)
#expect(remove.isEnabled)
filterBy("")
#expect(status.stringValue == counts)
#expect(table.numberOfRows == 1)
_ = controller.control(filter, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
#expect(!remove.isEnabled)
}
@Test @MainActor
func shortcutModifierGuidanceDoesNotAppearAsAnError() throws {
_ = NSApplication.shared
let suite = "Conjure.ToolFeedbackTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let controller = SettingsWindowController(shortcut: GlobalShortcut(defaults: defaults, action: {}))
let window = try #require(controller.window)
let views = descendants(try #require(window.contentView))
let recorder = try #require(views.compactMap { $0 as? NSButton }.first {
$0.accessibilityLabel() == "Record global shortcut"
})
let error = try #require(views.compactMap { $0 as? NSTextField }.first {
$0.accessibilityLabel() == "Shortcut feedback"
})
let status = try #require(views.compactMap { $0 as? NSTextField }.first {
$0.accessibilityLabel() == "Global shortcut registration status"
})
recorder.performClick(nil)
defer { window.close() }
#expect(window.firstResponder === recorder)
let modifier = try #require(NSEvent.keyEvent(with: .flagsChanged, location: .zero,
modifierFlags: .command, timestamp: 1, windowNumber: window.windowNumber,
context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 55))
recorder.flagsChanged(with: modifier)
#expect(error.stringValue.isEmpty)
#expect(status.textColor == SunsetPalette.secondaryText)
}