feat: learn launch suggestions and preserve selection during refresh
This commit is contained in:
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
|
||||
private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
|
||||
private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
|
||||
private let history: LaunchHistory
|
||||
private var applications: [Application] = []
|
||||
private var results: [Application] = []
|
||||
private var discoveryTask: Task<Void, Never>?
|
||||
private var isLaunching = false
|
||||
|
||||
init() {
|
||||
init(history: LaunchHistory = LaunchHistory()) {
|
||||
self.history = history
|
||||
let window = LauncherPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
||||
styleMask: [.borderless],
|
||||
@@ -227,16 +229,23 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
guard let self else { return }
|
||||
self.applications = applications
|
||||
self.discoveryTask = nil
|
||||
self.updateResults()
|
||||
self.updateResults(preservingSelection: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateResults() {
|
||||
results = ApplicationSearch.results(for: searchField.stringValue, in: applications)
|
||||
private func updateResults(preservingSelection: Bool = false) {
|
||||
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()
|
||||
if !results.isEmpty {
|
||||
tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
|
||||
tableView.scrollRowToVisible(0)
|
||||
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
|
||||
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
||||
tableView.scrollRowToVisible(row)
|
||||
}
|
||||
emptyLabel.isHidden = !results.isEmpty
|
||||
if discoveryTask != nil {
|
||||
@@ -350,6 +359,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
Task {
|
||||
do {
|
||||
try await AppLauncher.launch(application.url)
|
||||
history.recordLaunch(of: application)
|
||||
window?.orderOut(nil)
|
||||
} catch {
|
||||
let alert = NSAlert(error: error)
|
||||
|
||||
@@ -12,7 +12,8 @@ public struct Application: Identifiable, Sendable {
|
||||
}
|
||||
|
||||
fileprivate init(canonicalURL: URL, name: String) {
|
||||
url = canonicalURL
|
||||
url = canonicalURL.hasDirectoryPath
|
||||
? canonicalURL : URL(fileURLWithPath: canonicalURL.path, isDirectory: true)
|
||||
self.name = name
|
||||
searchableName = folded(name)
|
||||
searchableCharacters = Array(searchableName)
|
||||
@@ -137,7 +138,7 @@ private func folded(_ string: String) -> String {
|
||||
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)
|
||||
if order != .orderedSame { return order == .orderedAscending }
|
||||
if lhs.name != rhs.name { return lhs.name < rhs.name }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user