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
+16 -6
View File
@@ -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)
+3 -2
View File
@@ -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 }
+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)
}
}
}