Speed up command palette search

This commit is contained in:
Lawrence Chen 2026-03-05 21:18:39 -08:00
parent b848d60b0d
commit 6f01acfb5f
3 changed files with 499 additions and 63 deletions

View file

@ -1261,6 +1261,10 @@ struct ContentView: View {
@State private var commandPaletteScrollTargetIndex: Int?
@State private var commandPaletteScrollTargetAnchor: UnitPoint?
@State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget?
@State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry<CommandPaletteCommand>] = []
@State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = []
@State private var cachedCommandPaletteScope: CommandPaletteListScope?
@State private var cachedCommandPaletteFingerprint: Int?
@State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:]
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
@ -2729,7 +2733,7 @@ struct ContentView: View {
}
private var commandPaletteCommandListView: some View {
let visibleResults = Array(commandPaletteResults)
let visibleResults = cachedCommandPaletteResults
let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
let commandPaletteListMaxHeight: CGFloat = 450
let commandPaletteRowHeight: CGFloat = 24
@ -2868,7 +2872,8 @@ struct ContentView: View {
}
.onAppear {
commandPaletteHoveredResultIndex = nil
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false)
refreshCommandPaletteResults(forceSearchCorpusRefresh: true)
updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false)
resetCommandPaletteSearchFocus()
}
.onChange(of: commandPaletteQuery) { _ in
@ -2876,12 +2881,17 @@ struct ContentView: View {
commandPaletteHoveredResultIndex = nil
commandPaletteScrollTargetIndex = nil
commandPaletteScrollTargetAnchor = nil
refreshCommandPaletteResults()
syncCommandPaletteDebugStateForObservedWindow()
}
.onChange(of: visibleResults.count) { _ in
commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false)
if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count {
.onChange(of: commandPaletteCurrentSearchFingerprint) { _ in
refreshCommandPaletteResults(forceSearchCorpusRefresh: true)
syncCommandPaletteDebugStateForObservedWindow()
}
.onChange(of: cachedCommandPaletteResults.count) { _ in
commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: cachedCommandPaletteResults.count)
updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false)
if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= cachedCommandPaletteResults.count {
commandPaletteHoveredResultIndex = nil
}
syncCommandPaletteDebugStateForObservedWindow()
@ -2999,6 +3009,10 @@ struct ContentView: View {
return .switcher
}
private var commandPaletteCurrentSearchFingerprint: Int {
commandPaletteEntriesFingerprint(for: commandPaletteListScope)
}
private var commandPaletteSearchPlaceholder: String {
switch commandPaletteListScope {
case .commands:
@ -3027,8 +3041,8 @@ struct ContentView: View {
}
}
private var commandPaletteEntries: [CommandPaletteCommand] {
switch commandPaletteListScope {
private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] {
switch scope {
case .commands:
return commandPaletteCommands()
case .switcher:
@ -3036,39 +3050,98 @@ struct ContentView: View {
}
}
private var commandPaletteResults: [CommandPaletteSearchResult] {
let entries = commandPaletteEntries
let query = commandPaletteQueryForMatching
let queryIsEmpty = query.isEmpty
private func refreshCommandPaletteSearchCorpus(force: Bool = false) {
let scope = commandPaletteListScope
let fingerprint = commandPaletteEntriesFingerprint(for: scope)
guard force || cachedCommandPaletteScope != scope || cachedCommandPaletteFingerprint != fingerprint else {
return
}
let results: [CommandPaletteSearchResult] = queryIsEmpty
? entries.map { entry in
CommandPaletteSearchResult(
command: entry,
score: commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: true),
titleMatchIndices: []
)
}
: entries.compactMap { entry in
guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(query: query, candidates: entry.searchableTexts) else {
return nil
}
return CommandPaletteSearchResult(
command: entry,
score: fuzzyScore + commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: false),
titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices(
query: query,
candidate: entry.title
)
)
}
let entries = commandPaletteEntries(for: scope)
commandPaletteSearchCorpus = entries.map { entry in
CommandPaletteSearchCorpusEntry(
payload: entry,
rank: entry.rank,
title: entry.title,
searchableTexts: entry.searchableTexts
)
}
cachedCommandPaletteScope = scope
cachedCommandPaletteFingerprint = fingerprint
}
return results
.sorted { lhs, rhs in
if lhs.score != rhs.score { return lhs.score > rhs.score }
if lhs.command.rank != rhs.command.rank { return lhs.command.rank < rhs.command.rank }
return lhs.command.title.localizedCaseInsensitiveCompare(rhs.command.title) == .orderedAscending
private func refreshCommandPaletteResults(forceSearchCorpusRefresh: Bool = false) {
refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh)
cachedCommandPaletteResults = CommandPaletteSearchEngine.search(
entries: commandPaletteSearchCorpus,
query: commandPaletteQueryForMatching
) { command, queryIsEmpty in
commandPaletteHistoryBoost(for: command.id, queryIsEmpty: queryIsEmpty)
}
.map { result in
CommandPaletteSearchResult(
command: result.payload,
score: result.score,
titleMatchIndices: result.titleMatchIndices
)
}
}
private func commandPaletteEntriesFingerprint(for scope: CommandPaletteListScope) -> Int {
switch scope {
case .commands:
return commandPaletteCommandsFingerprint()
case .switcher:
return commandPaletteSwitcherEntriesFingerprint()
}
}
private func commandPaletteCommandsFingerprint() -> Int {
let panelContext = focusedPanelContext
let focusedDirectory: String? = {
guard let panelContext else { return nil }
if let directory = panelContext.workspace.panelDirectories[panelContext.panelId] {
return directory
}
return panelContext.workspace.currentDirectory
}()
var hasher = Hasher()
hasher.combine(tabManager.sessionAutosaveFingerprint())
hasher.combine(AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false)
hasher.combine(panelContext?.panelId)
hasher.combine(panelContext?.panel.panelType.rawValue)
hasher.combine(panelContext?.workspace.id)
hasher.combine(panelContext?.workspace.manualUnreadPanelIds.count ?? 0)
hasher.combine(panelContext?.workspace.sidebarPullRequestsInDisplayOrder().count ?? 0)
hasher.combine(focusedDirectory)
hasher.combine(caseIsUpdateAvailable(updateViewModel.effectiveState))
let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets
.map(\.rawValue)
.sorted()
for target in availableTargets {
hasher.combine(target)
}
return hasher.finalize()
}
private func commandPaletteSwitcherEntriesFingerprint() -> Int {
let windowContexts = commandPaletteSwitcherWindowContexts()
var hasher = Hasher()
hasher.combine(windowContexts.count)
for context in windowContexts {
hasher.combine(context.windowId)
hasher.combine(context.windowLabel)
hasher.combine(context.selectedWorkspaceId)
hasher.combine(context.tabManager.sessionAutosaveFingerprint())
}
return hasher.finalize()
}
private func caseIsUpdateAvailable(_ state: UpdateState) -> Bool {
if case .updateAvailable = state {
return true
}
return false
}
private func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set<Int>) -> Text {
@ -4426,7 +4499,7 @@ struct ContentView: View {
}
private func moveCommandPaletteSelection(by delta: Int) {
let count = commandPaletteResults.count
let count = cachedCommandPaletteResults.count
guard count > 0 else {
NSSound.beep()
return
@ -4490,7 +4563,7 @@ struct ContentView: View {
}
private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) {
let visibleResults = visibleResults ?? Array(commandPaletteResults)
let visibleResults = visibleResults ?? cachedCommandPaletteResults
guard !visibleResults.isEmpty else {
NSSound.beep()
return
@ -4589,7 +4662,7 @@ struct ContentView: View {
private func syncCommandPaletteDebugStateForObservedWindow() {
guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return }
AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window)
let visibleResultCount = commandPaletteResults.count
let visibleResultCount = cachedCommandPaletteResults.count
let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0
AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window)
AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window)
@ -4608,7 +4681,7 @@ struct ContentView: View {
mode = "rename_confirm"
}
let rows = Array(commandPaletteResults.prefix(20)).map { result in
let rows = Array(cachedCommandPaletteResults.prefix(20)).map { result in
CommandPaletteDebugResultRow(
commandId: result.command.id,
title: result.command.title,
@ -4653,6 +4726,7 @@ struct ContentView: View {
commandPaletteHoveredResultIndex = nil
commandPaletteScrollTargetIndex = nil
commandPaletteScrollTargetAnchor = nil
refreshCommandPaletteResults(forceSearchCorpusRefresh: true)
resetCommandPaletteSearchFocus()
syncCommandPaletteDebugStateForObservedWindow()
}
@ -4670,6 +4744,10 @@ struct ContentView: View {
isCommandPaletteSearchFocused = false
isCommandPaletteRenameFocused = false
commandPaletteRestoreFocusTarget = nil
commandPaletteSearchCorpus = []
cachedCommandPaletteResults = []
cachedCommandPaletteScope = nil
cachedCommandPaletteFingerprint = nil
if let window = observedWindow {
_ = window.makeFirstResponder(nil)
}
@ -5166,23 +5244,49 @@ enum CommandPaletteSwitcherSearchIndexer {
enum CommandPaletteFuzzyMatcher {
private static let tokenBoundaryChars: Set<Character> = [" ", "-", "_", "/", ".", ":"]
struct PreparedQuery {
let normalizedText: String
let tokens: [String]
var isEmpty: Bool {
tokens.isEmpty
}
}
static func preparedQuery(_ query: String) -> PreparedQuery {
let normalizedQuery = normalizeForSearch(query)
return PreparedQuery(
normalizedText: normalizedQuery,
tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty }
)
}
static func normalizeForSearch(_ text: String) -> String {
text
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
}
static func score(query: String, candidate: String) -> Int? {
score(query: query, candidates: [candidate])
}
static func score(query: String, candidates: [String]) -> Int? {
let normalizedQuery = normalize(query)
guard !normalizedQuery.isEmpty else { return 0 }
let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty }
guard !tokens.isEmpty else { return 0 }
score(
preparedQuery: preparedQuery(query),
normalizedCandidates: candidates
.map(normalizeForSearch)
.filter { !$0.isEmpty }
)
}
let normalizedCandidates = candidates
.map(normalize)
.filter { !$0.isEmpty }
static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? {
guard !preparedQuery.isEmpty else { return 0 }
guard !normalizedCandidates.isEmpty else { return nil }
var totalScore = 0
for token in tokens {
for token in preparedQuery.tokens {
var bestTokenScore: Int?
for candidate in normalizedCandidates {
guard let candidateScore = scoreToken(token, in: candidate) else { continue }
@ -5195,19 +5299,19 @@ enum CommandPaletteFuzzyMatcher {
}
static func matchCharacterIndices(query: String, candidate: String) -> Set<Int> {
let normalizedQuery = normalize(query)
guard !normalizedQuery.isEmpty else { return [] }
matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate)
}
let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty }
guard !tokens.isEmpty else { return [] }
static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set<Int> {
guard !preparedQuery.isEmpty else { return [] }
let loweredCandidate = normalize(candidate)
let loweredCandidate = normalizeForSearch(candidate)
guard !loweredCandidate.isEmpty else { return [] }
let candidateChars = Array(loweredCandidate)
var matched: Set<Int> = []
for token in tokens {
for token in preparedQuery.tokens {
if token == loweredCandidate {
matched.formUnion(0..<candidateChars.count)
continue
@ -5244,13 +5348,6 @@ enum CommandPaletteFuzzyMatcher {
return matched
}
private static func normalize(_ text: String) -> String {
text
.trimmingCharacters(in: .whitespacesAndNewlines)
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
}
private static func scoreToken(_ token: String, in candidate: String) -> Int? {
guard !token.isEmpty else { return 0 }
@ -5613,6 +5710,76 @@ enum CommandPaletteFuzzyMatcher {
}
}
struct CommandPaletteSearchCorpusEntry<Payload> {
let payload: Payload
let rank: Int
let title: String
let normalizedSearchableTexts: [String]
init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) {
self.payload = payload
self.rank = rank
self.title = title
self.normalizedSearchableTexts = searchableTexts
.map(CommandPaletteFuzzyMatcher.normalizeForSearch)
.filter { !$0.isEmpty }
}
}
struct CommandPaletteSearchCorpusResult<Payload> {
let payload: Payload
let rank: Int
let title: String
let score: Int
let titleMatchIndices: Set<Int>
}
enum CommandPaletteSearchEngine {
static func search<Payload>(
entries: [CommandPaletteSearchCorpusEntry<Payload>],
query: String,
historyBoost: (Payload, Bool) -> Int
) -> [CommandPaletteSearchCorpusResult<Payload>] {
let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query)
let queryIsEmpty = preparedQuery.isEmpty
let results: [CommandPaletteSearchCorpusResult<Payload>] = queryIsEmpty
? entries.map { entry in
CommandPaletteSearchCorpusResult(
payload: entry.payload,
rank: entry.rank,
title: entry.title,
score: historyBoost(entry.payload, true),
titleMatchIndices: []
)
}
: entries.compactMap { entry in
guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(
preparedQuery: preparedQuery,
normalizedCandidates: entry.normalizedSearchableTexts
) else {
return nil
}
return CommandPaletteSearchCorpusResult(
payload: entry.payload,
rank: entry.rank,
title: entry.title,
score: fuzzyScore + historyBoost(entry.payload, false),
titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices(
preparedQuery: preparedQuery,
candidate: entry.title
)
)
}
return results.sorted { lhs, rhs in
if lhs.score != rhs.score { return lhs.score > rhs.score }
if lhs.rank != rhs.rank { return lhs.rank < rhs.rank }
return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending
}
}
}
private struct SidebarResizerAccessibilityModifier: ViewModifier {
let accessibilityIdentifier: String?