feat: learn launch suggestions and preserve selection during refresh
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user