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. 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. 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 ## 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. 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. 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. 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. [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. 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 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. 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. 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.capture = { [weak self] event in self?.capture(event) }
recorder.cancel = { [weak self] in self?.cancelRecording() } recorder.cancel = { [weak self] in self?.cancelRecording() }
recorder.modifiersChanged = { [weak self] in 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)) let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace))
commandSpaceButton.bezelStyle = .rounded commandSpaceButton.bezelStyle = .rounded
@@ -175,6 +176,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
recoveryLabel.stringValue = report.needsRecovery recoveryLabel.stringValue = report.needsRecovery
? "An interrupted uninstall needs attention. Files have been retained for recovery." ? "An interrupted uninstall needs attention. Files have been retained for recovery."
: "Interrupted uninstall recovery completed." : "Interrupted uninstall recovery completed."
recoveryLabel.textColor = report.needsRecovery ? .systemRed : SunsetPalette.secondaryText
recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…" recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…"
} }
@@ -197,6 +199,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
private func refreshLogin() { private func refreshLogin() {
let status = SMAppService.mainApp.status let status = SMAppService.mainApp.status
loginStatus.textColor = SunsetPalette.secondaryText
loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off
loginSettings.isHidden = status != .requiresApproval loginSettings.isHidden = status != .requiresApproval
switch status { switch status {
@@ -210,6 +213,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login." loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login."
@unknown default: @unknown default:
loginStatus.stringValue = "Launch at login status is unavailable." loginStatus.stringValue = "Launch at login status is unavailable."
loginStatus.textColor = .systemRed
} }
} }
@@ -225,6 +229,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
} catch { } catch {
refreshLogin() refreshLogin()
loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)" 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 import Security
@MainActor @MainActor
final class TranslationController: NSViewController { final class TranslationController: NSViewController, NSTextFieldDelegate, NSTextViewDelegate {
private let keyField = NSSecureTextField() private let keyField = NSSecureTextField()
private let provider = NSPopUpButton() private let provider = NSPopUpButton()
private let credentials = NSStackView() private let credentials = NSStackView()
private var usesMyMemory: Bool { provider.indexOfSelectedItem == 0 } private var usesMyMemory: Bool { provider.indexOfSelectedItem == 0 }
private let output = NSTextView() private let output = NSTextView()
private let disclosure = NSTextField(wrappingLabelWithString: "")
private let status = NSTextField(wrappingLabelWithString: "") private let status = NSTextField(wrappingLabelWithString: "")
private let translateButton = NSButton(title: "Translate", target: nil, action: nil) private let translateButton = NSButton(title: "Translate", target: nil, action: nil)
private var task: Task<Void, Never>? private var task: Task<Void, Never>?
@@ -24,7 +25,16 @@ final class TranslationController: NSViewController {
view = NSView() view = NSView()
let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese") let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese")
title.font = .systemFont(ofSize: 13, weight: .semibold) 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.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"])
provider.selectItem(at: UserDefaults.standard.string(forKey: "translationProvider") == "openai" ? 1 : 0) provider.selectItem(at: UserDefaults.standard.string(forKey: "translationProvider") == "openai" ? 1 : 0)
provider.setAccessibilityLabel("Translation provider") provider.setAccessibilityLabel("Translation provider")
@@ -32,6 +42,7 @@ final class TranslationController: NSViewController {
provider.action = #selector(providerChanged) provider.action = #selector(providerChanged)
keyField.placeholderString = "OpenAI API key (saved in Keychain)" keyField.placeholderString = "OpenAI API key (saved in Keychain)"
keyField.setAccessibilityLabel("OpenAI API key") keyField.setAccessibilityLabel("OpenAI API key")
keyField.delegate = self
keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true
let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey)) let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey))
let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey)) let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey))
@@ -73,6 +84,7 @@ final class TranslationController: NSViewController {
output.textColor = SunsetPalette.text output.textColor = SunsetPalette.text
output.font = .systemFont(ofSize: 20) output.font = .systemFont(ofSize: 20)
output.setAccessibilityLabel("Japanese translation") output.setAccessibilityLabel("Japanese translation")
output.delegate = self
let scroll = NSScrollView() let scroll = NSScrollView()
scroll.documentView = output scroll.documentView = output
scroll.hasVerticalScroller = true scroll.hasVerticalScroller = true
@@ -80,15 +92,21 @@ final class TranslationController: NSViewController {
output.isVerticallyResizable = true output.isVerticallyResizable = true
output.autoresizingMask = [.width] output.autoresizingMask = [.width]
output.textContainer?.widthTracksTextView = true 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) 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.orientation = .vertical
stack.alignment = .leading stack.alignment = .leading
stack.spacing = 12 stack.spacing = 12
stack.setCustomSpacing(4, after: provider)
stack.translatesAutoresizingMaskIntoConstraints = false stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack) view.addSubview(stack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
back.widthAnchor.constraint(equalToConstant: 22),
back.heightAnchor.constraint(equalToConstant: 22),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: view.topAnchor), stack.topAnchor.constraint(equalTo: view.topAnchor),
@@ -98,6 +116,7 @@ final class TranslationController: NSViewController {
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
footer.heightAnchor.constraint(equalToConstant: 36), footer.heightAnchor.constraint(equalToConstant: 36),
credentials.widthAnchor.constraint(equalTo: stack.widthAnchor), credentials.widthAnchor.constraint(equalTo: stack.widthAnchor),
disclosure.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.widthAnchor.constraint(equalTo: stack.widthAnchor), scroll.widthAnchor.constraint(equalTo: stack.widthAnchor),
scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100), scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100),
status.widthAnchor.constraint(equalTo: stack.widthAnchor), status.widthAnchor.constraint(equalTo: stack.widthAnchor),
@@ -105,6 +124,22 @@ final class TranslationController: NSViewController {
updateProvider() 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() { @objc func goBack() {
task?.cancel() task?.cancel()
task = nil task = nil
@@ -116,9 +151,7 @@ final class TranslationController: NSViewController {
task = nil task = nil
translateButton.isEnabled = true translateButton.isEnabled = true
output.string = "" output.string = ""
status.stringValue = usesMyMemory setStatus("Press Return to translate.")
? "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."
} }
@objc private func providerChanged() { @objc private func providerChanged() {
@@ -129,13 +162,16 @@ final class TranslationController: NSViewController {
private func updateProvider() { private func updateProvider() {
credentials.isHidden = usesMyMemory 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() inputChanged()
} }
@objc private func saveKey() { @objc private func saveKey() {
let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !key.contains(where: { $0.isNewline }) else { 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 return
} }
let data = Data(key.utf8) let data = Data(key.utf8)
@@ -148,7 +184,7 @@ final class TranslationController: NSViewController {
} }
if result == errSecSuccess { if result == errSecSuccess {
keyField.stringValue = "" 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) } } else { showKeychainError(result) }
} }
@@ -159,19 +195,24 @@ final class TranslationController: NSViewController {
let result = SecItemDelete(Self.credential as CFDictionary) let result = SecItemDelete(Self.credential as CFDictionary)
if result == errSecSuccess || result == errSecItemNotFound { if result == errSecSuccess || result == errSecItemNotFound {
keyField.stringValue = "" keyField.stringValue = ""
status.stringValue = "API key removed." setStatus("API key removed.")
} else { showKeychainError(result) } } else { showKeychainError(result) }
} }
private func showKeychainError(_ code: OSStatus) { 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() { @objc func translate() {
guard task == nil else { return } guard task == nil else { return }
let text = input?() ?? "" let text = input?() ?? ""
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { 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 return
} }
let myMemory = usesMyMemory let myMemory = usesMyMemory
@@ -183,14 +224,14 @@ final class TranslationController: NSViewController {
var item: CFTypeRef? var item: CFTypeRef?
let result = SecItemCopyMatching(query as CFDictionary, &item) let result = SecItemCopyMatching(query as CFDictionary, &item)
guard result == errSecSuccess, let data = item as? Data, let savedKey = String(data: data, encoding: .utf8) else { 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) } else { showKeychainError(result) }
return return
} }
key = savedKey key = savedKey
} }
output.string = "" output.string = ""
status.stringValue = "Translating…" setStatus("Translating…")
translateButton.isEnabled = false translateButton.isEnabled = false
task = Task { [weak self] in task = Task { [weak self] in
do { do {
@@ -199,10 +240,10 @@ final class TranslationController: NSViewController {
: TranslationAPI.translate(text, apiKey: key) : TranslationAPI.translate(text, apiKey: key)
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.output.string = translation self.output.string = translation
self.status.stringValue = "Japanese · Select text to copy (⌘C)." self.setStatus("Japanese · Select text to copy (⌘C).")
} catch { } catch {
guard !Task.isCancelled, let self else { return } 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 } guard let self else { return }
self.task = nil self.task = nil
+72 -21
View File
@@ -3,6 +3,12 @@ import ConjureKit
@MainActor @MainActor
final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private enum DiscoveryState {
case loading
case ready
case failed(String)
}
private let application: Application private let application: Application
private let uninstaller: ApplicationUninstaller private let uninstaller: ApplicationUninstaller
private let search = NSSearchField() private let search = NSSearchField()
@@ -13,6 +19,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
private var candidates: [UninstallCandidate] = [] private var candidates: [UninstallCandidate] = []
private var visible: [UninstallCandidate] = [] private var visible: [UninstallCandidate] = []
private var selected: Set<URL> = [] private var selected: Set<URL> = []
private var discoveryState = DiscoveryState.loading
private var busy = false private var busy = false
private var scanTask: Task<Void, Never>? private var scanTask: Task<Void, Never>?
private var window: NSWindow? { view.window } private var window: NSWindow? { view.window }
@@ -30,6 +37,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
func present() { func present() {
guard let window else { return } guard let window else { return }
discoveryState = .loading
updateStatus()
window.makeFirstResponder(search) window.makeFirstResponder(search)
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent (search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
scanTask = Task { [weak self, application] in scanTask = Task { [weak self, application] in
@@ -38,12 +47,10 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
try UninstallDiscovery.scan(application: application) try UninstallDiscovery.scan(application: application)
}.value }.value
guard let self, !Task.isCancelled else { return } guard let self, !Task.isCancelled else { return }
candidates = found applyDiscoveryResult(.success(found))
selected = Set(found.filter { $0.isSelectable && $0.defaultSelected }.map(\.url))
reload()
} catch { } catch {
guard let self, !Task.isCancelled else { return } 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() let content = UninstallReviewContent()
content.wantsLayer = true content.wantsLayer = true
view = content 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( search.placeholderAttributedString = NSAttributedString(
string: "Filter files and folders by name or path…", string: "Filter files and folders by name or path…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)] attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)]
@@ -62,7 +75,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
} }
search.delegate = self search.delegate = self
search.sendsSearchStringImmediately = true 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.font = .systemFont(ofSize: 20)
search.textColor = SunsetPalette.text search.textColor = SunsetPalette.text
search.isBordered = false search.isBordered = false
@@ -101,7 +115,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
table.dataSource = self table.dataSource = self
table.delegate = self table.delegate = self
table.allowsEmptySelection = false table.allowsEmptySelection = false
table.setAccessibilityLabel("Files to uninstall") table.setAccessibilityLabel("Files to uninstall for \(application.name)")
table.toggle = { [weak self] in self?.toggleHighlighted() } table.toggle = { [weak self] in self?.toggleHighlighted() }
table.dismiss = { [weak self] in self?.goBack() } table.dismiss = { [weak self] in self?.goBack() }
let scroll = NSScrollView() let scroll = NSScrollView()
@@ -134,7 +148,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
let footer = NSStackView(views: [NSView(), pill]) let footer = NSStackView(views: [NSView(), pill])
footer.orientation = .horizontal footer.orientation = .horizontal
footer.spacing = 12 footer.spacing = 12
for view in [header, status, scroll, footer] { for view in [header, context, status, guidance, scroll, footer] {
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(view) content.addSubview(view)
} }
@@ -146,10 +160,16 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
back.widthAnchor.constraint(equalToConstant: 22), back.widthAnchor.constraint(equalToConstant: 22),
back.heightAnchor.constraint(equalToConstant: 22), back.heightAnchor.constraint(equalToConstant: 22),
search.widthAnchor.constraint(equalTo: header.widthAnchor, constant: -34), 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.leadingAnchor.constraint(equalTo: search.leadingAnchor),
status.trailingAnchor.constraint(equalTo: search.trailingAnchor), status.trailingAnchor.constraint(equalTo: search.trailingAnchor),
status.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 12), status.topAnchor.constraint(equalTo: context.bottomAnchor, constant: 6),
scroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 12), 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.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12),
scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12), scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12),
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -16), 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() { private func reload() {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visible = candidates.filter { query.isEmpty || $0.url.path.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil } 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() { private func updateStatus() {
let chosen = candidates.filter { selected.contains($0.url) } status.textColor = SunsetPalette.secondaryText
let size = chosen.compactMap(\.size).reduce(Int64(0), +) remove.isEnabled = false
let unknown = chosen.contains { $0.size == nil } ? " (some sizes unavailable)" : "" switch discoveryState {
status.stringValue = "\(chosen.count) of \(candidates.count) files selected · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))\(unknown)" case .loading:
if visible.isEmpty && !candidates.isEmpty { status.stringValue += " · No matching files" } status.stringValue = "Finding associated files…"
remove.isEnabled = !busy && !chosen.isEmpty 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() } func controlTextDidChange(_ notification: Notification) { reload() }
@@ -256,7 +303,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
} }
@objc private func confirmUninstall() { @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) let urls = candidates.filter { $0.isSelectable && selected.contains($0.url) }.map(\.url)
guard !urls.isEmpty else { return } guard !urls.isEmpty else { return }
let running = runningApplications() let running = runningApplications()
@@ -274,6 +321,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
return return
} }
do { do {
status.textColor = SunsetPalette.secondaryText
status.stringValue = "Waiting for application to quit…" status.stringValue = "Waiting for application to quit…"
for app in running where !app.isTerminated { for app in running where !app.isTerminated {
guard app.terminate() else { throw ReviewError("\(app.localizedName ?? application.name) could not quit. No files were moved.") } 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?() changed?()
await showFeedback("Could not uninstall \(application.name)", error.localizedDescription, await showFeedback("Could not uninstall \(application.name)", error.localizedDescription,
allowRecovery: !(error is ReviewError)) allowRecovery: !(error is ReviewError))
discoveryState = .loading
setBusy(false) setBusy(false)
// Refresh identities and availability after a failed operation before another review. // Refresh identities and availability after a failed operation before another review.
do { do {
candidates = try await Task.detached { [application] in let found = try await Task.detached { [application] in
try UninstallDiscovery.scan(application: application) try UninstallDiscovery.scan(application: application)
}.value }.value
selected.formIntersection(Set(candidates.filter(\.isSelectable).map(\.url))) applyDiscoveryResult(.success(found), preservingSelection: true)
reload() } catch {
} catch { status.stringValue = error.localizedDescription; remove.isEnabled = false } applyDiscoveryResult(.failure(error))
}
} }
} }
} }
@@ -321,6 +371,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
alert.addButton(withTitle: "OK") alert.addButton(withTitle: "OK")
if canRecover { alert.addButton(withTitle: "Retry recovery") } if canRecover { alert.addButton(withTitle: "Retry recovery") }
guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return } guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return }
status.textColor = SunsetPalette.secondaryText
status.stringValue = "Recovering pending uninstall…" status.stringValue = "Recovering pending uninstall…"
let report = await uninstaller.recover() let report = await uninstaller.recover()
changed?() changed?()
@@ -334,7 +385,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
busy = value busy = value
search.isEnabled = !value search.isEnabled = !value
back.isEnabled = !value back.isEnabled = !value
remove.isEnabled = !value && !selected.isEmpty remove.isEnabled = false
table.reloadData() table.reloadData()
if !value { if !value {
updateStatus() updateStatus()
+6 -6
View File
@@ -4,12 +4,12 @@
### Navigation, context, and feedback ### Navigation, context, and feedback
- [ ] Add a visible, accessible Back action to Translate, matching uninstall review; retain Escape and query/focus restoration. - [x] 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. - [x] 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. - [x] 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. - [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.
- [ ] Show persistent uninstall keyboard guidance: Return toggles file inclusion; Command-Return opens confirmation. Preserve the safer shortcut distinction. - [x] 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] Separate Translate's persistent provider/privacy/limit disclosure from transient validation, credential, loading, success, and error feedback.
### Visual consistency ### 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)
}