feat: learn launch suggestions and preserve selection during refresh

This commit is contained in:
2026-10-11 20:52:00 +02:00
parent 01cf6cc8c4
commit e9ac609884
5 changed files with 163 additions and 9 deletions
+5 -1
View File
@@ -33,7 +33,11 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to
Applications are discovered in `/Applications`, `/System/Applications`, `/System/Library/CoreServices/Applications`, and `~/Applications`, including nested folders. Discovery runs off the UI thread and refreshes whenever the launcher is presented. Hidden items, invalid bundles, and helpers inside application packages are excluded; symlinked applications are deduplicated by their canonical paths. Applications are discovered in `/Applications`, `/System/Applications`, `/System/Library/CoreServices/Applications`, and `~/Applications`, including nested folders. Discovery runs off the UI thread and refreshes whenever the launcher is presented. Hidden items, invalid bundles, and helpers inside application packages are excluded; symlinked applications are deduplicated by their canonical paths.
Search is case- and accent-insensitive. Ranking is exact name, prefix, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Ties and empty searches are alphabetical. 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. Ranking is exact name, prefix, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Ties are alphabetical. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher.
With an empty search, applications are ordered by successful launch count, then most recent launch, then alphabetically. Usage is stored locally in the app's preferences and survives restarts. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical.
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.
## Native UI ## Native UI
+16 -6
View File
@@ -8,12 +8,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private let statusLabel = NSTextField(labelWithString: "Finding applications…") private let statusLabel = NSTextField(labelWithString: "Finding applications…")
private let emptyLabel = NSTextField(labelWithString: "Finding applications…") private let emptyLabel = NSTextField(labelWithString: "Finding 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 var applications: [Application] = [] private var applications: [Application] = []
private var results: [Application] = [] private var results: [Application] = []
private var discoveryTask: Task<Void, Never>? private var discoveryTask: Task<Void, Never>?
private var isLaunching = false private var isLaunching = false
init() { init(history: LaunchHistory = LaunchHistory()) {
self.history = history
let window = LauncherPanel( let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474), contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless], styleMask: [.borderless],
@@ -227,16 +229,23 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
guard let self else { return } guard let self else { return }
self.applications = applications self.applications = applications
self.discoveryTask = nil self.discoveryTask = nil
self.updateResults() self.updateResults(preservingSelection: true)
} }
} }
private func updateResults() { private func updateResults(preservingSelection: Bool = false) {
results = ApplicationSearch.results(for: searchField.stringValue, in: applications) let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil
if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
results = history.suggestions(in: applications)
} else {
results = ApplicationSearch.results(for: searchField.stringValue, in: applications)
}
tableView.reloadData() tableView.reloadData()
if !results.isEmpty { if !results.isEmpty {
tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
tableView.scrollRowToVisible(0) tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
tableView.scrollRowToVisible(row)
} }
emptyLabel.isHidden = !results.isEmpty emptyLabel.isHidden = !results.isEmpty
if discoveryTask != nil { if discoveryTask != nil {
@@ -350,6 +359,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
Task { Task {
do { do {
try await AppLauncher.launch(application.url) try await AppLauncher.launch(application.url)
history.recordLaunch(of: application)
window?.orderOut(nil) window?.orderOut(nil)
} catch { } catch {
let alert = NSAlert(error: error) let alert = NSAlert(error: error)
+3 -2
View File
@@ -12,7 +12,8 @@ public struct Application: Identifiable, Sendable {
} }
fileprivate init(canonicalURL: URL, name: String) { fileprivate init(canonicalURL: URL, name: String) {
url = canonicalURL url = canonicalURL.hasDirectoryPath
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
self.name = name self.name = name
searchableName = folded(name) searchableName = folded(name)
searchableCharacters = Array(searchableName) searchableCharacters = Array(searchableName)
@@ -137,7 +138,7 @@ private func folded(_ string: String) -> String {
string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale) string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
} }
private func alphabetical(_ lhs: Application, _ rhs: Application) -> Bool { 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 }
+55
View File
@@ -0,0 +1,55 @@
import Foundation
@MainActor
public final class LaunchHistory {
private struct Usage {
var count: Int
var lastLaunch: Date
}
private static let storageKey = "applicationLaunchHistory"
private let defaults: UserDefaults
private let now: () -> Date
private var usage: [URL: Usage] = [:]
public init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
self.defaults = defaults
self.now = now
for (identifier, value) in defaults.dictionary(forKey: Self.storageKey) ?? [:] {
guard let url = URL(string: identifier), url.isFileURL,
let record = value as? [String: Any],
let count = record["count"] as? Int, count > 0,
let lastLaunch = record["lastLaunch"] as? Date,
lastLaunch.timeIntervalSinceReferenceDate.isFinite else { continue }
usage[url] = Usage(count: count, lastLaunch: lastLaunch)
}
}
public func recordLaunch(of application: Application) {
let timestamp = now()
var record = usage[application.id] ?? Usage(count: 0, lastLaunch: timestamp)
if record.count < Int.max { record.count += 1 }
record.lastLaunch = timestamp
usage[application.id] = record
var saved: [String: [String: Any]] = [:]
saved.reserveCapacity(usage.count)
for (url, record) in usage {
saved[url.absoluteString] = ["count": record.count, "lastLaunch": record.lastLaunch]
}
defaults.set(saved, forKey: Self.storageKey)
}
public func suggestions(in applications: [Application]) -> [Application] {
applications.sorted { lhs, rhs in
let left = usage[lhs.id]
let right = usage[rhs.id]
let leftCount = left?.count ?? 0
let rightCount = right?.count ?? 0
if leftCount != rightCount { return leftCount > rightCount }
if let left, let right, left.lastLaunch != right.lastLaunch {
return left.lastLaunch > right.lastLaunch
}
return alphabetical(lhs, rhs)
}
}
}
@@ -0,0 +1,84 @@
import ConjureKit
import Foundation
import Testing
@Test @MainActor
func suggestionsPreferFrequencyThenRecencyAndKeepUnusedAppsAlphabetical() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var instant = Date(timeIntervalSinceReferenceDate: 1_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let apps = ["Unused Zebra", "Recent", "Frequent", "Unused Alpha", "Older"].map(historyApplication)
#expect(history.suggestions(in: apps).map(\.name) == [
"Frequent", "Older", "Recent", "Unused Alpha", "Unused Zebra",
])
history.recordLaunch(of: apps[4])
instant += 10
history.recordLaunch(of: apps[2])
history.recordLaunch(of: apps[2])
instant += 10
history.recordLaunch(of: apps[1])
#expect(history.suggestions(in: apps).map(\.name) == [
"Frequent", "Recent", "Older", "Unused Alpha", "Unused Zebra",
])
}
@Test @MainActor
func suggestionsRetainUsageAcrossHistoryInstancesAndDoNotReintroduceMissingApps() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 2_000)
let alpha = historyApplication("Alpha")
let zebra = historyApplication("Zebra")
let removed = historyApplication("Removed")
let history = LaunchHistory(defaults: defaults, now: { instant })
history.recordLaunch(of: zebra)
history.recordLaunch(of: removed)
history.recordLaunch(of: removed)
let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant })
#expect(reloaded.suggestions(in: [alpha, zebra]).map(\.name) == ["Zebra", "Alpha"])
reloaded.recordLaunch(of: alpha)
#expect(reloaded.suggestions(in: [zebra, alpha]).map(\.name) == ["Alpha", "Zebra"])
}
@Test @MainActor
func usageTracksApplicationIdentityRatherThanDisplayNameAndKeepsTiesDeterministic() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 3_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let first = Application(url: URL(fileURLWithPath: "/First/Editor.app"), name: "Editor")
let second = Application(url: URL(fileURLWithPath: "/Second/Editor.app"), name: "Editor")
#expect(history.suggestions(in: [second, first]).map(\.url) == [first.url, second.url])
history.recordLaunch(of: second)
#expect(history.suggestions(in: [first, second]).map(\.url) == [second.url, first.url])
let renamed = Application(url: second.url, name: "Renamed Editor")
#expect(history.suggestions(in: [first, renamed]).map(\.url) == [second.url, first.url])
history.recordLaunch(of: first)
#expect(history.suggestions(in: [second, first]).map(\.url) == [first.url, second.url])
}
@Test @MainActor
func applicationIdentityIgnoresDirectoryURLFormattingInLaunchHistory() {
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let instant = Date(timeIntervalSinceReferenceDate: 4_000)
let history = LaunchHistory(defaults: defaults, now: { instant })
let path = "/Applications/HistoryTests/Editor.app"
let plain = Application(url: URL(fileURLWithPath: path, isDirectory: false), name: "Editor")
let directory = Application(url: URL(fileURLWithPath: path, isDirectory: true), name: "Editor")
#expect(plain.id == directory.id)
history.recordLaunch(of: directory)
#expect(history.suggestions(in: [historyApplication("Alpha"), plain]).first?.id == plain.id)
}
private func historyApplication(_ name: String) -> Application {
Application(url: URL(fileURLWithPath: "/Applications/HistoryTests/\(name).app"), name: name)
}