diff --git a/README.md b/README.md index cc0640d..425aa2f 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,11 @@ Conjure recognizes the native [login-launch Apple event](https://developer.apple 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, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches are ordered by successful launch count, then most recent launch, then alphabetically. Usage never promotes a weaker text match above a stronger one or changes which applications match. 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, full-name prefix, word prefix, ordered word initials, substring, then ordered-subsequence fuzzy matches. Fuzzy matches favor smaller gaps and earlier starts. Equally good text matches put favorites first, then order by successful launch count, most recent launch, and alphabetically. Neither favorites nor usage promote a weaker text match above a stronger one or change which applications match. Hover over a result to see its path. Launch failures appear in a native alert without dismissing the launcher. Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code. -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. +With an empty search, favorites appear first. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group. 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. @@ -116,6 +116,7 @@ This is not full Raycast parity: calendar/date/time-zone calculations, design un 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. +- **Favorite / Remove Favorite** toggles the selected application's persisted favorite status, updates ranking immediately, and preserves the selection without recording a launch. Open remains the default action. Reset ranking clears usage but does not remove a favorite. - **Show in Finder** reveals the selected application bundle and dismisses the launcher without restoring the previous application's focus. Its icon is the native monochrome Finder glyph; macOS versions without that symbol use Finder's application icon. - **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. diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index 2ac2018..78557f0 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -6,6 +6,7 @@ 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: "Favorite", symbol: "star", color: SunsetPalette.text), ActionEntry(title: "Show in Finder", symbol: "finder", color: SunsetPalette.text), ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text), ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text), @@ -24,6 +25,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat private var transitionGeneration = 0 private var panel: ActionsPanelView { view as! ActionsPanelView } var open: (() -> Void)? + var toggleFavorite: (() -> Void)? + var isFavorite = false var showInFinder: (() -> Void)? var resetRanking: (() -> Void)? var quit: (() -> Void)? @@ -126,6 +129,13 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat let pill = anchor.superview ?? anchor _ = view applicationLabel.stringValue = application.name + let favorite = entries[1] + let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" + favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [ + .foregroundColor: SunsetPalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) + ]) + favorite.button.setAccessibilityLabel(favoriteTitle) + favorite.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil) search.stringValue = "" controlTextDidChange(Notification(name: NSControl.textDidChangeNotification)) transitionGeneration += 1 @@ -242,10 +252,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat close() switch index { case 0: open?() - case 1: showInFinder?() - case 2: resetRanking?() - case 3: quit?() - case 4: uninstall?() + case 1: toggleFavorite?() + case 2: showInFinder?() + case 3: resetRanking?() + case 4: quit?() + case 5: uninstall?() default: break } } @@ -255,6 +266,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat private struct ActionEntry { let view = LauncherRowView() let button: NSButton + let icon = ActionIconView() let returnKey = ShortcutIndicator("↵") init(title: String, symbol: String, color: NSColor) { @@ -267,7 +279,6 @@ private struct ActionEntry { .foregroundColor: color, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) ]) button.setAccessibilityLabel(title) - let icon = ActionIconView() icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) ?? (symbol == "finder" ? NSWorkspace.shared.icon(forFile: "/System/Library/CoreServices/Finder.app") : nil) icon.contentTintColor = color diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 332e071..cf9ba5a 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -591,6 +591,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe (window as? LauncherPanel)?.resetNumberHints() let application = results[tableView.selectedRow] actions.open = { [weak self] in self?.openApplication(application) } + actions.isFavorite = history.isFavorite(application) + actions.toggleFavorite = { [weak self] in + guard let self else { return } + self.history.toggleFavorite(application) + self.updateResults(preservingSelection: true) + } actions.showInFinder = { [weak self] in self?.dismiss(restoringFocus: false) NSWorkspace.shared.activateFileViewerSelecting([application.url]) diff --git a/Sources/ConjureKit/LaunchHistory.swift b/Sources/ConjureKit/LaunchHistory.swift index 52babce..b3bff29 100644 --- a/Sources/ConjureKit/LaunchHistory.swift +++ b/Sources/ConjureKit/LaunchHistory.swift @@ -8,9 +8,11 @@ public final class LaunchHistory { } private static let storageKey = "applicationLaunchHistory" + private static let favoritesStorageKey = "applicationFavorites" private let defaults: UserDefaults private let now: () -> Date private var usage: [URL: Usage] = [:] + private var favorites = Set() public init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) { self.defaults = defaults @@ -23,6 +25,21 @@ public final class LaunchHistory { lastLaunch.timeIntervalSinceReferenceDate.isFinite else { continue } usage[url] = Usage(count: count, lastLaunch: lastLaunch) } + for identifier in defaults.stringArray(forKey: Self.favoritesStorageKey) ?? [] { + guard let url = URL(string: identifier), url.isFileURL else { continue } + favorites.insert(url) + } + } + + public func isFavorite(_ application: Application) -> Bool { + favorites.contains(application.id) + } + + public func toggleFavorite(_ application: Application) { + if favorites.remove(application.id) == nil { + favorites.insert(application.id) + } + defaults.set(favorites.map(\.absoluteString), forKey: Self.favoritesStorageKey) } public func recordLaunch(of application: Application) { @@ -57,6 +74,9 @@ public final class LaunchHistory { } private func preferred(_ lhs: Application, _ rhs: Application) -> Bool { + let leftFavorite = isFavorite(lhs) + let rightFavorite = isFavorite(rhs) + if leftFavorite != rightFavorite { return leftFavorite } let left = usage[lhs.id] let right = usage[rhs.id] let leftCount = left?.count ?? 0 diff --git a/TODO.md b/TODO.md index 85463ae..8bee9a0 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,3 @@ -- [ ] add a favorite action to the list of actions and display the favorites on top of the other results. ranking then becomes split in two checks/passes: - - first, sort by rankings, then sort by favorites - - that should put the favorites first sorted by their ranking and then other applications sorted by their rankings below i believe \ No newline at end of file +- [x] Add Favorite / Remove Favorite actions and persist favorites by application bundle. + - Empty search: favorites first, then usage ranking within each group. + - Typed search: match quality first, then favorites, launch count, recency, and alphabetical order. \ No newline at end of file diff --git a/Tests/ConjureKitTests/LaunchHistoryTests.swift b/Tests/ConjureKitTests/LaunchHistoryTests.swift index 91dbe19..52096ab 100644 --- a/Tests/ConjureKitTests/LaunchHistoryTests.swift +++ b/Tests/ConjureKitTests/LaunchHistoryTests.swift @@ -77,6 +77,12 @@ func applicationIdentityIgnoresDirectoryURLFormattingInLaunchHistory() { #expect(plain.id == directory.id) history.recordLaunch(of: directory) #expect(history.suggestions(in: [historyApplication("Alpha"), plain]).first?.id == plain.id) + history.toggleFavorite(directory) + #expect(history.isFavorite(plain)) + let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant }) + #expect(reloaded.isFavorite(plain)) + reloaded.toggleFavorite(plain) + #expect(!reloaded.isFavorite(directory)) } @Test @MainActor @@ -135,6 +141,132 @@ func resetRankingClearsOnlySelectedIdentityAndPersistsAcrossRelaunch() { #expect(reloaded.suggestions(in: apps).map(\.id) == [first.id, second.id, alpha.id]) } +@Test @MainActor +func emptySearchPlacesFavoritesFirstAndPreservesUsageOrderingWithinEachGroup() { + let suite = "ConjureHistoryTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + var instant = Date(timeIntervalSinceReferenceDate: 7_000) + let history = LaunchHistory(defaults: defaults, now: { instant }) + let favorites = ["Favorite Frequent", "Favorite Recent", "Favorite Older", "Favorite Unused Alpha", "Favorite Unused Zebra"] + .map(historyApplication) + let others = ["Other Frequent", "Other Recent", "Other Older", "Other Unused Alpha", "Other Unused Zebra"] + .map(historyApplication) + for application in favorites { history.toggleFavorite(application) } + for group in [favorites, others] { + history.recordLaunch(of: group[0]) + history.recordLaunch(of: group[0]) + history.recordLaunch(of: group[2]) + instant += 10 + history.recordLaunch(of: group[1]) + } + for _ in 0..<8 { history.recordLaunch(of: others[0]) } + let expected = (favorites + others).map(\.id) + let apps = Array((others + favorites).reversed()) + #expect(history.suggestions(in: apps).map(\.id) == expected) + #expect(history.results(for: "", in: apps).map(\.id) == expected) + #expect(history.results(for: " \n", in: apps).map(\.id) == expected) +} + +@Test @MainActor +func typedSearchPrefersTextRankBeforeFavoritesAndFavoritesBeforeUsage() { + let suite = "ConjureHistoryTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let instant = Date(timeIntervalSinceReferenceDate: 8_000) + let history = LaunchHistory(defaults: defaults, now: { instant }) + let apps = ["AB", "AB Alpha", "AB Heavy", "AyB", "AzB", "AxxxxB", "Unrelated"].map(historyApplication) + for index in [1, 3, 5, 6] { history.toggleFavorite(apps[index]) } + for _ in 0..<5 { + history.recordLaunch(of: apps[2]) + history.recordLaunch(of: apps[4]) + } + #expect(history.results(for: "ab", in: Array(apps.reversed())).map(\.id) == Array(apps.dropLast()).map(\.id)) +} + +@Test @MainActor +func favoritesPersistWithoutUsageAndToggleRemovalPersistsAcrossRelaunch() { + let suite = "ConjureHistoryTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let instant = Date(timeIntervalSinceReferenceDate: 9_000) + let history = LaunchHistory(defaults: defaults, now: { instant }) + let alpha = historyApplication("Alpha") + let zebra = historyApplication("Zebra") + let removed = historyApplication("Removed") + #expect(!history.isFavorite(zebra)) + history.toggleFavorite(zebra) + history.toggleFavorite(removed) + #expect(history.isFavorite(zebra)) + + let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant }) + #expect(reloaded.isFavorite(zebra)) + #expect(reloaded.isFavorite(removed)) + #expect(reloaded.suggestions(in: [alpha, zebra]).map(\.id) == [zebra.id, alpha.id]) + reloaded.toggleFavorite(zebra) + #expect(!reloaded.isFavorite(zebra)) + let afterRemoval = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant }) + #expect(!afterRemoval.isFavorite(zebra)) + #expect(afterRemoval.isFavorite(removed)) + #expect(afterRemoval.suggestions(in: [zebra, alpha]).map(\.id) == [alpha.id, zebra.id]) +} + +@Test @MainActor +func resettingUsageRetainsFavoritesAndTheirPersistence() { + let suite = "ConjureHistoryTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let instant = Date(timeIntervalSinceReferenceDate: 10_000) + let history = LaunchHistory(defaults: defaults, now: { instant }) + let alpha = historyApplication("Alpha") + let zebra = historyApplication("Zebra") + let other = historyApplication("Other") + let apps = [other, zebra, alpha] + history.toggleFavorite(alpha) + history.toggleFavorite(zebra) + history.recordLaunch(of: zebra) + history.recordLaunch(of: zebra) + for _ in 0..<5 { history.recordLaunch(of: other) } + #expect(history.suggestions(in: apps).map(\.id) == [zebra.id, alpha.id, other.id]) + history.resetRanking(of: zebra) + history.resetRanking(of: zebra) + #expect(history.isFavorite(zebra)) + #expect(history.suggestions(in: apps).map(\.id) == [alpha.id, zebra.id, other.id]) + + let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant }) + #expect(reloaded.isFavorite(alpha)) + #expect(reloaded.isFavorite(zebra)) + #expect(reloaded.suggestions(in: apps).map(\.id) == [alpha.id, zebra.id, other.id]) + reloaded.recordLaunch(of: zebra) + #expect(reloaded.isFavorite(zebra)) + #expect(reloaded.suggestions(in: apps).map(\.id) == [zebra.id, alpha.id, other.id]) +} + +@Test @MainActor +func favoritesTrackApplicationURLsRatherThanDisplayNames() { + let suite = "ConjureHistoryTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let instant = Date(timeIntervalSinceReferenceDate: 11_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") + for _ in 0..<5 { history.recordLaunch(of: first) } + history.toggleFavorite(second) + #expect(!history.isFavorite(first)) + #expect(history.isFavorite(second)) + #expect(history.results(for: "editor", in: [first, second]).map(\.id) == [second.id, first.id]) + + let renamed = Application(url: second.url, name: "Renamed Editor") + let reloaded = LaunchHistory(defaults: UserDefaults(suiteName: suite)!, now: { instant }) + #expect(!reloaded.isFavorite(first)) + #expect(reloaded.isFavorite(renamed)) + #expect(reloaded.suggestions(in: [first, renamed]).map(\.id) == [second.id, first.id]) + reloaded.toggleFavorite(renamed) + #expect(!reloaded.isFavorite(second)) + #expect(reloaded.suggestions(in: [second, first]).map(\.id) == [first.id, second.id]) +} + private func historyApplication(_ name: String) -> Application { Application(url: URL(fileURLWithPath: "/Applications/HistoryTests/\(name).app"), name: name) }