From e0d971205f3ff4cf8e7ad08c8ae38406b19adcdf Mon Sep 17 00:00:00 2001 From: adeetya-u Date: Tue, 13 Oct 2026 20:37:00 -0500 Subject: [PATCH 1/2] Launch apps from the search panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps whose names you type come first in the panel, under "Applications", and Return opens the top one, as in Spotlight or Alfred. An app matches by the start of its name, the start of a word in it ("code" for Visual Studio Code, "term" for iTerm), the starts of several words in order ("sys set"), or its initials ("vsc") — never the middle of a word, so "cat" doesn't launch Calculator. Among matches as good, apps launched from DigUp most and used most lately come first. The Applications folders are read at launch and again in the background when the panel opens (at most every 30 s). Matching lives in DigUpKit (AppSearch, with tests); reading the folders and launching in the app (AppLauncher). Settings → General can turn it off. Co-Authored-By: Claude Opus 5.5 --- Sources/DigUpApp/Apps.swift | 186 +++++++++++++++++++++++ Sources/DigUpApp/Moments.swift | 3 +- Sources/DigUpApp/Previews.swift | 9 +- Sources/DigUpApp/Results.swift | 20 +++ Sources/DigUpApp/SearchPanel.swift | 25 ++- Sources/DigUpApp/SearchPanelView.swift | 5 +- Sources/DigUpApp/SettingsWindow.swift | 12 ++ Sources/DigUpKit/AppSearch.swift | 106 +++++++++++++ Tests/DigUpKitTests/AppSearchTests.swift | 74 +++++++++ 9 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 Sources/DigUpApp/Apps.swift create mode 100644 Sources/DigUpKit/AppSearch.swift create mode 100644 Tests/DigUpKitTests/AppSearchTests.swift diff --git a/Sources/DigUpApp/Apps.swift b/Sources/DigUpApp/Apps.swift new file mode 100644 index 00000000..ca83d28a --- /dev/null +++ b/Sources/DigUpApp/Apps.swift @@ -0,0 +1,186 @@ +import AppKit +import CoreServices +import DigUpKit + +/// One app the panel can launch. +nonisolated struct InstalledApp: Sendable { + let url: URL + /// The name Finder shows ("Visual Studio Code"), then any other the app goes by (its bundle's: "Code"). + let names: [AppName] + let version: String? + /// When it was last opened, however it was (Spotlight keeps this for every app). + let lastUsed: Date? + let modified: Date + + var path: String { url.path } +} + +extension ResultRow { + /// The picture of the file, or the app's icon, without the arrow of a link (/Applications/Safari.app is one). + var iconURL: URL { isApp ? url.resolvingSymlinksInPath() : url } +} + +/// The apps in the Applications folders, found by name as you type (`AppSearch`): they come first in the panel, and ↩ +/// launches the top one, as in Spotlight or Alfred. The apps you launch from DigUp most, and the ones you've used +/// lately, come first among matches as good. +/// +/// The folders are read when the app starts and again, in the background, each time the panel opens (at most every 30 +/// seconds): an app you just installed shows up the next time you look. +final class AppLauncher { + static let shared = AppLauncher() + /// The most the panel shows: past a few, the name you typed fits too many apps to be much help. + static let limit = 4 + static let rescanAfter: TimeInterval = 30 + /// Off in Settings: the panel finds only files. + static let settingKey = "searchApps" + + /// Where apps are, and how many folders down to look (Utilities, an app's own folder like "Adobe Photoshop 2026", + /// Chrome's web apps). Never inside an app. + nonisolated static let folders: [(URL, depth: Int)] = [ + (URL(fileURLWithPath: "/Applications"), 2), + (FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications"), 2), + (URL(fileURLWithPath: "/System/Applications"), 1), + (URL(fileURLWithPath: "/System/Library/CoreServices/Applications"), 0), + ] + /// Apps that live outside those folders but people launch by name. + nonisolated static let extras = [URL(fileURLWithPath: "/System/Library/CoreServices/Finder.app")] + + private(set) var apps: [InstalledApp] = [] + private var scannedAt = Date.distantPast + private var scanning = false + /// Launches from the panel, by path (`appLaunches` in defaults). + private var launches: [String: Int] + /// The apps found changed (the first read, an app installed or removed). + var onChange: () -> Void = {} + + init() { + launches = AppSettings.defaults.dictionary(forKey: "appLaunches") as? [String: Int] ?? [:] + } + + var isOn: Bool { AppSettings.defaults.object(forKey: Self.settingKey) as? Bool ?? true } + + /// Reads the folders again in the background, unless that was done moments ago. + func refresh() { + guard isOn, !scanning, Date().timeIntervalSince(scannedAt) > Self.rescanAfter else { return } + scanning = true + let started = Date() + Task { + let found = await Task.detached(priority: .userInitiated) { Self.scan() }.value + let changed = found.map(\.path) != apps.map(\.path) + apps = found + scannedAt = Date() + scanning = false + if changed { + log("apps: \(found.count) in \(ms(since: started))") + onChange() + } + } + } + + /// The apps `query` names, best first, as panel rows. + func matches(_ query: String) -> [ResultRow] { + guard isOn else { return [] } + let now = Date() + return AppSearch.rank(query, apps, names: \.names, boost: { self.boost($0, now: now) }, limit: Self.limit) + .map(ResultRow.init(app:)) + } + + /// Opens the app (or brings it forward, if it's running), and remembers it was chosen. + func launch(_ url: URL) { + launches[url.path, default: 0] += 1 + AppSettings.defaults.set(launches, forKey: "appLaunches") + let name = url.deletingPathExtension().lastPathComponent + if Opener.dryRun { return log("launch (dry run): \(name)") } + log("launch: \(name)") + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.openApplication(at: url, configuration: configuration) { _, error in + if let error { log("launch: \(name) failed: \(error.localizedDescription)") } + } + } + + /// How much to favor an app among matches as good: up to 200 for the ones launched from here most (25 launches), + /// and up to 60 for one used today, less for this week or this month. + private func boost(_ app: InstalledApp, now: Date) -> Int { + let launched = min(launches[app.path] ?? 0, 25) * 8 + let days = app.lastUsed.map { now.timeIntervalSince($0) / 86_400 } ?? .infinity + let recent = days < 1 ? 60 : days < 7 ? 40 : days < 30 ? 20 : 0 + return launched + recent + } + + // MARK: Reading the folders + + nonisolated static func scan() -> [InstalledApp] { + var found: [InstalledApp] = [] + var seen = Set() // an app in two places (by bundle ID, else path) shows once, from the first + func add(_ url: URL) { + guard let app = read(url) else { return } + let id = Bundle(url: url)?.bundleIdentifier ?? url.resolvingSymlinksInPath().path + guard seen.insert(id).inserted else { return } + found.append(app) + } + func look(in folder: URL, depth: Int) { + let keys: [URLResourceKey] = [.isDirectoryKey] + // Not `.skipsHiddenFiles`: /Applications/Safari.app is a link into a cryptex that's flagged hidden. + guard let items = try? FileManager.default.contentsOfDirectory( + at: folder, includingPropertiesForKeys: keys) else { return } + for item in items where !item.lastPathComponent.hasPrefix(".") { + if item.pathExtension == "app" { + add(item) + } else if depth > 0, (try? item.resourceValues(forKeys: Set(keys)))?.isDirectory == true { + look(in: item, depth: depth - 1) + } + } + } + for (folder, depth) in folders { look(in: folder, depth: depth) } + for url in extras { add(url) } + return found + } + + /// Nil for an app that can't be launched by hand (a background-only helper). + private nonisolated static func read(_ url: URL) -> InstalledApp? { + let bundle = Bundle(url: url) + let info = bundle?.infoDictionary ?? [:] + if info["LSBackgroundOnly"] as? Bool == true { return nil } + var shown = FileManager.default.displayName(atPath: url.path) + if shown.hasSuffix(".app") { shown = String(shown.dropLast(4)) } + var names = [AppName(shown)] + let others: [Any?] = [bundle?.localizedInfoDictionary?["CFBundleDisplayName"], info["CFBundleDisplayName"], + bundle?.localizedInfoDictionary?["CFBundleName"], info["CFBundleName"], + url.deletingPathExtension().lastPathComponent] + for other in others.compactMap({ $0 as? String }) where !other.isEmpty { + if !names.contains(where: { $0.name.lowercased() == other.lowercased() }) { names.append(AppName(other)) } + } + var lastUsed: Date? + if let item = MDItemCreateWithURL(kCFAllocatorDefault, url as CFURL) { + lastUsed = MDItemCopyAttribute(item, kMDItemLastUsedDate) as? Date + } + let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + return InstalledApp(url: url, names: names, version: info["CFBundleShortVersionString"] as? String, + lastUsed: lastUsed, modified: modified ?? .distantPast) + } +} + +extension ResultRow { + /// An app, as the panel lists it: by the name Finder shows, in the folder it's in. + init(app: InstalledApp) { + id = app.path + url = app.url + name = app.names.first?.name ?? app.url.deletingPathExtension().lastPathComponent + folder = tildePath(app.url.deletingLastPathComponent().path) + isApp = true + kind = .doc // unused for an app: nothing about one is a file kind's + segment = .name + segmentID = 0 + loc = nil + locEnd = nil + focus = nil + excerpt = nil + matchedMeaning = false + matchedWords = false + modified = app.modified + size = 0 + info = app.version.map { ["version": $0] } ?? [:] + terms = [] + } +} diff --git a/Sources/DigUpApp/Moments.swift b/Sources/DigUpApp/Moments.swift index 16a149f3..2731349d 100644 --- a/Sources/DigUpApp/Moments.swift +++ b/Sources/DigUpApp/Moments.swift @@ -79,8 +79,9 @@ enum QuickLookMoment { extension Engine { /// Opens `row` in its app: a PDF with words from its page to search for, a recording at its moment when the app - /// can be told it (see `Opener`). + /// can be told it (see `Opener`). An app's row launches the app. func open(_ row: ResultRow) { + if row.isApp { return AppLauncher.shared.launch(row.url) } if row.kind == .code { return CodeEditor.current.open(row.url, line: row.codeLine) } guard let page = row.page else { return Opener.open(row, searching: nil) } let (url, words) = (row.url, row.foundWords + row.terms) diff --git a/Sources/DigUpApp/Previews.swift b/Sources/DigUpApp/Previews.swift index 247abeeb..7bf5e572 100644 --- a/Sources/DigUpApp/Previews.swift +++ b/Sources/DigUpApp/Previews.swift @@ -200,6 +200,13 @@ struct PreviewPicture: View { Passage(row: row) .frame(maxWidth: size.width, maxHeight: size.height) .onTapGesture(count: 2, perform: open) + } else if row.isApp { + Image(nsImage: NSWorkspace.shared.icon(forFile: row.iconURL.path)) + .resizable() + .frame(width: 128, height: 128) + .contentShape(Rectangle()) + .onTapGesture(count: 2, perform: open) + .frame(maxWidth: size.width, maxHeight: size.height) } else if row.moment != nil, let player = PreviewPlayback.shared.player, PreviewPlayback.shared.isCurrent(row) { PlayerView(player: player) @@ -239,7 +246,7 @@ struct PreviewPicture: View { // without its controls. .onDisappear { if PreviewPlayback.shared.isCurrent(row) { PreviewPlayback.shared.stop() } } .task(id: Previews.shared.key(row, max(size.width, size.height))) { - guard row.segment != .chunk, row.kind != .code else { return } + guard row.segment != .chunk, row.kind != .code, !row.isApp else { return } let side = max(size.width, size.height) image = Previews.shared.cached(row, side: side) loaded = image != nil diff --git a/Sources/DigUpApp/Results.swift b/Sources/DigUpApp/Results.swift index 55c3c13d..6c205ccc 100644 --- a/Sources/DigUpApp/Results.swift +++ b/Sources/DigUpApp/Results.swift @@ -22,6 +22,8 @@ struct ResultRow: Identifiable, Equatable { let info: [String: String] /// The query's words (`Searcher.terms`): what the preview highlights on a page or in a passage. let terms: [String] + /// An app the panel can launch (`AppLauncher`), not a file from the index: its `kind` means nothing. + let isApp: Bool init(_ hit: SearchHit, terms: [String] = []) { id = hit.path @@ -47,6 +49,7 @@ struct ResultRow: Identifiable, Equatable { size = hit.size info = hit.info self.terms = terms + isApp = false } /// The name to show: left to right as Finder shows it, also when it starts in Arabic ("ملاحظة.md", not "md.ملاحظة"). @@ -115,6 +118,7 @@ struct ResultRow: Identifiable, Equatable { /// "Screenshot · Oct 1, 2026 · 1440 × 900 · 2.1 MB", "PDF · … · 463 pages, 120 read so far · 23 MB", /// "Python · 63 lines · Oct 1, 2026 · 2 KB" var details: String { + if isApp { return info["version"].map { "Application · version \($0)" } ?? "Application" } if kind == .code { var parts = [CodeExtractor.language(of: url.path)] if let cells = info["cells"].flatMap(Int.init) { @@ -140,6 +144,9 @@ struct ResultRow: Identifiable, Equatable { return parts.joined(separator: " · ") } + /// "Screenshot", "PDF", "Application": what it is, for VoiceOver. + var kindTitle: String { isApp ? "Application" : kind.title } + static func clock(_ seconds: Double) -> String { let total = Int(seconds) return total >= 3600 ? String(format: "%d:%02d:%02d", total / 3600, total / 60 % 60, total % 60) @@ -152,6 +159,8 @@ enum ResultGroupKind: CaseIterable { case screenshots, images, documents, video, audio /// Code search's results (the code index; a search of it never shows anything else). case code + /// Apps the query names (`AppLauncher`): the panel's first group, never in the window. + case apps /// The kinds of files the main index holds: the window's kind filters, ⌘2 to ⌘6. static let files: [ResultGroupKind] = [.screenshots, .images, .documents, .video, .audio] @@ -175,6 +184,7 @@ enum ResultGroupKind: CaseIterable { case .video: "Video" case .audio: "Audio" case .code: "Code" + case .apps: "Applications" } } @@ -186,6 +196,7 @@ enum ResultGroupKind: CaseIterable { case .video: FileKind.video.symbol case .audio: FileKind.audio.symbol case .code: FileKind.code.symbol + case .apps: "square.grid.2x2" } } @@ -197,6 +208,7 @@ enum ResultGroupKind: CaseIterable { case .video: [.video] case .audio: [.audio] case .code: [.code] + case .apps: [] } } } @@ -238,6 +250,14 @@ struct ResultSet: Equatable { weaker = hits.dropFirst(strong).map { ResultRow($0, terms: terms) } } + /// The same results with `apps` as the first group, in place of the apps they had. + func withApps(_ apps: [ResultRow]) -> ResultSet { + var set = self + set.groups.removeAll { $0.kind == .apps } + if !apps.isEmpty { set.groups.insert(ResultGroup(kind: .apps, rows: apps, total: apps.count), at: 0) } + return set + } + var strongRows: [ResultRow] { groups.flatMap(\.rows) } var isEmpty: Bool { groups.isEmpty && weaker.isEmpty } var count: Int { strongRows.count + weaker.count } diff --git a/Sources/DigUpApp/SearchPanel.swift b/Sources/DigUpApp/SearchPanel.swift index ce158d3e..d78809a7 100644 --- a/Sources/DigUpApp/SearchPanel.swift +++ b/Sources/DigUpApp/SearchPanel.swift @@ -18,7 +18,8 @@ enum SearchPhase: Equatable { /// /// Every keystroke shows keyword results at once (file names, OCR and document text, the last word as a prefix). /// After a short pause in typing the query is embedded and the full hybrid results replace them. Results far weaker -/// than the best one are folded under "N weaker matches". +/// than the best one are folded under "N weaker matches". Apps the query names (`AppLauncher`) come first, at once, +/// so that ↩ launches one as in Spotlight. @Observable final class SearchPanelController { static let width: CGFloat = 780 @@ -54,6 +55,8 @@ final class SearchPanelController { /// The generation whose final (meaning) results are showing; late keyword results must not replace them. @ObservationIgnored private var finalGeneration = -1 @ObservationIgnored private var meaningTask: Task? + /// The apps the query names: the first group, whatever the index finds. + @ObservationIgnored private var apps: [ResultRow] = [] @ObservationIgnored private var keyMonitor: Any? @ObservationIgnored private var mouseMonitor: Any? @ObservationIgnored private var resignObserver: NSObjectProtocol? @@ -75,6 +78,8 @@ final class SearchPanelController { self.app = app model = app.model engine = app.engine + AppLauncher.shared.onChange = { [weak self] in self?.appsChanged() } + AppLauncher.shared.refresh() } var isVisible: Bool { window?.isVisible == true && !fading } @@ -99,6 +104,7 @@ final class SearchPanelController { let started = Date() shownAt = started engine.prewarm() + AppLauncher.shared.refresh() let window = self.window ?? makeWindow() placeWindow(window) if AppController.offscreen { return } @@ -169,6 +175,7 @@ final class SearchPanelController { isCode = codeText != nil let code = isCode let text = codeText ?? typed + apps = [] guard !typed.isEmpty else { results = .empty selection = nil @@ -184,6 +191,12 @@ final class SearchPanelController { resizeWindow() return } + // Apps by their names, now: ↩ launches the top one even before the index answers. + apps = code ? [] : AppLauncher.shared.matches(typed) + results = results.withApps(apps) + selection = rowIDs.first + if phase == .empty, !apps.isEmpty { phase = .words } + resizeWindow() // Words now. The last word is probably half-typed, so it matches as a prefix. Task { let started = Date() @@ -223,6 +236,7 @@ final class SearchPanelController { private func show(_ hits: [SearchHit], query: String, phase: SearchPhase) { // Code is one group: it gets the room of all of them. results = ResultSet(hits, query: query, rowsPerGroup: isCode ? Self.rowsPerGroup * 3 : Self.rowsPerGroup) + .withApps(apps) if !navigated { showWeaker = false } // Follow the top hit until you pick something with the arrow keys. if !navigated || !rowIDs.contains(where: { $0 == selection }) { @@ -233,6 +247,15 @@ final class SearchPanelController { if QLPreviewPanel.sharedPreviewPanelExists(), QLPreviewPanel.shared().isVisible { updateQuickLook() } } + /// The apps were read again and some came or went: the query's apps again. + private func appsChanged() { + let typed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !typed.isEmpty, !isCode else { return } + apps = AppLauncher.shared.matches(typed) + results = results.withApps(apps) + if !navigated || !rowIDs.contains(where: { $0 == selection }) { selection = rowIDs.first } + } + func toggleWeaker() { showWeaker.toggle() selection = Self.weakerToggle diff --git a/Sources/DigUpApp/SearchPanelView.swift b/Sources/DigUpApp/SearchPanelView.swift index 4d1d0d54..d11bd25e 100644 --- a/Sources/DigUpApp/SearchPanelView.swift +++ b/Sources/DigUpApp/SearchPanelView.swift @@ -238,7 +238,8 @@ private struct PanelRow: View { var body: some View { HStack(spacing: 10) { // Code's thumbnail would be a page of tiny text: its file type's icon says more. - ThumbnailView(url: row.url, side: 36, iconOnly: row.kind == .code) + // So is an app's: its icon. + ThumbnailView(url: row.iconURL, side: 36, iconOnly: row.kind == .code || row.isApp) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(row.title) @@ -268,7 +269,7 @@ private struct PanelRow: View { .background(RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(selected ? Color.accentColor.opacity(0.24) : Color.clear)) .accessibilityElement(children: .combine) - .accessibilityLabel("\(row.kind.title), \(row.name), \(row.reason)") + .accessibilityLabel("\(row.kindTitle), \(row.name), \(row.reason)") .accessibilityAddTraits(selected ? .isSelected : []) } } diff --git a/Sources/DigUpApp/SettingsWindow.swift b/Sources/DigUpApp/SettingsWindow.swift index f56d40fd..efffad22 100644 --- a/Sources/DigUpApp/SettingsWindow.swift +++ b/Sources/DigUpApp/SettingsWindow.swift @@ -201,6 +201,7 @@ private struct SettingsPane: View { private struct GeneralSettings: View { @Bindable var state: SettingsState + @AppStorage(AppLauncher.settingKey, store: AppSettings.defaults) private var searchApps = true var body: some View { Form { @@ -217,6 +218,17 @@ private struct GeneralSettings: View { } } } + Section { + Toggle(isOn: $searchApps) { + VStack(alignment: .leading, spacing: 2) { + Text("Find apps by name") + Text("Apps whose names you type come first in the search panel, and Return opens the top one.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } Section { Toggle(isOn: Binding(get: { state.launchAtLogin }, set: { on in state.loginProblem = state.app.setLaunchAtLogin(on) diff --git a/Sources/DigUpKit/AppSearch.swift b/Sources/DigUpKit/AppSearch.swift new file mode 100644 index 00000000..afd7f992 --- /dev/null +++ b/Sources/DigUpKit/AppSearch.swift @@ -0,0 +1,106 @@ +import Foundation + +/// An app's name, ready to match what's typed against it the way launchers do: the whole name from its start, the start +/// of a word in it ("code" for Visual Studio Code, "term" for iTerm), the starts of several of its words in order +/// ("vis stu"), or its initials ("vsc"). Never the middle of a word: "cat" must not launch Calculator when you're +/// looking for a cat. +public struct AppName: Sendable, Equatable { + public let name: String + let folded: String + let words: [String] + let initials: String + + public init(_ name: String) { + self.name = name + folded = Self.fold(name) + words = Self.words(of: name).map(Self.fold) + initials = String(words.compactMap(\.first)) + } + + /// How well `query` matches (already `AppSearch.normalized`): nil for not at all, higher for better. + func score(_ query: String) -> Int? { + guard !query.isEmpty else { return nil } + if folded == query { return 1000 } + if folded.hasPrefix(query) { return 800 } + let parts = query.split(separator: " ").map(String.init) + if Self.startsWords(parts, of: words) { return 600 } + if parts.count == 1, query.count >= 2, initials.hasPrefix(query) { return 500 } + return nil + } + + /// Each part starts one of `words`, in order: "vis stu" in Visual Studio Code, "code" in it too. + private static func startsWords(_ parts: [String], of words: [String]) -> Bool { + var next = 0 + for part in parts { + guard let found = words[next...].firstIndex(where: { $0.hasPrefix(part) }) else { return false } + next = found + 1 + } + return true + } + + static func fold(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil) + } + + /// "Visual Studio Code" → Visual, Studio, Code; "iTerm" → i, Term; "zoom.us" → zoom, us; "1Password 8" → 1, + /// Password, 8. Words end at anything that isn't a letter or a digit, and inside one where a capital follows a + /// small letter or letters and digits meet. + static func words(of name: String) -> [String] { + var words: [String] = [] + var word = "" + var previous: Character? + for character in name { + guard character.isLetter || character.isNumber else { + if !word.isEmpty { words.append(word) } + word = "" + previous = nil + continue + } + if let previous, !word.isEmpty, + (previous.isLowercase && character.isUppercase) || (previous.isNumber != character.isNumber) { + words.append(word) + word = "" + } + word.append(character) + previous = character + } + if !word.isEmpty { words.append(word) } + return words + } +} + +/// Finds apps by name as you type (`AppName`), best match first. Among matches as good, the ones favored (`boost`: +/// launched most, used lately) come first, then the shorter name ("Notes" before "Notes Helper"). +public enum AppSearch { + /// A name the app also goes by (the bundle's own, "Code" for Visual Studio Code) matches a little less well than + /// the one Finder shows. + static let otherNamePenalty = 50 + + /// The query as names are matched against it: folded like them, single spaces. + static func normalized(_ query: String) -> String { + AppName.fold(query).split(whereSeparator: \.isWhitespace).joined(separator: " ") + } + + /// The best of `apps` for `query`, at most `limit`. `names`: an app's names, the one Finder shows first. + public static func rank(_ query: String, _ apps: [App], names: (App) -> [AppName], + boost: (App) -> Int = { _ in 0 }, limit: Int) -> [App] { + let query = normalized(query) + guard !query.isEmpty, limit > 0 else { return [] } + var scored: [(app: App, score: Int, length: Int, name: String)] = [] + for app in apps { + let all = names(app) + guard let first = all.first else { continue } + let best = all.enumerated().compactMap { index, name in + name.score(query).map { $0 - (index == 0 ? 0 : otherNamePenalty) } + }.max() + guard let best else { continue } + scored.append((app, best + boost(app), first.name.count, first.folded)) + } + scored.sort { + if $0.score != $1.score { return $0.score > $1.score } + if $0.length != $1.length { return $0.length < $1.length } + return $0.name < $1.name + } + return scored.prefix(limit).map(\.app) + } +} diff --git a/Tests/DigUpKitTests/AppSearchTests.swift b/Tests/DigUpKitTests/AppSearchTests.swift new file mode 100644 index 00000000..97db08f4 --- /dev/null +++ b/Tests/DigUpKitTests/AppSearchTests.swift @@ -0,0 +1,74 @@ +import Testing +@testable import DigUpKit + +@Suite struct AppSearchTests { + static let apps = ["Safari", "Calculator", "Calendar", "Visual Studio Code", "iTerm", "Notes", "Notion", "Contacts", + "System Settings", "Café Racer", "1Password 8", "zoom.us", "FaceTime", "Screen Sharing"] + + func find(_ query: String, boost: [String: Int] = [:], also: [String: String] = [:], limit: Int = 4) -> [String] { + AppSearch.rank(query, Self.apps, names: { [AppName($0)] + (also[$0].map { [AppName($0)] } ?? []) }, + boost: { boost[$0] ?? 0 }, limit: limit) + } + + @Test func wordsOfNames() { + #expect(AppName.words(of: "Visual Studio Code") == ["Visual", "Studio", "Code"]) + #expect(AppName.words(of: "iTerm") == ["i", "Term"]) + #expect(AppName.words(of: "zoom.us") == ["zoom", "us"]) + #expect(AppName.words(of: "1Password 8") == ["1", "Password", "8"]) + #expect(AppName.words(of: "OBS") == ["OBS"]) + } + + @Test func startsOfNamesAndWords() { + #expect(find("saf") == ["Safari"]) + #expect(find("code") == ["Visual Studio Code"]) + #expect(find("term") == ["iTerm"]) + #expect(find("vis stu") == ["Visual Studio Code"]) + #expect(find("visual studio") == ["Visual Studio Code"]) + #expect(find("pass") == ["1Password 8"]) + #expect(find("time") == ["FaceTime"]) + #expect(find("SYSTEM set") == ["System Settings"]) + } + + @Test func initials() { + #expect(find("vsc") == ["Visual Studio Code"]) + #expect(find("ss") == ["Screen Sharing", "System Settings"]) + #expect(find("v") == ["Visual Studio Code"]) // one letter is the start of a word, never initials + } + + @Test func neverTheMiddleOfAWord() { + #expect(find("cat").isEmpty) // not Calculator, not Contacts + #expect(find("ari").isEmpty) // not Safari + #expect(find("a dog on the beach").isEmpty) + } + + @Test func accentsAndCaseDontMatter() { + #expect(find("cafe") == ["Café Racer"]) + #expect(find("CALC") == ["Calculator"]) + } + + @Test func betterMatchesFirstThenShorterNames() { + #expect(find("notes") == ["Notes"]) + #expect(find("no") == ["Notes", "Notion"]) // as good a match: the shorter name first + #expect(find("cal") == ["Calendar", "Calculator"]) + #expect(find("c", limit: 2) == ["Calendar", "Contacts"]) + } + + @Test func favoredAppsComeFirst() { + #expect(find("cal", boost: ["Calculator": 60]) == ["Calculator", "Calendar"]) + #expect(find("no", boost: ["Notion": 1]) == ["Notion", "Notes"]) + // The start of a later word, favored enough, beats the start of a name. + #expect(find("co", boost: ["Visual Studio Code": 260]) == ["Visual Studio Code", "Contacts"]) + } + + @Test func otherNamesMatchALittleLessWell() { + let also = ["Visual Studio Code": "Code"] + #expect(find("code", also: also) == ["Visual Studio Code"]) + #expect(find("co", also: also) == ["Contacts", "Visual Studio Code"]) + } + + @Test func nothingTypedFindsNothing() { + #expect(find("").isEmpty) + #expect(find(" ").isEmpty) + #expect(find("saf", limit: 0).isEmpty) + } +} From ceca40a41977fbc9c90027263c15bee10ab68a29 Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Wed, 14 Oct 2026 10:13:34 +0200 Subject: [PATCH 2/2] Show matching apps in the full search window --- Sources/DigUpApp/AppController.swift | 4 ++++ Sources/DigUpApp/Results.swift | 4 +++- Sources/DigUpApp/SearchPanel.swift | 3 +-- Sources/DigUpApp/SearchWindow.swift | 30 +++++++++++++++++++++++---- Sources/DigUpApp/SettingsWindow.swift | 2 +- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/Sources/DigUpApp/AppController.swift b/Sources/DigUpApp/AppController.swift index f061d628..e0aaea86 100644 --- a/Sources/DigUpApp/AppController.swift +++ b/Sources/DigUpApp/AppController.swift @@ -59,6 +59,10 @@ final class AppController { func start(showWindow: Bool = false) { panel = SearchPanelController(app: self) searchWindow = SearchWindowController(app: self) + AppLauncher.shared.onChange = { [weak self] in + self?.panel.appsChanged() + self?.searchWindow.search.appsChanged() + } settingsWindow = SettingsWindowController(app: self) statusMenu = StatusMenuController(model: model) statusMenu.onSearch = { [weak self] in self?.panel.show() } diff --git a/Sources/DigUpApp/Results.swift b/Sources/DigUpApp/Results.swift index 6c205ccc..69dec156 100644 --- a/Sources/DigUpApp/Results.swift +++ b/Sources/DigUpApp/Results.swift @@ -159,7 +159,7 @@ enum ResultGroupKind: CaseIterable { case screenshots, images, documents, video, audio /// Code search's results (the code index; a search of it never shows anything else). case code - /// Apps the query names (`AppLauncher`): the panel's first group, never in the window. + /// Apps the query names (`AppLauncher`): first in the panel and the window's All results. case apps /// The kinds of files the main index holds: the window's kind filters, ⌘2 to ⌘6. @@ -254,6 +254,8 @@ struct ResultSet: Equatable { func withApps(_ apps: [ResultRow]) -> ResultSet { var set = self set.groups.removeAll { $0.kind == .apps } + set.ranked.removeAll { $0.isApp } + set.ranked.insert(contentsOf: apps, at: 0) if !apps.isEmpty { set.groups.insert(ResultGroup(kind: .apps, rows: apps, total: apps.count), at: 0) } return set } diff --git a/Sources/DigUpApp/SearchPanel.swift b/Sources/DigUpApp/SearchPanel.swift index d78809a7..70a976a5 100644 --- a/Sources/DigUpApp/SearchPanel.swift +++ b/Sources/DigUpApp/SearchPanel.swift @@ -78,7 +78,6 @@ final class SearchPanelController { self.app = app model = app.model engine = app.engine - AppLauncher.shared.onChange = { [weak self] in self?.appsChanged() } AppLauncher.shared.refresh() } @@ -248,7 +247,7 @@ final class SearchPanelController { } /// The apps were read again and some came or went: the query's apps again. - private func appsChanged() { + func appsChanged() { let typed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !typed.isEmpty, !isCode else { return } apps = AppLauncher.shared.matches(typed) diff --git a/Sources/DigUpApp/SearchWindow.swift b/Sources/DigUpApp/SearchWindow.swift index 4fc54cc6..c3593018 100644 --- a/Sources/DigUpApp/SearchWindow.swift +++ b/Sources/DigUpApp/SearchWindow.swift @@ -27,6 +27,8 @@ final class SearchWindowController: NSObject, NSWindowDelegate { search.filter = kind search.query = query } + AppLauncher.shared.refresh() + search.appsChanged() app.present(window) if !AppController.offscreen { window.makeFirstResponder(search.field) } installKeyMonitor() @@ -263,6 +265,16 @@ final class SearchModel { navigated = true } + /// Merge local apps without waiting for the file encoder or disturbing a selection made with the arrows. + func appsChanged() { + let typed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let apps = !isCode && !typed.isEmpty ? AppLauncher.shared.matches(typed) : [] + counts[.apps] = apps.count + results = results.withApps(filter == nil ? apps : []) + if !results.isEmpty { hasResults = true } + if !navigated || !rows.contains(where: { $0.id == selection }) { selection = rows.first?.id } + } + private func run() { generation += 1 let current = generation @@ -285,6 +297,11 @@ final class SearchModel { codePhase = engine.hasCode ? .codeHint : .codeOff return } + results = .empty + counts = [:] + hasResults = false + showWeaker = false + appsChanged() engine.prewarm() engine.embedAhead(text, code: code) let filter = self.filter @@ -317,6 +334,7 @@ final class SearchModel { for hit in all.prefix(Searcher.strongCount(all)) { counts[ResultGroupKind(hit.kind), default: 0] += 1 } self.counts = counts results = ResultSet(shown, query: text) + appsChanged() showWeaker = false if !rows.contains(where: { $0.id == selection }) { selection = rows.first?.id } hasResults = true @@ -418,7 +436,7 @@ struct SearchWindowView: View { } @ViewBuilder private var content: some View { - if !model.hasFolders { + if !model.hasFolders && search.results.isEmpty { VStack(spacing: 0) { BrandMark() Text("Choose folders to search").font(.system(size: 20, weight: .bold)).padding(.top, 18) @@ -618,7 +636,7 @@ private struct GridCell: View { } .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay(alignment: .topLeading) { - if row.kind != .image && row.kind != .screenshot { + if !row.isApp && row.kind != .image && row.kind != .screenshot { Image(systemName: row.kind.symbol) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.white) @@ -648,7 +666,7 @@ private struct GridCell: View { } .contentShape(Rectangle()) .accessibilityElement(children: .ignore) - .accessibilityLabel("\(row.kind.title), \(row.name), \(row.reason)") + .accessibilityLabel("\(row.kindTitle), \(row.name), \(row.reason)") .accessibilityAddTraits(selected ? .isSelected : []) } } @@ -767,7 +785,10 @@ private struct MomentThumbnail: View { var body: some View { Group { - if let image { + if row.isApp { + Image(nsImage: NSWorkspace.shared.icon(forFile: row.iconURL.path)) + .resizable().scaledToFit().frame(width: 64, height: 64) + } else if let image { // A page shows its top (its title), anything else its middle. Image(nsImage: image).resizable().aspectRatio(contentMode: .fill) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: row.page == nil ? .center : .top) @@ -778,6 +799,7 @@ private struct MomentThumbnail: View { } } .task(id: row.previewKey) { + guard !row.isApp else { return } let side: CGFloat = 230 image = Previews.shared.cached(row, side: side) if image == nil { image = await Previews.shared.load(row, side: side, scale: scale) } diff --git a/Sources/DigUpApp/SettingsWindow.swift b/Sources/DigUpApp/SettingsWindow.swift index efffad22..21bf9d10 100644 --- a/Sources/DigUpApp/SettingsWindow.swift +++ b/Sources/DigUpApp/SettingsWindow.swift @@ -222,7 +222,7 @@ private struct GeneralSettings: View { Toggle(isOn: $searchApps) { VStack(alignment: .leading, spacing: 2) { Text("Find apps by name") - Text("Apps whose names you type come first in the search panel, and Return opens the top one.") + Text("Apps whose names you type come first in search, and Return opens the top one.") .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true)