Skip to content
This repository was archived by the owner on Oct 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ All notable changes to this project are documented here. The format follows [Kee
- Moving the pointer over a result selects it, and apps, files, and folders can be dragged out of the panel.
- A macOS 26 look: a glass capsule for the field and a glass card for results, grouped into Top Hit, Applications, System Settings, Files, and Search the Web. A short fade on opening, none with Reduce Motion.
- "No Results" when nothing matches and web search is off.
- Scopes, as in Spotlight on macOS 26: ⌘1 Apps, ⌘2 Files, ⌘3 Actions, also as buttons in the field. With nothing typed, a scope lists every app and pane, recent files, or all actions.
- Actions: run shortcuts from the Shortcuts app, and quit or hide running apps.

### Changed

- ⌘1–⌘9 choose a scope instead of opening the nth result, to match Spotlight.

- Rows say less: no "Application" label, a pane's name instead of "System Settings ›", and file paths keep their start and end when shortened.
- System Settings topics match only where each typed word starts a word of the topic, and keywords only from the fourth letter, so a short query finds apps instead of a dozen loosely related settings.

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ Spotlight owns ⌘Space. To give it to Common Command, turn off **Show Spotlight
| ↑ ↓, ⌃P ⌃N, Tab | Move the selection |
| ↩ | Open the selected result, or copy a calculation |
| ⌘↩ | Show the selected file or app in Finder |
| ⌘1 … ⌘9 | Open a result directly; hold ⌘ to see the numbers |
| ⎋ | Close |
| ⌘1 ⌘2 ⌘3 | Search only Apps, Files, or Actions; press again for everything |
| ⌫ in an empty field | Leave the scope |
| ⎋ | Leave the scope, or close |

The pointer works too: click a result to open it, move over one to select it, or drag an app, file, or folder out into the Dock, Finder, or a document. Rows that shift under a still pointer while you type never take the selection.

Expand All @@ -52,6 +53,11 @@ What a query finds, in order:
3. **Apps, System Settings, and Common Command's own commands** (Settings, Quit). Settings covers each pane and the topics inside it, found by the same keywords System Settings' own search uses: "dark mode" finds Appearance, "low power" finds Low Power Mode, "firewall" finds the firewall switch. Panes show their own icons. Laptop-only settings appear only on laptops.
4. **Files and folders** in your home folder whose name has a word starting with each word you typed. Build output, caches, and dot-folders are left out, and files you've opened recently rank higher.
5. **A web search** with your chosen engine: Google, DuckDuckGo, Bing, Kagi, Ecosia, or Startpage.
6. **Actions**: your shortcuts from the Shortcuts app, and Quit or Hide for each running app ("quit saf"). They rank below apps, so a name you type still means the app.

### Scopes

As in Spotlight on macOS 26, ⌘1, ⌘2, and ⌘3 (or the buttons at the end of the field) narrow the search to Apps, Files, or Actions. With nothing typed, a scope lists what it holds: every app and System Settings pane from A to Z, the files you opened in the last month, or your shortcuts and running apps. A scope searches its kind even if Settings leaves it out of everything else.

Each of these except apps can be turned off in Settings. The last query comes back selected when you reopen the panel, so typing replaces it and Return repeats it.

Expand Down
6 changes: 6 additions & 0 deletions Sources/CommandKit/Catalogs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ public enum FileResults {
/// anyone is looking for.
static let clutter = ["/Library/", "/.", ".app/", "/node_modules/", "/target/", "/DerivedData/", "/__pycache__/"]

/// Recently opened files, most recent first, for the Files scope with nothing typed.
public static func recent(_ found: [Found], home: String, now: Date) -> [Candidate] {
let ordered = found.filter { $0.lastUsed != nil }.sorted { $0.lastUsed! > $1.lastUsed! }
return candidates(ordered, home: home, now: now)
}

public static func candidates(_ found: [Found], home: String, now: Date) -> [Candidate] {
// Spotlight can list one file twice, for instance while it re-indexes it.
var seen: Set<String> = []
Expand Down
7 changes: 6 additions & 1 deletion Sources/CommandKit/Item.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

public enum Kind: String, Sendable, Hashable {
case application, settings, command, file, folder, calculation, link, webSearch
case application, settings, command, action, file, folder, calculation, link, webSearch
}

/// What choosing a result does.
Expand All @@ -11,6 +11,11 @@ public enum Action: Sendable, Hashable {
case copy(String)
/// Handled by the app shell: one of its own commands, such as opening Settings.
case command(String)
/// Runs one of the user's shortcuts from the Shortcuts app, by name.
case shortcut(String)
/// Quits or hides a running app, by bundle identifier.
case quit(String)
case hide(String)
}

/// One row in the results.
Expand Down
53 changes: 52 additions & 1 deletion Sources/CommandKit/Results.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ public struct SearchOptions: Sendable, Equatable {
public var limit = 8
public var fileLimit = 5
public var locale = Locale.current
public var scope = Scope.all
/// How many results a scope shows: more than everything does, since it's one kind of thing.
public var scopedLimit = 40

public init() {}
}
Expand All @@ -34,7 +37,19 @@ public enum Results {
options: SearchOptions
) -> [ResultSection] {
let text = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return [] }
let scope = options.scope
let candidates = candidates.filter { scope.includes($0.item.kind) }
let files = scope.includes(.file) ? files : []
guard !text.isEmpty else {
return scope == .all ? [] : browse(scope, candidates: candidates, files: files)
}
guard scope == .all else {
let limit = options.scopedLimit
return group(
merge(
Ranker.rank(candidates, query: text, usage: usage, now: now, limit: limit),
Ranker.rank(files, query: text, usage: usage, now: now, limit: limit)))
}
var sections: [ResultSection] = []

if let url = Web.address(text) {
Expand Down Expand Up @@ -71,6 +86,41 @@ public enum Results {
return sections
}

/// A scope with nothing typed lists what it holds, as Spotlight's Apps view does: apps and panes from A
/// to Z, files in the order given (most recently used first), actions from A to Z.
static func browse(_ scope: Scope, candidates: [Candidate], files: [Candidate]) -> [ResultSection] {
func sorted(_ kind: Kind) -> [Ranked] {
// Settings topics are left out: hundreds of them would bury the panes.
candidates.filter { $0.item.kind == kind && !($0.item.kind == .settings && $0.matchesWordStarts) }
.sorted { $0.item.title.localizedStandardCompare($1.item.title) == .orderedAscending }
.map { Ranked(item: $0.item, score: 0) }
}
let sections: [ResultSection]
switch scope {
case .all:
sections = []
case .apps:
sections = [
ResultSection(title: "Applications", rows: sorted(.application)),
ResultSection(title: "System Settings", rows: sorted(.settings)),
]
case .files:
sections = [ResultSection(title: "Recent Files", rows: files.map { Ranked(item: $0.item, score: 0) })]
case .actions:
// Shortcuts from A to Z; running apps in the order given, which keeps each app's Quit and Hide together.
let shortcuts = sorted(.action).filter { if case .shortcut = $0.item.action { true } else { false } }
let running = candidates.filter {
if case .shortcut = $0.item.action { return false }
return $0.item.kind == .action
}
sections = [
ResultSection(title: "Shortcuts", rows: shortcuts),
ResultSection(title: "Running Apps", rows: running.map { Ranked(item: $0.item, score: 0) }),
]
}
return sections.filter { !$0.rows.isEmpty }
}

/// Two best-first lists as one, best first; on a tie the first list's entry leads.
static func merge(_ a: [Ranked], _ b: [Ranked]) -> [Ranked] {
var merged: [Ranked] = []
Expand Down Expand Up @@ -108,6 +158,7 @@ public enum Results {
case .application: "Applications"
case .settings: "System Settings"
case .command: "Common Command"
case .action: "Actions"
case .file, .folder: "Files"
case .calculation: "Calculator"
case .link: "Website"
Expand Down
101 changes: 101 additions & 0 deletions Sources/CommandKit/Scope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Foundation

/// What the search covers, as macOS 26 Spotlight splits it: everything, or only apps, files, or actions.
public enum Scope: String, CaseIterable, Sendable, Identifiable {
case all, apps, files, actions

public var id: String { rawValue }

public var name: String {
switch self {
case .all: "Everything"
case .apps: "Apps"
case .files: "Files"
case .actions: "Actions"
}
}

/// The SF Symbol on the scope's button in the field.
public var symbol: String {
switch self {
case .all: "magnifyingglass"
case .apps: "square.grid.2x2"
case .files: "folder"
case .actions: "bolt"
}
}

/// ⌘1, ⌘2, ⌘3, as in Spotlight.
public var key: Character? {
switch self {
case .all: nil
case .apps: "1"
case .files: "2"
case .actions: "3"
}
}

public static let scoped: [Scope] = [.apps, .files, .actions]

func includes(_ kind: Kind) -> Bool {
switch self {
case .all: true
case .apps: kind == .application || kind == .settings || kind == .command
case .files: kind == .file || kind == .folder
case .actions: kind == .action
}
}
}

/// Things to do rather than open: the user's shortcuts from the Shortcuts app, and quitting or hiding
/// running apps. They rank below apps with an equally good name, so "saf" still means Safari.
public enum Actions {
static let weight = -6

/// The names `shortcuts list` prints, one per line.
public static func shortcutNames(fromList output: String) -> [String] {
output.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}

public static func shortcuts(_ names: [String], icon: URL?) -> [Candidate] {
var seen: Set<String> = []
return names.filter { seen.insert($0).inserted }.map { name in
Candidate(
Item(
id: "shortcut:" + name, title: name, subtitle: "Shortcut", kind: .action,
action: .shortcut(name), iconFile: icon),
weight: weight)
}
}

public struct RunningApp: Sendable {
public var name: String
public var bundleIdentifier: String
public var url: URL

public init(name: String, bundleIdentifier: String, url: URL) {
self.name = name
self.bundleIdentifier = bundleIdentifier
self.url = url
}
}

/// "Quit Safari" and "Hide Safari" for each running app with a Dock icon, by app name. They match by
/// word starts only, so "quit sa" means Quit Safari and never the "sa" inside Quit Messages.
public static func appActions(_ apps: [RunningApp]) -> [Candidate] {
apps.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.flatMap { app in
[
Candidate(
Item(
id: "quit:" + app.bundleIdentifier, title: "Quit \(app.name)", subtitle: "Running",
kind: .action, action: .quit(app.bundleIdentifier), iconFile: app.url),
weight: weight, matchesWordStarts: true),
Candidate(
Item(
id: "hide:" + app.bundleIdentifier, title: "Hide \(app.name)", subtitle: "Running",
kind: .action, action: .hide(app.bundleIdentifier), iconFile: app.url),
weight: weight, matchesWordStarts: true),
]
}
}
}
37 changes: 37 additions & 0 deletions Sources/CommonCommand/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,43 @@ final class AppController: NSObject {
NSApp.terminate(nil)
case .command:
break
case .shortcut(let name):
runShortcut(name)
case .quit(let id):
NSRunningApplication.runningApplications(withBundleIdentifier: id).forEach { $0.terminate() }
case .hide(let id):
NSRunningApplication.runningApplications(withBundleIdentifier: id).forEach { $0.hide() }
}
}

/// Runs a shortcut through the `shortcuts` tool, off the main thread: some take a while, and the panel
/// is already closed. A failure is reported, since nothing else would show it.
private func runShortcut(_ name: String) {
Task.detached(priority: .userInitiated) {
let process = Process()
process.executableURL = URL(filePath: "/usr/bin/shortcuts")
process.arguments = ["run", name]
let errors = Pipe()
process.standardError = errors
do {
try process.run()
process.waitUntilExit()
} catch {
await MainActor.run { Self.present(error) }
return
}
guard process.terminationStatus != 0 else { return }
let message = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
await MainActor.run {
Self.present(
CocoaError(
.featureUnsupported,
userInfo: [
NSLocalizedDescriptionKey: "The shortcut “\(name)” didn't finish.",
NSLocalizedRecoverySuggestionErrorKey: message.trimmingCharacters(
in: .whitespacesAndNewlines),
]))
}
}
}

Expand Down
Loading
Loading