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