feat: add contextual actions and recoverable application uninstall

This commit is contained in:
2026-10-12 15:01:15 +02:00
parent 8d8e2278ab
commit 2598f6f1dd
10 changed files with 2361 additions and 12 deletions
+54 -5
View File
@@ -17,9 +17,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var previousApplication: NSRunningApplication?
private var alignmentGuides: LauncherAlignmentGuides?
private static let positionKey = "launcherPosition"
private let uninstaller: ApplicationUninstaller
private let actionsButton = NSButton(title: "Actions ⌘K", target: nil, action: nil)
private let actions = ApplicationActionsController()
private var uninstallReview: UninstallReviewController?
init(history: LaunchHistory = LaunchHistory()) {
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history
self.uninstaller = uninstaller
let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless],
@@ -46,6 +51,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.alignmentGuides?.end(snapping: self.window)
self.savePosition()
}
actions.closed = { [weak self] in
guard let self else { return }
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
else { self.dismiss(restoringFocus: false) }
}
restorePosition()
}
@@ -81,6 +91,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
actions.close()
alignmentGuides?.end()
let previous = previousApplication
previousApplication = nil
@@ -132,7 +143,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
func windowDidResignKey(_ notification: Notification) {
guard !isLaunching, window?.attachedSheet == nil else { return }
guard !isLaunching, !actions.isShown, window?.attachedSheet == nil else { return }
dismiss(restoringFocus: false)
}
@@ -228,6 +239,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
openButton.target = self
openButton.action = #selector(openSelection)
openButton.isEnabled = false
actionsButton.isBordered = false
actionsButton.font = .systemFont(ofSize: 14, weight: .medium)
actionsButton.contentTintColor = SunsetPalette.text
actionsButton.keyEquivalent = "k"
actionsButton.keyEquivalentModifierMask = .command
actionsButton.target = self
actionsButton.action = #selector(showActions)
actionsButton.setAccessibilityLabel("Application actions")
actionsButton.toolTip = "Actions for the selected application (Command-K)"
actionsButton.isEnabled = false
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
let returnKey = NSTextField(labelWithString: "↵")
returnKey.font = .systemFont(ofSize: 14)
@@ -244,7 +265,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
actionPill.state = .active
roundMaterialCorners(actionPill, radius: 18)
actionPill.layer?.borderWidth = 0.5
for view in [openButton, returnKey] {
for view in [openButton, returnKey, actionsButton] {
view.translatesAutoresizingMaskIntoConstraints = false
actionPill.addSubview(view)
}
@@ -282,7 +303,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
openButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14),
openButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.leadingAnchor.constraint(equalTo: openButton.trailingAnchor, constant: 8),
returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
actionsButton.leadingAnchor.constraint(equalTo: returnKey.trailingAnchor, constant: 14),
actionsButton.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -14),
actionsButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.widthAnchor.constraint(equalToConstant: 18),
returnKey.heightAnchor.constraint(equalToConstant: 18),
@@ -305,6 +328,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
ApplicationCatalog.discover()
}.value
guard let self else { return }
guard !Task.isCancelled else { return }
self.applications = applications
self.updateLocationLabels()
self.discoveryTask = nil
@@ -388,6 +412,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func updateOpenButton() {
openButton.isEnabled = !isLaunching && results.indices.contains(tableView.selectedRow)
actionsButton.isEnabled = openButton.isEnabled
}
func tableViewSelectionDidChange(_ notification: Notification) {
@@ -465,6 +490,30 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
return cell
}
@objc private func showActions() {
guard !isLaunching, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
let application = results[tableView.selectedRow]
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
actions.show(for: application, relativeTo: actionsButton)
}
private func reviewUninstall(_ application: Application) {
guard let window, window.attachedSheet == nil else { return }
let review = UninstallReviewController(application: application, uninstaller: uninstaller)
uninstallReview = review
review.closed = { [weak self] in
self?.uninstallReview = nil
self?.window?.makeFirstResponder(self?.searchField)
}
review.changed = { [weak self] in
guard let self else { return }
self.discoveryTask?.cancel()
self.discoveryTask = nil
self.refreshApplications()
}
review.present(on: window)
}
@objc private func openSelection() {
guard !isLaunching, results.indices.contains(tableView.selectedRow) else { return }
let application = results[tableView.selectedRow]
@@ -551,7 +600,7 @@ private final class LauncherPanel: NSPanel {
}
@MainActor
private final class LauncherRowView: NSTableRowView {
final class LauncherRowView: NSTableRowView {
override func drawSelection(in dirtyRect: NSRect) {
let selection = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
SunsetPalette.selectionFill.setFill()