feat: rank built-in commands alongside applications

This commit is contained in:
Valdemar Lorenzen
2026-10-15 11:23:50 +02:00
parent 31c651ecb1
commit e0f17c6339
7 changed files with 131 additions and 43 deletions
+5 -3
View File
@@ -69,13 +69,13 @@ Applications are discovered in `/Applications`, `/System/Applications`, `/System
Discovery supports both standard (`APPL`) and XPC-backed (`XPC!`) `.app` bundles, including Apple Passwords, provided they contain a valid executable. Discovery supports both standard (`APPL`) and XPC-backed (`XPC!`) `.app` bundles, including Apple Passwords, provided they contain a valid executable.
Search is case- and accent-insensitive. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches put favorites first, then order by successful launch count, most recent launch, and alphabetically. Neither favorites nor usage promote a weaker text match above a stronger one or change which applications match. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher. Search is case- and accent-insensitive for applications and built-in commands alike. Ranking is exact name, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches put favorites first, then order by launch count, most recent launch, and alphabetically. Neither favorites nor usage promote a weaker text match above a stronger one or change which entries match. Hover over an application to see its path. Application launch failures appear in a native alert without dismissing the launcher.
Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code. Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code.
With an empty search, favorites appear first, separated from the remaining applications by a 12-point gap when both groups are present. Typed searches have no group spacing. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group. With an empty search, favorites appear first, separated from the remaining entries by a 12-point gap when both groups are present. Typed searches have no group spacing. Within both favorite and non-favorite groups, applications and commands are ordered together by launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity or stable built-in command identity and survive restarts; an application and a command with the same name remain independent. Successful application launches and opening a command through Conjure count; submitting translations inside a command does not. Entries without history remain alphabetical within their group.
Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result. Background discovery preserves the selected entry even if its row changes. If that entry disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result.
Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes. Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes.
@@ -83,6 +83,8 @@ 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 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.
[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.
@@ -13,6 +13,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed) ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed)
] ]
private var visibleEntries: [Int] = [] private var visibleEntries: [Int] = []
private var isCommand = false
private var selectedEntry: Int? = 0 private var selectedEntry: Int? = 0
private var rowTopConstraints: [NSLayoutConstraint] = [] private var rowTopConstraints: [NSLayoutConstraint] = []
private var heightConstraint: NSLayoutConstraint? private var heightConstraint: NSLayoutConstraint?
@@ -129,6 +130,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
let pill = anchor.superview ?? anchor let pill = anchor.superview ?? anchor
_ = view _ = view
applicationLabel.stringValue = application.name applicationLabel.stringValue = application.name
isCommand = application.command != nil
let favorite = entries[1] let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [ favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
@@ -201,7 +203,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func controlTextDidChange(_ notification: Notification) { func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visibleEntries = entries.indices.filter { query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil } visibleEntries = entries.indices.filter {
(!isCommand || $0 == 0 || $0 == 1 || $0 == 3)
&& (query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil)
}
selectedEntry = visibleEntries.first selectedEntry = visibleEntries.first
for index in entries.indices { for index in entries.indices {
let position = visibleEntries.firstIndex(of: index) let position = visibleEntries.firstIndex(of: index)
+29 -27
View File
@@ -10,7 +10,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private let sectionLabel = NSTextField(labelWithString: "Applications") private let sectionLabel = NSTextField(labelWithString: "Applications")
private let openButton = NSButton(title: "Open Application", target: nil, action: nil) private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
private let history: LaunchHistory private let history: LaunchHistory
private var applications: [Application] = [] private var applications: [Application] = Application.commands
private var results: [Application] = [] private var results: [Application] = []
private var calculation: CalculatorResult? private var calculation: CalculatorResult?
private var isCurrencyCalculation = false private var isCurrencyCalculation = false
@@ -32,10 +32,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var launcherViews: [NSView] = [] private var launcherViews: [NSView] = []
private var launcherFrameBeforeReview: NSRect? private var launcherFrameBeforeReview: NSRect?
private var translation: TranslationController? private var translation: TranslationController?
private var showsTranslationCommand = true
private var translationCommandRow: Int? {
calculation == nil && showsTranslationCommand ? results.count : nil
}
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) { init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history self.history = history
@@ -74,8 +70,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
window.numberHintsChanged = { [weak self] in self?.updateNumberHints() } window.numberHintsChanged = { [weak self] in self?.updateNumberHints() }
window.launchNumber = { [weak self] index in window.launchNumber = { [weak self] index in
guard let self else { return } guard let self else { return }
if index == self.translationCommandRow { self.showTranslation() } if self.results.indices.contains(index) { self.openApplication(self.results[index]) }
else if self.results.indices.contains(index) { self.openApplication(self.results[index]) }
} }
window.dragEnded = { [weak self] in window.dragEnded = { [weak self] in
guard let self else { return } guard let self else { return }
@@ -300,8 +295,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
actionsButton.keyEquivalentModifierMask = .command actionsButton.keyEquivalentModifierMask = .command
actionsButton.target = self actionsButton.target = self
actionsButton.action = #selector(showActions) actionsButton.action = #selector(showActions)
actionsButton.setAccessibilityLabel("Application actions") actionsButton.setAccessibilityLabel("Entry actions")
actionsButton.toolTip = "Actions for the selected application (Command-K)" actionsButton.toolTip = "Actions for the selected application or command (Command-K)"
actionsButton.isEnabled = false actionsButton.isEnabled = false
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select." openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
let returnKey = ShortcutIndicator("↵") let returnKey = ShortcutIndicator("↵")
@@ -375,7 +370,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}.value }.value
guard let self else { return } guard let self else { return }
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
self.applications = applications self.applications = applications + Application.commands
self.updateLocationLabels() self.updateLocationLabels()
self.discoveryTask = nil self.discoveryTask = nil
self.updateResults(preservingSelection: true) self.updateResults(preservingSelection: true)
@@ -407,9 +402,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func updateLocationLabels() { private func updateLocationLabels() {
var nameCounts: [String: Int] = [:] var nameCounts: [String: Int] = [:]
nameCounts.reserveCapacity(applications.count) nameCounts.reserveCapacity(applications.count)
for application in applications { nameCounts[application.name, default: 0] += 1 } for application in applications where application.command == nil { nameCounts[application.name, default: 0] += 1 }
var groups: [String: [(url: URL, folder: URL)]] = [:] var groups: [String: [(url: URL, folder: URL)]] = [:]
for application in applications where (nameCounts[application.name] ?? 0) > 1 { for application in applications where application.command == nil && (nameCounts[application.name] ?? 0) > 1 {
groups[application.name, default: []].append( groups[application.name, default: []].append(
(application.url, application.url.deletingLastPathComponent()) (application.url, application.url.deletingLastPathComponent())
) )
@@ -458,9 +453,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
detail: "\(answer.detail) · \(cacheStatus)" detail: "\(answer.detail) · \(cacheStatus)"
) )
} }
showsTranslationCommand = query.split(whereSeparator: \.isWhitespace).allSatisfy {
"Conjure Translate".localizedStandardContains(String($0))
}
if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() } if calculation != nil { (window as? LauncherPanel)?.resetNumberHints() }
if calculation != nil { if calculation != nil {
results = [] results = []
@@ -472,7 +464,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates" sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates"
: calculation == nil ? "Applications & Commands" : "Calculator" : calculation == nil ? "Applications & Commands" : "Calculator"
tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result" tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result"
: calculation == nil ? "Applications" : "Calculator result") : calculation == nil ? "Applications and commands" : "Calculator result")
tableView.reloadData() tableView.reloadData()
if resultCount > 0 { if resultCount > 0 {
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0 let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
@@ -486,8 +478,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
emptyLabel.stringValue = "Finding applications…" emptyLabel.stringValue = "Finding applications…"
statusLabel.stringValue = "Finding applications…" statusLabel.stringValue = "Finding applications…"
} else { } else {
emptyLabel.stringValue = applications.isEmpty ? "No applications found" : "No matching applications" emptyLabel.stringValue = "No matching applications or commands"
statusLabel.stringValue = "\(results.count) applications" + (showsTranslationCommand ? " · 1 command" : "") let commandCount = results.lazy.filter { $0.command != nil }.count
statusLabel.stringValue = "\(results.count - commandCount) applications · \(commandCount) commands"
} }
updateOpenButton() updateOpenButton()
(window as? LauncherPanel)?.sampleNumberHints() (window as? LauncherPanel)?.sampleNumberHints()
@@ -519,7 +512,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return true return true
} }
private var resultCount: Int { calculation == nil ? results.count + (showsTranslationCommand ? 1 : 0) : 1 } private var resultCount: Int { calculation == nil ? results.count : 1 }
private func moveSelection(by offset: Int) { private func moveSelection(by offset: Int) {
guard translation == nil else { return } guard translation == nil else { return }
@@ -531,12 +524,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func updateOpenButton() { private func updateOpenButton() {
let canAct = !isLaunching && launcherFrameBeforeReview == nil && translation == nil && tableView.selectedRow >= 0 let canAct = !isLaunching && launcherFrameBeforeReview == nil && translation == nil && tableView.selectedRow >= 0
openButton.title = calculation == nil ? (tableView.selectedRow == translationCommandRow ? "Open Command" : "Open Application") : "Copy Answer" openButton.title = calculation == nil
? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil
? "Open Command" : "Open Application") : "Copy Answer"
openButton.toolTip = calculation == nil openButton.toolTip = calculation == nil
? "Open the selected entry (Return). Use ↑ and ↓ to select." ? "Open the selected entry (Return). Use ↑ and ↓ to select."
: "Copy the answer to the clipboard (Return)." : "Copy the answer to the clipboard (Return)."
openButton.isEnabled = canAct && (calculation.map { $0.copyValue != nil } openButton.isEnabled = canAct && (calculation.map { $0.copyValue != nil }
?? (results.indices.contains(tableView.selectedRow) || tableView.selectedRow == translationCommandRow)) ?? results.indices.contains(tableView.selectedRow))
actionsButton.isEnabled = canAct && calculation == nil && results.indices.contains(tableView.selectedRow) actionsButton.isEnabled = canAct && calculation == nil && results.indices.contains(tableView.selectedRow)
} }
@@ -574,11 +569,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
.withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular)) .withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular))
cell.imageView?.contentTintColor = SunsetPalette.accent cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = calculation.detail cell.toolTip = calculation.detail
} else if row == translationCommandRow { } else if results[row].command != nil {
cell.textField?.stringValue = "Conjure Translate" let entry = results[row]
cell.textField?.setAccessibilityLabel(nil) cell.textField?.stringValue = entry.name
cell.textField?.setAccessibilityLabel(history.isFavorite(entry) ? "\(entry.name), Favorite" : nil)
cell.locationLabel.isHidden = true cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = "Command" cell.kindLabel.stringValue = history.isFavorite(entry) ? "★ Command" : "Command"
cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil) cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
cell.imageView?.contentTintColor = SunsetPalette.accent cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = "Translate English to Japanese" cell.toolTip = "Translate English to Japanese"
@@ -723,7 +719,6 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@objc private func openSelection() { @objc private func openSelection() {
guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return } guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if tableView.selectedRow == translationCommandRow { showTranslation(); return }
if let calculation { if let calculation {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return } guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
@@ -749,7 +744,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
private func openApplication(_ application: Application) { private func openApplication(_ application: Application) {
guard !isLaunching else { return } guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let command = application.command {
switch command {
case .translate: showTranslation()
}
history.recordLaunch(of: application)
return
}
(window as? LauncherPanel)?.resetNumberHints() (window as? LauncherPanel)?.resetNumberHints()
isLaunching = true isLaunching = true
updateOpenButton() updateOpenButton()
+16 -3
View File
@@ -1,6 +1,11 @@
import Foundation import Foundation
public struct Application: Identifiable, Sendable { public struct Application: Identifiable, Sendable {
public enum Command: String, CaseIterable, Sendable {
case translate
}
public static let commands = Command.allCases.map { Application(command: $0) }
public let command: Command?
public var id: URL { url } public var id: URL { url }
public let url: URL public let url: URL
public let name: String public let name: String
@@ -12,8 +17,16 @@ public struct Application: Identifiable, Sendable {
self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name) self.init(canonicalURL: url.standardizedFileURL.resolvingSymlinksInPath(), name: name)
} }
fileprivate init(canonicalURL: URL, name: String) { public init(command: Command) {
url = canonicalURL.hasDirectoryPath let name = switch command {
case .translate: "Conjure Translate"
}
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
}
fileprivate init(canonicalURL: URL, name: String, command: Command? = nil) {
self.command = command
url = command != nil || canonicalURL.hasDirectoryPath
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true) ? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
self.name = name self.name = name
searchableName = folded(name) searchableName = folded(name)
@@ -178,5 +191,5 @@ func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool {
let order = lhs.name.localizedStandardCompare(rhs.name) let order = lhs.name.localizedStandardCompare(rhs.name)
if order != .orderedSame { return order == .orderedAscending } if order != .orderedSame { return order == .orderedAscending }
if lhs.name != rhs.name { return lhs.name < rhs.name } if lhs.name != rhs.name { return lhs.name < rhs.name }
return lhs.url.path < rhs.url.path return lhs.url.absoluteString < rhs.url.absoluteString
} }
+4 -2
View File
@@ -18,7 +18,8 @@ public final class LaunchHistory {
self.defaults = defaults self.defaults = defaults
self.now = now self.now = now
for (identifier, value) in defaults.dictionary(forKey: Self.storageKey) ?? [:] { for (identifier, value) in defaults.dictionary(forKey: Self.storageKey) ?? [:] {
guard let url = URL(string: identifier), url.isFileURL, guard let url = URL(string: identifier),
url.isFileURL || Application.commands.contains(where: { $0.id == url }),
let record = value as? [String: Any], let record = value as? [String: Any],
let count = record["count"] as? Int, count > 0, let count = record["count"] as? Int, count > 0,
let lastLaunch = record["lastLaunch"] as? Date, let lastLaunch = record["lastLaunch"] as? Date,
@@ -26,7 +27,8 @@ public final class LaunchHistory {
usage[url] = Usage(count: count, lastLaunch: lastLaunch) usage[url] = Usage(count: count, lastLaunch: lastLaunch)
} }
for identifier in defaults.stringArray(forKey: Self.favoritesStorageKey) ?? [] { for identifier in defaults.stringArray(forKey: Self.favoritesStorageKey) ?? [] {
guard let url = URL(string: identifier), url.isFileURL else { continue } guard let url = URL(string: identifier),
url.isFileURL || Application.commands.contains(where: { $0.id == url }) else { continue }
favorites.insert(url) favorites.insert(url)
} }
} }
+1 -7
View File
@@ -1,7 +1 @@
- [x] add a translation "command" - [ ] improve on the conjure translation command
- it should appear in the list as a "Command" instead of an "Application" (entry type/kind)
- it should be named/titled "Conjure Translate"
- applying the open action on the command will cause the launcher to morph into a translation tool (ui transition like the uninstall confirmation view)
- the search bar becomes the translate input
- first pass should translate from english to japanese
- it should use gpt-6-luna as the translation unit (requires the user to insert an api key)
@@ -0,0 +1,70 @@
import AppKit
import ConjureKit
import Foundation
import Testing
@testable import Conjure
@Test @MainActor func commandsShareUsageFavoritesAndTextRankingWithApplications() {
let suite = "ConjureCommandRanking.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var now = Date(timeIntervalSinceReferenceDate: 12_000)
let history = LaunchHistory(defaults: defaults, now: { now })
let command = Application(command: .translate)
let app = Application(url: URL(fileURLWithPath: "/Applications/Conjure Test.app"), name: "Conjure Test")
let sameNameApp = Application(url: URL(fileURLWithPath: "/Applications/Conjure Translate.app"), name: command.name)
let entries = [app, sameNameApp, command]
history.recordLaunch(of: app)
now += 1
history.recordLaunch(of: command)
#expect(history.suggestions(in: entries).first?.id == command.id)
#expect(history.results(for: "conjure", in: entries).first?.id == command.id)
#expect(history.results(for: "ct", in: entries).first?.id == command.id)
history.recordLaunch(of: app)
#expect(history.suggestions(in: entries).first?.id == app.id)
#expect(history.results(for: "Conjure Translate", in: entries).first?.id == command.id)
history.toggleFavorite(command)
#expect(history.suggestions(in: entries).first?.id == command.id)
#expect(!history.isFavorite(sameNameApp))
let reloaded = LaunchHistory(defaults: defaults, now: { now })
#expect(reloaded.isFavorite(command))
#expect(reloaded.suggestions(in: entries).first?.id == command.id)
reloaded.toggleFavorite(command)
#expect(reloaded.results(for: "conjure", in: entries).first?.id == app.id)
#expect(reloaded.results(for: "Conjure Translate", in: entries).first?.id == command.id)
reloaded.recordLaunch(of: sameNameApp)
reloaded.resetRanking(of: command)
let afterReset = LaunchHistory(defaults: defaults, now: { now })
#expect(afterReset.suggestions(in: entries).first?.id == app.id)
#expect(afterReset.results(for: "Conjure Translate", in: entries).first?.id == sameNameApp.id)
}
@Test @MainActor func openingCommandByReturnAndNumberShortcutRecordsUsage() throws {
_ = NSApplication.shared
let suite = "ConjureCommandOpening.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let now = Date(timeIntervalSinceReferenceDate: 13_000)
let history = LaunchHistory(defaults: defaults, now: { now })
let command = Application(command: .translate)
let competitor = Application(url: URL(fileURLWithPath: "/Applications/Alpha.app"), name: "Alpha")
history.recordLaunch(of: competitor)
let launcher = LauncherWindowController(history: history)
let content = try #require(launcher.window?.contentView)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "ct"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first)
#expect(table.numberOfRows == 1)
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
#expect(history.suggestions(in: [command, competitor]).first?.id == competitor.id)
let panel = try #require(launcher.window as? LauncherPanel)
panel.launchNumber?(0)
#expect(search.accessibilityLabel() == "English text to translate into Japanese")
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
let reloaded = LaunchHistory(defaults: defaults, now: { now })
#expect(reloaded.suggestions(in: [command, competitor]).first?.id == command.id)
}