Speed up command palette search
This commit is contained in:
parent
b848d60b0d
commit
6f01acfb5f
3 changed files with 499 additions and 63 deletions
|
|
@ -90,6 +90,7 @@
|
|||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
|
||||
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
|
||||
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
|
||||
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
|
||||
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
|
@ -228,6 +229,7 @@
|
|||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
|
||||
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
|
||||
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
|
@ -453,6 +455,7 @@
|
|||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
|
||||
A5008380 /* BrowserFindJavaScriptTests.swift */,
|
||||
A5008382 /* CommandPaletteSearchEngineTests.swift */,
|
||||
);
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -687,6 +690,7 @@
|
|||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
|
||||
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
|
||||
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
265
cmuxTests/CommandPaletteSearchEngineTests.swift
Normal file
265
cmuxTests/CommandPaletteSearchEngineTests.swift
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class CommandPaletteSearchEngineTests: XCTestCase {
|
||||
private struct FixtureEntry {
|
||||
let id: String
|
||||
let rank: Int
|
||||
let title: String
|
||||
let searchableTexts: [String]
|
||||
}
|
||||
|
||||
private struct FixtureResult: Equatable {
|
||||
let id: String
|
||||
let rank: Int
|
||||
let title: String
|
||||
let score: Int
|
||||
let titleMatchIndices: Set<Int>
|
||||
}
|
||||
|
||||
private func makeCommandEntries(count: Int) -> [FixtureEntry] {
|
||||
(0..<count).map { index in
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let keywords: [String]
|
||||
|
||||
switch index % 8 {
|
||||
case 0:
|
||||
title = "Rename Workspace \(index)"
|
||||
subtitle = "Workspace"
|
||||
keywords = ["rename", "workspace", "title", "project", "switch"]
|
||||
case 1:
|
||||
title = "Rename Tab \(index)"
|
||||
subtitle = "Tab"
|
||||
keywords = ["rename", "tab", "surface", "title"]
|
||||
case 2:
|
||||
title = "Open Current Directory in IDE \(index)"
|
||||
subtitle = "Terminal"
|
||||
keywords = ["open", "directory", "cwd", "ide", "vscode"]
|
||||
case 3:
|
||||
title = "Toggle Sidebar \(index)"
|
||||
subtitle = "Layout"
|
||||
keywords = ["toggle", "sidebar", "layout", "panel"]
|
||||
case 4:
|
||||
title = "Apply Update If Available \(index)"
|
||||
subtitle = "Global"
|
||||
keywords = ["apply", "update", "install", "upgrade"]
|
||||
case 5:
|
||||
title = "Restart CLI Listener \(index)"
|
||||
subtitle = "Global"
|
||||
keywords = ["restart", "cli", "listener", "socket", "cmux"]
|
||||
case 6:
|
||||
title = "Show Notifications \(index)"
|
||||
subtitle = "Notifications"
|
||||
keywords = ["notifications", "inbox", "unread", "alerts"]
|
||||
default:
|
||||
title = "Split Browser Right \(index)"
|
||||
subtitle = "Layout"
|
||||
keywords = ["split", "browser", "right", "layout", "web"]
|
||||
}
|
||||
|
||||
return FixtureEntry(
|
||||
id: "command.\(index)",
|
||||
rank: index,
|
||||
title: title,
|
||||
searchableTexts: [title, subtitle] + keywords
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSwitcherEntries(count: Int) -> [FixtureEntry] {
|
||||
(0..<count).map { index in
|
||||
let title = "Workspace \(index) Phoenix"
|
||||
let keywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace", "switch", "go", title],
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feature-\(index)-rename-tab"],
|
||||
branches: ["feature/rename-tab-\(index)"],
|
||||
ports: [3000 + (index % 20), 9200 + (index % 5)]
|
||||
),
|
||||
detail: .workspace
|
||||
)
|
||||
return FixtureEntry(
|
||||
id: "workspace.\(index)",
|
||||
rank: index,
|
||||
title: title,
|
||||
searchableTexts: [title, "Workspace"] + keywords
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func optimizedResults(
|
||||
entries: [FixtureEntry],
|
||||
query: String
|
||||
) -> [FixtureResult] {
|
||||
let corpus = entries.map { entry in
|
||||
CommandPaletteSearchCorpusEntry(
|
||||
payload: entry.id,
|
||||
rank: entry.rank,
|
||||
title: entry.title,
|
||||
searchableTexts: entry.searchableTexts
|
||||
)
|
||||
}
|
||||
|
||||
return CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 }
|
||||
.map {
|
||||
FixtureResult(
|
||||
id: $0.payload,
|
||||
rank: $0.rank,
|
||||
title: $0.title,
|
||||
score: $0.score,
|
||||
titleMatchIndices: $0.titleMatchIndices
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func legacyResults(
|
||||
entries: [FixtureEntry],
|
||||
query: String
|
||||
) -> [FixtureResult] {
|
||||
let queryIsEmpty = query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let results: [FixtureResult] = queryIsEmpty
|
||||
? entries.map { entry in
|
||||
FixtureResult(id: entry.id, rank: entry.rank, title: entry.title, score: 0, titleMatchIndices: [])
|
||||
}
|
||||
: entries.compactMap { entry in
|
||||
guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(
|
||||
query: query,
|
||||
candidates: entry.searchableTexts
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return FixtureResult(
|
||||
id: entry.id,
|
||||
rank: entry.rank,
|
||||
title: entry.title,
|
||||
score: fuzzyScore,
|
||||
titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices(
|
||||
query: query,
|
||||
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 func benchmarkElapsedMs(operation: () -> Void) -> Double {
|
||||
let start = DispatchTime.now().uptimeNanoseconds
|
||||
operation()
|
||||
let elapsed = DispatchTime.now().uptimeNanoseconds - start
|
||||
return Double(elapsed) / 1_000_000
|
||||
}
|
||||
|
||||
private func repeatedQueries(_ baseQueries: [String], repetitions: Int) -> [String] {
|
||||
Array(repeating: baseQueries, count: repetitions).flatMap { $0 }
|
||||
}
|
||||
|
||||
func testOptimizedSearchMatchesLegacyPipeline() {
|
||||
let commandEntries = makeCommandEntries(count: 96)
|
||||
let switcherEntries = makeSwitcherEntries(count: 64)
|
||||
let queries = [
|
||||
"rename",
|
||||
"rename tab",
|
||||
"workspace",
|
||||
"feature-12",
|
||||
"3004",
|
||||
"toggle side",
|
||||
"open dir",
|
||||
"phoenix",
|
||||
"apply update",
|
||||
]
|
||||
|
||||
for query in queries {
|
||||
XCTAssertEqual(
|
||||
optimizedResults(entries: commandEntries, query: query),
|
||||
legacyResults(entries: commandEntries, query: query),
|
||||
"Command corpus mismatch for query \(query)"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
optimizedResults(entries: switcherEntries, query: query),
|
||||
legacyResults(entries: switcherEntries, query: query),
|
||||
"Switcher corpus mismatch for query \(query)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testCommandSearchBenchmarkBeatsLegacyPipeline() {
|
||||
let entries = makeCommandEntries(count: 900)
|
||||
let corpus = entries.map { entry in
|
||||
CommandPaletteSearchCorpusEntry(
|
||||
payload: entry.id,
|
||||
rank: entry.rank,
|
||||
title: entry.title,
|
||||
searchableTexts: entry.searchableTexts
|
||||
)
|
||||
}
|
||||
let queries = repeatedQueries(
|
||||
["rename", "rename tab", "open dir", "toggle side", "apply update", "notif", "split right", "cmux"],
|
||||
repetitions: 12
|
||||
)
|
||||
|
||||
for query in queries.prefix(8) {
|
||||
_ = legacyResults(entries: entries, query: query)
|
||||
_ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 }
|
||||
}
|
||||
|
||||
let legacyMs = benchmarkElapsedMs {
|
||||
for query in queries {
|
||||
_ = legacyResults(entries: entries, query: query)
|
||||
}
|
||||
}
|
||||
let optimizedMs = benchmarkElapsedMs {
|
||||
for query in queries {
|
||||
_ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 }
|
||||
}
|
||||
}
|
||||
|
||||
print(String(format: "BENCH cmd+shift+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs))
|
||||
XCTAssertLessThan(optimizedMs, legacyMs)
|
||||
}
|
||||
|
||||
func testSwitcherSearchBenchmarkBeatsLegacyPipeline() {
|
||||
let entries = makeSwitcherEntries(count: 400)
|
||||
let corpus = entries.map { entry in
|
||||
CommandPaletteSearchCorpusEntry(
|
||||
payload: entry.id,
|
||||
rank: entry.rank,
|
||||
title: entry.title,
|
||||
searchableTexts: entry.searchableTexts
|
||||
)
|
||||
}
|
||||
let queries = repeatedQueries(
|
||||
["workspace 12", "phoenix", "feature-18", "rename-tab", "3007", "9202", "switch", "worktrees"],
|
||||
repetitions: 12
|
||||
)
|
||||
|
||||
for query in queries.prefix(8) {
|
||||
_ = legacyResults(entries: entries, query: query)
|
||||
_ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 }
|
||||
}
|
||||
|
||||
let legacyMs = benchmarkElapsedMs {
|
||||
for query in queries {
|
||||
_ = legacyResults(entries: entries, query: query)
|
||||
}
|
||||
}
|
||||
let optimizedMs = benchmarkElapsedMs {
|
||||
for query in queries {
|
||||
_ = CommandPaletteSearchEngine.search(entries: corpus, query: query) { _, _ in 0 }
|
||||
}
|
||||
}
|
||||
|
||||
print(String(format: "BENCH cmd+p legacy=%.2fms optimized=%.2fms", legacyMs, optimizedMs))
|
||||
XCTAssertLessThan(optimizedMs, legacyMs)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue