Decouple command palette search from typing
This commit is contained in:
parent
6f01acfb5f
commit
8a05c7d1da
2 changed files with 236 additions and 48 deletions
|
|
@ -1261,10 +1261,17 @@ 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 commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry<String>] = []
|
||||
@State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:]
|
||||
@State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = []
|
||||
@State private var cachedCommandPaletteScope: CommandPaletteListScope?
|
||||
@State private var cachedCommandPaletteFingerprint: Int?
|
||||
@State private var commandPaletteSearchTask: Task<Void, Never>?
|
||||
@State private var commandPaletteSearchRequestID: UInt64 = 0
|
||||
@State private var commandPaletteResolvedSearchRequestID: UInt64 = 0
|
||||
@State private var isCommandPaletteSearchPending = false
|
||||
@State private var commandPaletteDeferredSubmitRequestID: UInt64?
|
||||
@State private var commandPaletteResultsRevision: UInt64 = 0
|
||||
@State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:]
|
||||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
|
|
@ -1377,7 +1384,7 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct CommandPaletteUsageEntry: Codable {
|
||||
private struct CommandPaletteUsageEntry: Codable, Sendable {
|
||||
var useCount: Int
|
||||
var lastUsedAt: TimeInterval
|
||||
}
|
||||
|
|
@ -2734,6 +2741,7 @@ struct ContentView: View {
|
|||
|
||||
private var commandPaletteCommandListView: some View {
|
||||
let visibleResults = cachedCommandPaletteResults
|
||||
let isSearchPending = isCommandPaletteSearchPending
|
||||
let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
|
||||
let commandPaletteListMaxHeight: CGFloat = 450
|
||||
let commandPaletteRowHeight: CGFloat = 24
|
||||
|
|
@ -2750,7 +2758,7 @@ struct ContentView: View {
|
|||
.tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0)))
|
||||
.focused($isCommandPaletteSearchFocused)
|
||||
.onSubmit {
|
||||
runSelectedCommandPaletteResult(visibleResults: visibleResults)
|
||||
runSelectedCommandPaletteResult()
|
||||
}
|
||||
.backport.onKeyPress(.downArrow) { _ in
|
||||
moveCommandPaletteSelection(by: 1)
|
||||
|
|
@ -2773,6 +2781,10 @@ struct ContentView: View {
|
|||
handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1)
|
||||
}
|
||||
|
||||
if isSearchPending {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 7)
|
||||
|
|
@ -2782,12 +2794,22 @@ struct ContentView: View {
|
|||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if visibleResults.isEmpty {
|
||||
Text(commandPaletteEmptyStateText)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
if isSearchPending {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
} else {
|
||||
Text(commandPaletteEmptyStateText)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
} else {
|
||||
ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in
|
||||
let isSelected = index == selectedIndex
|
||||
|
|
@ -2797,7 +2819,7 @@ struct ContentView: View {
|
|||
: (isHovered ? Color.primary.opacity(0.08) : .clear)
|
||||
|
||||
Button {
|
||||
runCommandPaletteCommand(result.command)
|
||||
runCommandPaletteResult(commandID: result.id)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
commandPaletteHighlightedTitleText(
|
||||
|
|
@ -2832,6 +2854,7 @@ struct ContentView: View {
|
|||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSearchPending)
|
||||
.id(index)
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
|
|
@ -2872,7 +2895,6 @@ struct ContentView: View {
|
|||
}
|
||||
.onAppear {
|
||||
commandPaletteHoveredResultIndex = nil
|
||||
refreshCommandPaletteResults(forceSearchCorpusRefresh: true)
|
||||
updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false)
|
||||
resetCommandPaletteSearchFocus()
|
||||
}
|
||||
|
|
@ -2881,14 +2903,14 @@ struct ContentView: View {
|
|||
commandPaletteHoveredResultIndex = nil
|
||||
commandPaletteScrollTargetIndex = nil
|
||||
commandPaletteScrollTargetAnchor = nil
|
||||
refreshCommandPaletteResults()
|
||||
scheduleCommandPaletteResultsRefresh()
|
||||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
}
|
||||
.onChange(of: commandPaletteCurrentSearchFingerprint) { _ in
|
||||
refreshCommandPaletteResults(forceSearchCorpusRefresh: true)
|
||||
scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true)
|
||||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
}
|
||||
.onChange(of: cachedCommandPaletteResults.count) { _ in
|
||||
.onChange(of: commandPaletteResultsRevision) { _ in
|
||||
commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: cachedCommandPaletteResults.count)
|
||||
updateCommandPaletteScrollTarget(resultCount: cachedCommandPaletteResults.count, animated: false)
|
||||
if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= cachedCommandPaletteResults.count {
|
||||
|
|
@ -3058,9 +3080,10 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
let entries = commandPaletteEntries(for: scope)
|
||||
commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) })
|
||||
commandPaletteSearchCorpus = entries.map { entry in
|
||||
CommandPaletteSearchCorpusEntry(
|
||||
payload: entry,
|
||||
payload: entry.id,
|
||||
rank: entry.rank,
|
||||
title: entry.title,
|
||||
searchableTexts: entry.searchableTexts
|
||||
|
|
@ -3070,20 +3093,75 @@ struct ContentView: View {
|
|||
cachedCommandPaletteFingerprint = fingerprint
|
||||
}
|
||||
|
||||
private func refreshCommandPaletteResults(forceSearchCorpusRefresh: Bool = false) {
|
||||
private func cancelCommandPaletteSearch() {
|
||||
commandPaletteSearchTask?.cancel()
|
||||
commandPaletteSearchTask = nil
|
||||
}
|
||||
|
||||
private func scheduleCommandPaletteResultsRefresh(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
|
||||
|
||||
commandPaletteSearchRequestID &+= 1
|
||||
let requestID = commandPaletteSearchRequestID
|
||||
commandPaletteDeferredSubmitRequestID = nil
|
||||
isCommandPaletteSearchPending = true
|
||||
let query = commandPaletteQueryForMatching
|
||||
let scope = commandPaletteListScope
|
||||
let fingerprint = cachedCommandPaletteFingerprint
|
||||
let searchCorpus = commandPaletteSearchCorpus
|
||||
let usageHistory = commandPaletteUsageHistoryByCommandId
|
||||
let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty
|
||||
let historyTimestamp = Date().timeIntervalSince1970
|
||||
|
||||
cancelCommandPaletteSearch()
|
||||
commandPaletteSearchTask = Task.detached(priority: .userInitiated) {
|
||||
let results = CommandPaletteSearchEngine.search(
|
||||
entries: searchCorpus,
|
||||
query: query,
|
||||
historyBoost: { commandId, _ in
|
||||
Self.commandPaletteHistoryBoost(
|
||||
for: commandId,
|
||||
queryIsEmpty: queryIsEmpty,
|
||||
history: usageHistory,
|
||||
now: historyTimestamp
|
||||
)
|
||||
},
|
||||
shouldCancel: { Task.isCancelled }
|
||||
)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
guard commandPaletteSearchRequestID == requestID,
|
||||
isCommandPalettePresented,
|
||||
commandPaletteListScope == scope,
|
||||
commandPaletteQueryForMatching == query,
|
||||
cachedCommandPaletteFingerprint == fingerprint else {
|
||||
return
|
||||
}
|
||||
|
||||
cachedCommandPaletteResults = results.compactMap { result in
|
||||
guard let command = commandPaletteSearchCommandsByID[result.payload] else { return nil }
|
||||
return CommandPaletteSearchResult(
|
||||
command: command,
|
||||
score: result.score,
|
||||
titleMatchIndices: result.titleMatchIndices
|
||||
)
|
||||
}
|
||||
commandPaletteResolvedSearchRequestID = requestID
|
||||
isCommandPaletteSearchPending = false
|
||||
let shouldRunDeferredSubmit = commandPaletteDeferredSubmitRequestID == requestID
|
||||
if shouldRunDeferredSubmit {
|
||||
commandPaletteDeferredSubmitRequestID = nil
|
||||
}
|
||||
commandPaletteResultsRevision &+= 1
|
||||
if commandPaletteSearchRequestID == requestID {
|
||||
commandPaletteSearchTask = nil
|
||||
}
|
||||
if shouldRunDeferredSubmit {
|
||||
runSelectedCommandPaletteResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4562,8 +4640,27 @@ struct ContentView: View {
|
|||
return .handled
|
||||
}
|
||||
|
||||
private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) {
|
||||
let visibleResults = visibleResults ?? cachedCommandPaletteResults
|
||||
private var commandPaletteHasCurrentResolvedResults: Bool {
|
||||
!isCommandPaletteSearchPending && commandPaletteResolvedSearchRequestID == commandPaletteSearchRequestID
|
||||
}
|
||||
|
||||
private func runCommandPaletteResult(commandID: String) {
|
||||
guard commandPaletteHasCurrentResolvedResults,
|
||||
let command = cachedCommandPaletteResults.first(where: { $0.id == commandID })?.command else {
|
||||
return
|
||||
}
|
||||
runCommandPaletteCommand(command)
|
||||
}
|
||||
|
||||
private func runSelectedCommandPaletteResult() {
|
||||
guard commandPaletteHasCurrentResolvedResults else {
|
||||
if isCommandPalettePresented {
|
||||
commandPaletteDeferredSubmitRequestID = commandPaletteSearchRequestID
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let visibleResults = cachedCommandPaletteResults
|
||||
guard !visibleResults.isEmpty else {
|
||||
NSSound.beep()
|
||||
return
|
||||
|
|
@ -4726,13 +4823,15 @@ struct ContentView: View {
|
|||
commandPaletteHoveredResultIndex = nil
|
||||
commandPaletteScrollTargetIndex = nil
|
||||
commandPaletteScrollTargetAnchor = nil
|
||||
refreshCommandPaletteResults(forceSearchCorpusRefresh: true)
|
||||
scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true)
|
||||
resetCommandPaletteSearchFocus()
|
||||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
}
|
||||
|
||||
private func dismissCommandPalette(restoreFocus: Bool = true) {
|
||||
let focusTarget = commandPaletteRestoreFocusTarget
|
||||
cancelCommandPaletteSearch()
|
||||
commandPaletteSearchRequestID &+= 1
|
||||
isCommandPalettePresented = false
|
||||
commandPaletteMode = .commands
|
||||
commandPaletteQuery = ""
|
||||
|
|
@ -4745,9 +4844,14 @@ struct ContentView: View {
|
|||
isCommandPaletteRenameFocused = false
|
||||
commandPaletteRestoreFocusTarget = nil
|
||||
commandPaletteSearchCorpus = []
|
||||
commandPaletteSearchCommandsByID = [:]
|
||||
cachedCommandPaletteResults = []
|
||||
cachedCommandPaletteScope = nil
|
||||
cachedCommandPaletteFingerprint = nil
|
||||
commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID
|
||||
isCommandPaletteSearchPending = false
|
||||
commandPaletteDeferredSubmitRequestID = nil
|
||||
commandPaletteResultsRevision &+= 1
|
||||
if let window = observedWindow {
|
||||
_ = window.makeFirstResponder(nil)
|
||||
}
|
||||
|
|
@ -4910,10 +5014,14 @@ struct ContentView: View {
|
|||
persistCommandPaletteUsageHistory(history)
|
||||
}
|
||||
|
||||
private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int {
|
||||
guard let entry = commandPaletteUsageHistoryByCommandId[commandId] else { return 0 }
|
||||
nonisolated private static func commandPaletteHistoryBoost(
|
||||
for commandId: String,
|
||||
queryIsEmpty: Bool,
|
||||
history: [String: CommandPaletteUsageEntry],
|
||||
now: TimeInterval
|
||||
) -> Int {
|
||||
guard let entry = history[commandId] else { return 0 }
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
let ageDays = max(0, now - entry.lastUsedAt) / 86_400
|
||||
let recencyBoost = max(0, 320 - Int(ageDays * 20))
|
||||
let countBoost = min(180, entry.useCount * 12)
|
||||
|
|
@ -4922,6 +5030,15 @@ struct ContentView: View {
|
|||
return queryIsEmpty ? totalBoost : max(0, totalBoost / 3)
|
||||
}
|
||||
|
||||
private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int {
|
||||
Self.commandPaletteHistoryBoost(
|
||||
for: commandId,
|
||||
queryIsEmpty: queryIsEmpty,
|
||||
history: commandPaletteUsageHistoryByCommandId,
|
||||
now: Date().timeIntervalSince1970
|
||||
)
|
||||
}
|
||||
|
||||
private func beginRenameWorkspaceFlow() {
|
||||
guard let workspace = tabManager.selectedWorkspace else {
|
||||
NSSound.beep()
|
||||
|
|
@ -5710,7 +5827,7 @@ enum CommandPaletteFuzzyMatcher {
|
|||
}
|
||||
}
|
||||
|
||||
struct CommandPaletteSearchCorpusEntry<Payload> {
|
||||
struct CommandPaletteSearchCorpusEntry<Payload>: Sendable where Payload: Sendable {
|
||||
let payload: Payload
|
||||
let rank: Int
|
||||
let title: String
|
||||
|
|
@ -5726,7 +5843,7 @@ struct CommandPaletteSearchCorpusEntry<Payload> {
|
|||
}
|
||||
}
|
||||
|
||||
struct CommandPaletteSearchCorpusResult<Payload> {
|
||||
struct CommandPaletteSearchCorpusResult<Payload>: Sendable where Payload: Sendable {
|
||||
let payload: Payload
|
||||
let rank: Int
|
||||
let title: String
|
||||
|
|
@ -5735,42 +5852,87 @@ struct CommandPaletteSearchCorpusResult<Payload> {
|
|||
}
|
||||
|
||||
enum CommandPaletteSearchEngine {
|
||||
static func search<Payload>(
|
||||
static func search<Payload: Sendable>(
|
||||
entries: [CommandPaletteSearchCorpusEntry<Payload>],
|
||||
query: String,
|
||||
historyBoost: (Payload, Bool) -> Int
|
||||
) -> [CommandPaletteSearchCorpusResult<Payload>] {
|
||||
search(
|
||||
entries: entries,
|
||||
query: query,
|
||||
historyBoost: historyBoost,
|
||||
shouldCancel: nil
|
||||
)
|
||||
}
|
||||
|
||||
static func search<Payload: Sendable>(
|
||||
entries: [CommandPaletteSearchCorpusEntry<Payload>],
|
||||
query: String,
|
||||
historyBoost: (Payload, Bool) -> Int,
|
||||
shouldCancel: @escaping () -> Bool
|
||||
) -> [CommandPaletteSearchCorpusResult<Payload>] {
|
||||
search(
|
||||
entries: entries,
|
||||
query: query,
|
||||
historyBoost: historyBoost,
|
||||
shouldCancel: Optional(shouldCancel)
|
||||
)
|
||||
}
|
||||
|
||||
private static func search<Payload: Sendable>(
|
||||
entries: [CommandPaletteSearchCorpusEntry<Payload>],
|
||||
query: String,
|
||||
historyBoost: (Payload, Bool) -> Int,
|
||||
shouldCancel: (() -> Bool)?
|
||||
) -> [CommandPaletteSearchCorpusResult<Payload>] {
|
||||
let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query)
|
||||
let queryIsEmpty = preparedQuery.isEmpty
|
||||
var results: [CommandPaletteSearchCorpusResult<Payload>] = []
|
||||
results.reserveCapacity(entries.count)
|
||||
|
||||
let results: [CommandPaletteSearchCorpusResult<Payload>] = queryIsEmpty
|
||||
? entries.map { entry in
|
||||
CommandPaletteSearchCorpusResult(
|
||||
func shouldCancelSearch(at index: Int) -> Bool {
|
||||
guard let shouldCancel else { return false }
|
||||
return index % 16 == 0 && shouldCancel()
|
||||
}
|
||||
|
||||
if queryIsEmpty {
|
||||
for (index, entry) in entries.enumerated() {
|
||||
if shouldCancelSearch(at: index) { return [] }
|
||||
results.append(
|
||||
CommandPaletteSearchCorpusResult(
|
||||
payload: entry.payload,
|
||||
rank: entry.rank,
|
||||
title: entry.title,
|
||||
score: historyBoost(entry.payload, true),
|
||||
titleMatchIndices: []
|
||||
)
|
||||
)
|
||||
}
|
||||
: entries.compactMap { entry in
|
||||
} else {
|
||||
for (index, entry) in entries.enumerated() {
|
||||
if shouldCancelSearch(at: index) { return [] }
|
||||
guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(
|
||||
preparedQuery: preparedQuery,
|
||||
normalizedCandidates: entry.normalizedSearchableTexts
|
||||
) else {
|
||||
return nil
|
||||
continue
|
||||
}
|
||||
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
|
||||
results.append(
|
||||
CommandPaletteSearchCorpusResult(
|
||||
payload: entry.payload,
|
||||
rank: entry.rank,
|
||||
title: entry.title,
|
||||
score: fuzzyScore + historyBoost(entry.payload, false),
|
||||
titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices(
|
||||
preparedQuery: preparedQuery,
|
||||
candidate: entry.title
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if shouldCancel?() == true { return [] }
|
||||
|
||||
return results.sorted { lhs, rhs in
|
||||
if lhs.score != rhs.score { return lhs.score > rhs.score }
|
||||
|
|
|
|||
|
|
@ -193,6 +193,32 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
func testSearchCancellationReturnsNoResults() {
|
||||
let entries = makeCommandEntries(count: 512)
|
||||
let corpus = entries.map { entry in
|
||||
CommandPaletteSearchCorpusEntry(
|
||||
payload: entry.id,
|
||||
rank: entry.rank,
|
||||
title: entry.title,
|
||||
searchableTexts: entry.searchableTexts
|
||||
)
|
||||
}
|
||||
var cancellationChecks = 0
|
||||
|
||||
let results = CommandPaletteSearchEngine.search(
|
||||
entries: corpus,
|
||||
query: "rename"
|
||||
) { _, _ in
|
||||
0
|
||||
} shouldCancel: {
|
||||
cancellationChecks += 1
|
||||
return cancellationChecks >= 4
|
||||
}
|
||||
|
||||
XCTAssertTrue(results.isEmpty)
|
||||
XCTAssertGreaterThanOrEqual(cancellationChecks, 4)
|
||||
}
|
||||
|
||||
func testCommandSearchBenchmarkBeatsLegacyPipeline() {
|
||||
let entries = makeCommandEntries(count: 900)
|
||||
let corpus = entries.map { entry in
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue