feat: add Finder, ranking reset, and quit actions
This commit is contained in:
@@ -79,6 +79,10 @@ Applications with identical display names show a muted containing-folder hint be
|
||||
|
||||
Select an application and open **Actions ⌘K**. **Open** is the first, default-selected action and uses the same launch path, successful-launch history tracking, dismissal, and error reporting as Return in the application list. Filtering resets selection to the first matching action; an empty result does nothing on Return. Up/Down or Control-K/Control-J navigates actions without changing the selected application.
|
||||
|
||||
- **Show in Finder** reveals the selected application bundle and dismisses the launcher without restoring the previous application's focus.
|
||||
- **Reset ranking** clears only the selected bundle's launch count and last-launch timestamp, including persisted history. Results reorder immediately while retaining the selected application; other copies and applications keep their history. Future launches start counting again.
|
||||
- **Quit application** sends a normal macOS quit request to running instances of that exact bundle, not another copy with the same name or bundle identifier. A non-running application is a no-op. The launcher dismisses after an accepted request; the application may still present save prompts or cancel quitting. Rejected requests show a native alert. Conjure never force-quits the application.
|
||||
|
||||
The red **Uninstall application** action morphs the existing launcher into a file-review screen with checkboxes, paths, sizes, and a selected-item count; it does not create another window or review sheet. A native 180-millisecond resize and content crossfade runs in both directions, or switches immediately with Reduce Motion. The filter uses a centered, intrinsic-height text field without built-in search/cancel icons, beside an explicit back chevron. Filtering files by name or path does not change file selections. Up/Down or Control-K/Control-J highlights files; Return toggles the highlighted eligible file, including from the filter input, and Space also toggles when the list has focus. Back or Escape restores the original launcher frame, query, and selection. Temporary review geometry is not saved as the launcher position. Leaving is blocked during confirmation or an uninstall operation. Command-Return or the uninstall button opens a native final confirmation dialog; nothing is moved merely by entering review. The confirmation control uses the launcher's frosted rounded pill with separate Command and Return key badges. The launcher Actions control likewise shows separate Command and K badges matching its Open Return badge.
|
||||
|
||||
Keyboard file toggles update the existing checkbox without reloading or fading the list, preserving both the highlighted row and scroll position. The launcher's footer Open and Actions labels use 12-point medium text. All custom shortcut hints—in the launcher footer, uninstall footer, and actions selector—use `ShortcutIndicator` from `Sources/Conjure/ShortcutIndicator.swift` (for example, `ShortcutIndicator("⌘", "K")`). It owns 12-point medium key text, square 18×18-point containers, visible-glyph centering, border styling, and 2-point gaps between adjacent keys; callers only position or hide the whole indicator. Native menus and shortcut-recording controls retain their platform styling.
|
||||
|
||||
@@ -18,4 +18,17 @@ enum AppLauncher {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func quit(_ url: URL) throws {
|
||||
let path = url.standardizedFileURL.resolvingSymlinksInPath().path
|
||||
for application in NSWorkspace.shared.runningApplications {
|
||||
guard application.bundleURL?.standardizedFileURL.resolvingSymlinksInPath().path == path,
|
||||
!application.isTerminated else { continue }
|
||||
guard application.terminate() else {
|
||||
throw NSError(domain: "Conjure", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "The application refused the quit request. Save your work and quit it manually."
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
private let search = ActionsSearchField()
|
||||
private let entries = [
|
||||
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Show in Finder", symbol: "folder", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed)
|
||||
]
|
||||
private var visibleEntries = [0, 1]
|
||||
private var visibleEntries: [Int] = []
|
||||
private var selectedEntry: Int? = 0
|
||||
private var rowTopConstraints: [NSLayoutConstraint] = []
|
||||
private var heightConstraint: NSLayoutConstraint?
|
||||
@@ -21,6 +24,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
private var transitionGeneration = 0
|
||||
private var panel: ActionsPanelView { view as! ActionsPanelView }
|
||||
var open: (() -> Void)?
|
||||
var showInFinder: (() -> Void)?
|
||||
var resetRanking: (() -> Void)?
|
||||
var quit: (() -> Void)?
|
||||
var uninstall: (() -> Void)?
|
||||
var closed: (() -> Void)?
|
||||
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
|
||||
@@ -88,7 +94,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
top
|
||||
])
|
||||
}
|
||||
let height = view.heightAnchor.constraint(equalToConstant: 116 + LauncherRowView.rowHeight)
|
||||
let height = view.heightAnchor.constraint(equalToConstant: 116 + CGFloat(entries.count - 1) * LauncherRowView.rowHeight)
|
||||
heightConstraint = height
|
||||
NSLayoutConstraint.activate([
|
||||
view.widthAnchor.constraint(equalToConstant: 360), height,
|
||||
@@ -234,7 +240,14 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
private func chooseAction(at index: Int) {
|
||||
guard isShown, visibleEntries.contains(index) else { return }
|
||||
close()
|
||||
if index == 0 { open?() } else { uninstall?() }
|
||||
switch index {
|
||||
case 0: open?()
|
||||
case 1: showInFinder?()
|
||||
case 2: resetRanking?()
|
||||
case 3: quit?()
|
||||
case 4: uninstall?()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -505,6 +505,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
|
||||
let application = results[tableView.selectedRow]
|
||||
actions.open = { [weak self] in self?.openApplication(application) }
|
||||
actions.showInFinder = { [weak self] in
|
||||
self?.dismiss(restoringFocus: false)
|
||||
NSWorkspace.shared.activateFileViewerSelecting([application.url])
|
||||
}
|
||||
actions.resetRanking = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.history.resetRanking(of: application)
|
||||
self.updateResults(preservingSelection: true)
|
||||
}
|
||||
actions.quit = { [weak self] in self?.quitApplication(application) }
|
||||
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
|
||||
actions.show(for: application, relativeTo: actionsButton)
|
||||
openButton.keyEquivalent = actions.isShown ? "" : "\r"
|
||||
@@ -576,6 +586,17 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
openApplication(results[tableView.selectedRow])
|
||||
}
|
||||
|
||||
private func quitApplication(_ application: Application) {
|
||||
do {
|
||||
try AppLauncher.quit(application.url)
|
||||
dismiss()
|
||||
} catch {
|
||||
let alert = NSAlert(error: error)
|
||||
alert.messageText = "Couldn’t quit \(application.name)"
|
||||
if let window { alert.beginSheetModal(for: window) }
|
||||
}
|
||||
}
|
||||
|
||||
private func openApplication(_ application: Application) {
|
||||
guard !isLaunching else { return }
|
||||
isLaunching = true
|
||||
|
||||
@@ -31,6 +31,15 @@ public final class LaunchHistory {
|
||||
if record.count < Int.max { record.count += 1 }
|
||||
record.lastLaunch = timestamp
|
||||
usage[application.id] = record
|
||||
save()
|
||||
}
|
||||
|
||||
public func resetRanking(of application: Application) {
|
||||
usage.removeValue(forKey: application.id)
|
||||
save()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
var saved: [String: [String: Any]] = [:]
|
||||
saved.reserveCapacity(usage.count)
|
||||
for (url, record) in usage {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- [x] make the actions indicator font size smaller.
|
||||
- [x] decrease the distance between the icons and their text by 1/3.
|
||||
- [ ] add more actions to the actions selector:
|
||||
- [x] add more actions to the actions selector:
|
||||
- show in finder
|
||||
- reset ranking
|
||||
- quit application
|
||||
|
||||
@@ -109,6 +109,32 @@ func typedSearchUsesHistoryOnlyForEqualTextRanks() {
|
||||
])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func resetRankingClearsOnlySelectedIdentityAndPersistsAcrossRelaunch() {
|
||||
let suite = "ConjureHistoryTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let instant = Date(timeIntervalSinceReferenceDate: 6_000)
|
||||
let history = LaunchHistory(defaults: defaults, now: { instant })
|
||||
let alpha = historyApplication("Editor Alpha")
|
||||
let first = Application(url: URL(fileURLWithPath: "/First/Editor.app"), name: "Editor")
|
||||
let second = Application(url: URL(fileURLWithPath: "/Second/Editor.app"), name: "Editor")
|
||||
let apps = [alpha, first, second]
|
||||
history.recordLaunch(of: second)
|
||||
history.recordLaunch(of: first)
|
||||
history.recordLaunch(of: first)
|
||||
#expect(history.suggestions(in: apps).map(\.id) == [first.id, second.id, alpha.id])
|
||||
|
||||
history.resetRanking(of: first)
|
||||
history.resetRanking(of: first)
|
||||
#expect(history.suggestions(in: apps).map(\.id) == [second.id, first.id, alpha.id])
|
||||
#expect(history.results(for: "editor", in: apps).map(\.id) == [second.id, first.id, alpha.id])
|
||||
let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant })
|
||||
#expect(reloaded.suggestions(in: apps).map(\.id) == [second.id, first.id, alpha.id])
|
||||
reloaded.recordLaunch(of: first)
|
||||
#expect(reloaded.suggestions(in: apps).map(\.id) == [first.id, second.id, alpha.id])
|
||||
}
|
||||
|
||||
private func historyApplication(_ name: String) -> Application {
|
||||
Application(url: URL(fileURLWithPath: "/Applications/HistoryTests/\(name).app"), name: name)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user