From 6fd3931f3a4704731bd13598919797c0c05a2db4 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Thu, 15 Oct 2026 12:13:45 +0200 Subject: [PATCH] fix: unify navigation context and feedback across tools --- README.md | 8 ++ .../Conjure/SettingsWindowController.swift | 7 +- Sources/Conjure/TranslationController.swift | 73 +++++++++++---- .../Conjure/UninstallReviewController.swift | 93 ++++++++++++++----- TODO.md | 12 +-- Tests/ConjureKitTests/ToolFeedbackTests.swift | 89 ++++++++++++++++++ 6 files changed, 238 insertions(+), 44 deletions(-) create mode 100644 Tests/ConjureKitTests/ToolFeedbackTests.swift diff --git a/README.md b/README.md index 125fd37..721c215 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ Activation uses the native macOS hotkey API, without an Accessibility permission Open Settings with `⌘,` and enable **Launch at login**. Conjure uses macOS's native login-item service and reads its actual registration status rather than storing a separate preference. If macOS requires approval, Settings explains this and offers **Open Login Items Settings…**. Registration errors are shown beside the control, and the checkbox returns to the actual system state. Install Conjure in `/Applications` before enabling it; disable the toggle to unregister it. +Shortcut modifier guidance uses secondary text rather than error red. Shortcut and login-operation failures and unresolved uninstall recovery use red feedback; ordinary instructions and completed recovery use secondary text. + Conjure recognizes the native [login-launch Apple event](https://developer.apple.com/documentation/coreservices/1556410-launch_apple_event_constants) and starts without showing a window or taking focus, while still registering its saved global shortcut. Opening it manually from Finder still shows the launcher, including when it is already running. If shortcut registration fails at login, the error is retained and shown in Settings when you next open Conjure manually. Normal manual launches continue to report startup errors immediately. ## Discovery and search @@ -83,10 +85,14 @@ Applications with identical display names show a muted containing-folder hint be Search for **Conjure Translate**, labeled **Command**, and open it with Return, double-click, or **Open Command**. The launcher fades into an English → Japanese translation tool; the existing search bar becomes the English input and retains the launcher's placeholder styling. Escape returns to the previous search. +The visible Back chevron and Escape return to the previous launcher query with search focused. Escape also works from the API-key field and translation output. + The command participates in the same text matching, usage ranking, and favorites as applications (`ct` matches its initials). Return, double-click, **Open Command**, numbered shortcuts, and **Actions → Open** all record an opening. Its Actions menu offers **Open**, **Favorite / Remove Favorite**, and **Reset ranking**, but not Finder, Quit, or Uninstall. Reset ranking clears usage without removing its favorite status. Choose **MyMemory (no key)** or **OpenAI · gpt-6-luna** from the provider dropdown. MyMemory is the initial default, and the selection is saved locally. Switching providers cancels pending work and clears the previous translation; there is no automatic fallback. Press Return from the English input or click **Translate** in the bottom-right launcher-style action pill, beside its Return key badge, to send text to the selected provider. +Provider privacy information and MyMemory limits remain visible separately from transient feedback. Validation, Keychain, and service errors use red text; guidance, progress, and success use the secondary text color. Editing input or credentials clears stale output and error styling without sending a request. + [MyMemory](https://mymemory.translated.net/doc/spec.php) requires no account or key. Anonymous use allows 500 UTF-8 bytes per request and [5,000 characters per day](https://mymemory.translated.net/doc/usagelimits.php). Oversized input is rejected before sending; quota and service errors appear in the tool. Text is sent to a third-party translation service: avoid sensitive content. If MyMemory's top translation is blank, Conjure uses the first nonblank exact English→Japanese match in that same response. Approximate matches are not substituted, and service/quota failures still surface as errors. @@ -141,6 +147,8 @@ Select an application and open **Actions ⌘K**. **Open** is the first, default- The red **Uninstall application** action morphs the existing launcher into a file-review screen with checkboxes, paths, sizes, and a selected-item count; it does not create another window or review sheet. A native 180-millisecond resize and content crossfade runs in both directions, or switches immediately with Reduce Motion. The filter uses a centered, intrinsic-height text field without built-in search/cancel icons, beside an explicit back chevron. Filtering files by name or path does not change file selections. Up/Down or Control-K/Control-J highlights files; Return toggles the highlighted eligible file, including from the filter input, and Space also toggles when the list has focus. Back or Escape restores the original launcher frame, query, and selection. Temporary review geometry is not saved as the launcher position. Leaving is blocked during confirmation or an uninstall operation. Command-Return or the uninstall button opens a native final confirmation dialog; nothing is moved merely by entering review. The confirmation control uses the launcher's frosted rounded pill with separate Command and Return key badges. The launcher Actions control likewise shows separate Command and K badges matching its Open Return badge. +The application's name and keyboard guidance remain visible throughout file review: Return toggles inclusion; Command-Return opens confirmation. Filtering preserves discovery progress or errors until discovery succeeds, then shows selection counts and no-match feedback without changing checked files. + Keyboard file toggles update the existing checkbox without reloading or fading the list, preserving both the highlighted row and scroll position. The launcher's footer Open and Actions labels use 12-point medium text. All custom shortcut hints—in the launcher footer, numbered application rows, uninstall footer, and actions selector—use `ShortcutIndicator` from `Sources/Conjure/ShortcutIndicator.swift` (for example, `ShortcutIndicator("⌘", "K")`). It owns 12-point medium key text, square 18×18-point containers, visible-glyph centering, border styling, and 2-point gaps between adjacent keys; callers position or hide the whole indicator, and numbered rows update their single key without rebuilding the cell. Native menus and shortcut-recording controls retain their platform styling. Numbered row overlays use 13-point numbers in 22×22-point boxes with a lighter opaque warm-brown backing. Their label fade begins 36 points before the badge, twice the original lead-in; other shortcut hints retain their 12-point text and 18×18-point boxes. diff --git a/Sources/Conjure/SettingsWindowController.swift b/Sources/Conjure/SettingsWindowController.swift index c21e74d..55ec731 100644 --- a/Sources/Conjure/SettingsWindowController.swift +++ b/Sources/Conjure/SettingsWindowController.swift @@ -79,7 +79,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { recorder.capture = { [weak self] event in self?.capture(event) } recorder.cancel = { [weak self] in self?.cancelRecording() } recorder.modifiersChanged = { [weak self] in - self?.errorLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels." + self?.errorLabel.stringValue = "" + self?.statusLabel.stringValue = "Press a key together with Command, Option, or Control. Escape cancels." } let commandSpaceButton = NSButton(title: "Use ⌘Space", target: self, action: #selector(useCommandSpace)) commandSpaceButton.bezelStyle = .rounded @@ -175,6 +176,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { recoveryLabel.stringValue = report.needsRecovery ? "An interrupted uninstall needs attention. Files have been retained for recovery." : "Interrupted uninstall recovery completed." + recoveryLabel.textColor = report.needsRecovery ? .systemRed : SunsetPalette.secondaryText recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…" } @@ -197,6 +199,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private func refreshLogin() { let status = SMAppService.mainApp.status + loginStatus.textColor = SunsetPalette.secondaryText loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off loginSettings.isHidden = status != .requiresApproval switch status { @@ -210,6 +213,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { loginStatus.stringValue = "Install Conjure in Applications before enabling launch at login." @unknown default: loginStatus.stringValue = "Launch at login status is unavailable." + loginStatus.textColor = .systemRed } } @@ -225,6 +229,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } catch { refreshLogin() loginStatus.stringValue = "Could not change launch at login: \(error.localizedDescription)" + loginStatus.textColor = .systemRed } } diff --git a/Sources/Conjure/TranslationController.swift b/Sources/Conjure/TranslationController.swift index de72018..f3a20ae 100644 --- a/Sources/Conjure/TranslationController.swift +++ b/Sources/Conjure/TranslationController.swift @@ -3,12 +3,13 @@ import ConjureKit import Security @MainActor -final class TranslationController: NSViewController { +final class TranslationController: NSViewController, NSTextFieldDelegate, NSTextViewDelegate { private let keyField = NSSecureTextField() private let provider = NSPopUpButton() private let credentials = NSStackView() private var usesMyMemory: Bool { provider.indexOfSelectedItem == 0 } private let output = NSTextView() + private let disclosure = NSTextField(wrappingLabelWithString: "") private let status = NSTextField(wrappingLabelWithString: "") private let translateButton = NSButton(title: "Translate", target: nil, action: nil) private var task: Task? @@ -24,7 +25,16 @@ final class TranslationController: NSViewController { view = NSView() let title = NSTextField(labelWithString: "Conjure Translate · English → Japanese") title.font = .systemFont(ofSize: 13, weight: .semibold) - let heading = NSStackView(views: [title, NSView()]) + let back = NSButton(title: "", target: self, action: #selector(goBack)) + back.image = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil) + back.imageScaling = .scaleProportionallyDown + back.isBordered = false + back.contentTintColor = SunsetPalette.secondaryText + back.setAccessibilityLabel("Back to applications") + back.toolTip = "Back to applications (Escape)" + let heading = NSStackView(views: [back, title, NSView()]) + heading.alignment = .centerY + heading.spacing = 12 provider.addItems(withTitles: ["MyMemory (no key)", "OpenAI · gpt-6-luna"]) provider.selectItem(at: UserDefaults.standard.string(forKey: "translationProvider") == "openai" ? 1 : 0) provider.setAccessibilityLabel("Translation provider") @@ -32,6 +42,7 @@ final class TranslationController: NSViewController { provider.action = #selector(providerChanged) keyField.placeholderString = "OpenAI API key (saved in Keychain)" keyField.setAccessibilityLabel("OpenAI API key") + keyField.delegate = self keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey)) let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey)) @@ -73,6 +84,7 @@ final class TranslationController: NSViewController { output.textColor = SunsetPalette.text output.font = .systemFont(ofSize: 20) output.setAccessibilityLabel("Japanese translation") + output.delegate = self let scroll = NSScrollView() scroll.documentView = output scroll.hasVerticalScroller = true @@ -80,15 +92,21 @@ final class TranslationController: NSViewController { output.isVerticallyResizable = true output.autoresizingMask = [.width] output.textContainer?.widthTracksTextView = true - status.textColor = SunsetPalette.secondaryText + disclosure.textColor = SunsetPalette.secondaryText + disclosure.font = .systemFont(ofSize: 12) + disclosure.setAccessibilityLabel("Translation provider, privacy, and limits") status.font = .systemFont(ofSize: 12) - let stack = NSStackView(views: [heading, provider, credentials, scroll, status]) + status.setAccessibilityLabel("Translation status and errors") + let stack = NSStackView(views: [heading, provider, disclosure, credentials, scroll, status]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 12 + stack.setCustomSpacing(4, after: provider) stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ + back.widthAnchor.constraint(equalToConstant: 22), + back.heightAnchor.constraint(equalToConstant: 22), stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), stack.topAnchor.constraint(equalTo: view.topAnchor), @@ -98,6 +116,7 @@ final class TranslationController: NSViewController { footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8), footer.heightAnchor.constraint(equalToConstant: 36), credentials.widthAnchor.constraint(equalTo: stack.widthAnchor), + disclosure.widthAnchor.constraint(equalTo: stack.widthAnchor), scroll.widthAnchor.constraint(equalTo: stack.widthAnchor), scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 100), status.widthAnchor.constraint(equalTo: stack.widthAnchor), @@ -105,6 +124,22 @@ final class TranslationController: NSViewController { updateProvider() } + override func cancelOperation(_ sender: Any?) { goBack() } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + guard selector == #selector(NSResponder.cancelOperation(_:)) else { return false } + goBack() + return true + } + + func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { + guard selector == #selector(NSResponder.cancelOperation(_:)) else { return false } + goBack() + return true + } + + func controlTextDidChange(_ notification: Notification) { inputChanged() } + @objc func goBack() { task?.cancel() task = nil @@ -116,9 +151,7 @@ final class TranslationController: NSViewController { task = nil translateButton.isEnabled = true output.string = "" - status.stringValue = usesMyMemory - ? "MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory; avoid sensitive content." - : "OpenAI · gpt-6-luna · Text is sent to OpenAI only when you translate." + setStatus("Press Return to translate.") } @objc private func providerChanged() { @@ -129,13 +162,16 @@ final class TranslationController: NSViewController { private func updateProvider() { credentials.isHidden = usesMyMemory + disclosure.stringValue = usesMyMemory + ? "MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory; avoid sensitive content." + : "OpenAI · gpt-6-luna · Text is sent to OpenAI only when you translate." inputChanged() } @objc private func saveKey() { let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty, !key.contains(where: { $0.isNewline }) else { - status.stringValue = "Enter a valid API key before saving." + setStatus("Enter a valid API key before saving.", isError: true) return } let data = Data(key.utf8) @@ -148,7 +184,7 @@ final class TranslationController: NSViewController { } if result == errSecSuccess { keyField.stringValue = "" - status.stringValue = "API key saved in Keychain. Press Return to translate." + setStatus("API key saved in Keychain. Press Return to translate.") } else { showKeychainError(result) } } @@ -159,19 +195,24 @@ final class TranslationController: NSViewController { let result = SecItemDelete(Self.credential as CFDictionary) if result == errSecSuccess || result == errSecItemNotFound { keyField.stringValue = "" - status.stringValue = "API key removed." + setStatus("API key removed.") } else { showKeychainError(result) } } private func showKeychainError(_ code: OSStatus) { - status.stringValue = "Keychain: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))" + setStatus("Keychain error: \(SecCopyErrorMessageString(code, nil) as String? ?? String(code))", isError: true) + } + + private func setStatus(_ message: String, isError: Bool = false) { + status.stringValue = message + status.textColor = isError ? .systemRed : SunsetPalette.secondaryText } @objc func translate() { guard task == nil else { return } let text = input?() ?? "" guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - status.stringValue = "Enter English text in the search bar." + setStatus("Enter English text in the search bar.", isError: true) return } let myMemory = usesMyMemory @@ -183,14 +224,14 @@ final class TranslationController: NSViewController { var item: CFTypeRef? let result = SecItemCopyMatching(query as CFDictionary, &item) guard result == errSecSuccess, let data = item as? Data, let savedKey = String(data: data, encoding: .utf8) else { - if result == errSecItemNotFound { status.stringValue = "Enter and save your OpenAI API key first." } + if result == errSecItemNotFound { setStatus("Enter and save your OpenAI API key first.", isError: true) } else { showKeychainError(result) } return } key = savedKey } output.string = "" - status.stringValue = "Translating…" + setStatus("Translating…") translateButton.isEnabled = false task = Task { [weak self] in do { @@ -199,10 +240,10 @@ final class TranslationController: NSViewController { : TranslationAPI.translate(text, apiKey: key) guard !Task.isCancelled, let self else { return } self.output.string = translation - self.status.stringValue = "Japanese · Select text to copy (⌘C)." + self.setStatus("Japanese · Select text to copy (⌘C).") } catch { guard !Task.isCancelled, let self else { return } - self.status.stringValue = error.localizedDescription + self.setStatus("Translation failed: \(error.localizedDescription)", isError: true) } guard let self else { return } self.task = nil diff --git a/Sources/Conjure/UninstallReviewController.swift b/Sources/Conjure/UninstallReviewController.swift index 6822a3b..75892ea 100644 --- a/Sources/Conjure/UninstallReviewController.swift +++ b/Sources/Conjure/UninstallReviewController.swift @@ -3,6 +3,12 @@ import ConjureKit @MainActor final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { + private enum DiscoveryState { + case loading + case ready + case failed(String) + } + private let application: Application private let uninstaller: ApplicationUninstaller private let search = NSSearchField() @@ -13,6 +19,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, private var candidates: [UninstallCandidate] = [] private var visible: [UninstallCandidate] = [] private var selected: Set = [] + private var discoveryState = DiscoveryState.loading private var busy = false private var scanTask: Task? private var window: NSWindow? { view.window } @@ -30,6 +37,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, func present() { guard let window else { return } + discoveryState = .loading + updateStatus() window.makeFirstResponder(search) (search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent scanTask = Task { [weak self, application] in @@ -38,12 +47,10 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, try UninstallDiscovery.scan(application: application) }.value guard let self, !Task.isCancelled else { return } - candidates = found - selected = Set(found.filter { $0.isSelectable && $0.defaultSelected }.map(\.url)) - reload() + applyDiscoveryResult(.success(found)) } catch { guard let self, !Task.isCancelled else { return } - status.stringValue = "Could not inspect application files: \(error.localizedDescription)" + applyDiscoveryResult(.failure(error)) } } } @@ -52,6 +59,12 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, let content = UninstallReviewContent() content.wantsLayer = true view = content + let context = NSTextField(wrappingLabelWithString: "Uninstall \(application.name)") + context.font = .systemFont(ofSize: 14, weight: .medium) + context.textColor = SunsetPalette.text + let guidance = NSTextField(wrappingLabelWithString: "Return toggles file inclusion · Command-Return opens confirmation") + guidance.font = .systemFont(ofSize: 12) + guidance.textColor = SunsetPalette.secondaryText search.placeholderAttributedString = NSAttributedString( string: "Filter files and folders by name or path…", attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)] @@ -62,7 +75,8 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, } search.delegate = self search.sendsSearchStringImmediately = true - search.setAccessibilityLabel("Filter uninstall files") + search.setAccessibilityLabel("Filter uninstall files for \(application.name)") + search.setAccessibilityHelp(guidance.stringValue) search.font = .systemFont(ofSize: 20) search.textColor = SunsetPalette.text search.isBordered = false @@ -101,7 +115,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, table.dataSource = self table.delegate = self table.allowsEmptySelection = false - table.setAccessibilityLabel("Files to uninstall") + table.setAccessibilityLabel("Files to uninstall for \(application.name)") table.toggle = { [weak self] in self?.toggleHighlighted() } table.dismiss = { [weak self] in self?.goBack() } let scroll = NSScrollView() @@ -134,7 +148,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, let footer = NSStackView(views: [NSView(), pill]) footer.orientation = .horizontal footer.spacing = 12 - for view in [header, status, scroll, footer] { + for view in [header, context, status, guidance, scroll, footer] { view.translatesAutoresizingMaskIntoConstraints = false content.addSubview(view) } @@ -146,10 +160,16 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, back.widthAnchor.constraint(equalToConstant: 22), back.heightAnchor.constraint(equalToConstant: 22), search.widthAnchor.constraint(equalTo: header.widthAnchor, constant: -34), + context.leadingAnchor.constraint(equalTo: search.leadingAnchor), + context.trailingAnchor.constraint(equalTo: search.trailingAnchor), + context.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 12), status.leadingAnchor.constraint(equalTo: search.leadingAnchor), status.trailingAnchor.constraint(equalTo: search.trailingAnchor), - status.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 12), - scroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 12), + status.topAnchor.constraint(equalTo: context.bottomAnchor, constant: 6), + guidance.leadingAnchor.constraint(equalTo: search.leadingAnchor), + guidance.trailingAnchor.constraint(equalTo: search.trailingAnchor), + guidance.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 6), + scroll.topAnchor.constraint(equalTo: guidance.bottomAnchor, constant: 12), scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12), scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12), scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -16), @@ -160,6 +180,23 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, ]) } + func applyDiscoveryResult(_ result: Result<[UninstallCandidate], Error>, preservingSelection: Bool = false) { + switch result { + case .success(let found): + candidates = found + if preservingSelection { + selected.formIntersection(Set(found.filter(\.isSelectable).map(\.url))) + } else { + selected = Set(found.filter { $0.isSelectable && $0.defaultSelected }.map(\.url)) + } + discoveryState = .ready + reload() + case .failure(let error): + discoveryState = .failed("Could not inspect application files: \(error.localizedDescription)") + updateStatus() + } + } + private func reload() { let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) visible = candidates.filter { query.isEmpty || $0.url.path.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil } @@ -169,12 +206,22 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, } private func updateStatus() { - let chosen = candidates.filter { selected.contains($0.url) } - let size = chosen.compactMap(\.size).reduce(Int64(0), +) - let unknown = chosen.contains { $0.size == nil } ? " (some sizes unavailable)" : "" - status.stringValue = "\(chosen.count) of \(candidates.count) files selected · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))\(unknown)" - if visible.isEmpty && !candidates.isEmpty { status.stringValue += " · No matching files" } - remove.isEnabled = !busy && !chosen.isEmpty + status.textColor = SunsetPalette.secondaryText + remove.isEnabled = false + switch discoveryState { + case .loading: + status.stringValue = "Finding associated files…" + case .failed(let message): + status.stringValue = message + status.textColor = .systemRed + case .ready: + let chosen = candidates.filter { selected.contains($0.url) } + let size = chosen.compactMap(\.size).reduce(Int64(0), +) + let unknown = chosen.contains { $0.size == nil } ? " (some sizes unavailable)" : "" + status.stringValue = "\(chosen.count) of \(candidates.count) files selected · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))\(unknown)" + if visible.isEmpty && !candidates.isEmpty { status.stringValue += " · No matching files" } + remove.isEnabled = !busy && !chosen.isEmpty + } } func controlTextDidChange(_ notification: Notification) { reload() } @@ -256,7 +303,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, } @objc private func confirmUninstall() { - guard !busy, !selected.isEmpty, let window else { return } + guard case .ready = discoveryState, !busy, !selected.isEmpty, let window else { return } let urls = candidates.filter { $0.isSelectable && selected.contains($0.url) }.map(\.url) guard !urls.isEmpty else { return } let running = runningApplications() @@ -274,6 +321,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, return } do { + status.textColor = SunsetPalette.secondaryText status.stringValue = "Waiting for application to quit…" for app in running where !app.isTerminated { guard app.terminate() else { throw ReviewError("\(app.localizedName ?? application.name) could not quit. No files were moved.") } @@ -296,15 +344,17 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, changed?() await showFeedback("Could not uninstall \(application.name)", error.localizedDescription, allowRecovery: !(error is ReviewError)) + discoveryState = .loading setBusy(false) // Refresh identities and availability after a failed operation before another review. do { - candidates = try await Task.detached { [application] in + let found = try await Task.detached { [application] in try UninstallDiscovery.scan(application: application) }.value - selected.formIntersection(Set(candidates.filter(\.isSelectable).map(\.url))) - reload() - } catch { status.stringValue = error.localizedDescription; remove.isEnabled = false } + applyDiscoveryResult(.success(found), preservingSelection: true) + } catch { + applyDiscoveryResult(.failure(error)) + } } } } @@ -321,6 +371,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, alert.addButton(withTitle: "OK") if canRecover { alert.addButton(withTitle: "Retry recovery") } guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return } + status.textColor = SunsetPalette.secondaryText status.stringValue = "Recovering pending uninstall…" let report = await uninstaller.recover() changed?() @@ -334,7 +385,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, busy = value search.isEnabled = !value back.isEnabled = !value - remove.isEnabled = !value && !selected.isEmpty + remove.isEnabled = false table.reloadData() if !value { updateStatus() diff --git a/TODO.md b/TODO.md index 7e66eec..d7ec429 100644 --- a/TODO.md +++ b/TODO.md @@ -4,12 +4,12 @@ ### Navigation, context, and feedback -- [ ] Add a visible, accessible Back action to Translate, matching uninstall review; retain Escape and query/focus restoration. -- [ ] Keep the selected application's name visible throughout uninstall file review, not only in Actions and final confirmation. -- [ ] Preserve uninstall discovery loading/error feedback when filtering; show selection counts only after successful discovery. -- [ ] Standardize feedback severity: secondary text for guidance/progress and consistent error styling for actual failures. Shortcut modifier guidance should not appear red; login and translation failures should receive error treatment. -- [ ] Show persistent uninstall keyboard guidance: Return toggles file inclusion; Command-Return opens confirmation. Preserve the safer shortcut distinction. -- [ ] Separate Translate's persistent provider/privacy/limit disclosure from transient validation, credential, loading, success, and error feedback. +- [x] Add a visible, accessible Back action to Translate, matching uninstall review; retain Escape and query/focus restoration. +- [x] Keep the selected application's name visible throughout uninstall file review, not only in Actions and final confirmation. +- [x] Preserve uninstall discovery loading/error feedback when filtering; show selection counts only after successful discovery. +- [x] Standardize feedback severity: secondary text for guidance/progress and consistent error styling for actual failures. Shortcut modifier guidance should not appear red; login and translation failures should receive error treatment. +- [x] Show persistent uninstall keyboard guidance: Return toggles file inclusion; Command-Return opens confirmation. Preserve the safer shortcut distinction. +- [x] Separate Translate's persistent provider/privacy/limit disclosure from transient validation, credential, loading, success, and error feedback. ### Visual consistency diff --git a/Tests/ConjureKitTests/ToolFeedbackTests.swift b/Tests/ConjureKitTests/ToolFeedbackTests.swift new file mode 100644 index 0000000..5364156 --- /dev/null +++ b/Tests/ConjureKitTests/ToolFeedbackTests.swift @@ -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) +}