Merge pull request #1382 from manaflow-ai/task-cmd-p-search-all-surfaces
Add Cmd+P all-surface search option
This commit is contained in:
commit
f90bcbc862
5 changed files with 1122 additions and 40 deletions
|
|
@ -19932,6 +19932,57 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"commandPalette.kind.browser": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Browser"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ブラウザ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"commandPalette.kind.markdown": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Markdown"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Markdown"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"commandPalette.kind.terminal": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Terminal"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ターミナル"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"commandPalette.kind.workspace": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -21627,6 +21678,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"commandPalette.search.switcherEmptyAllSurfaces": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "No workspaces or surfaces match your search."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "検索に一致するワークスペースまたはサーフェスはありません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"commandPalette.search.switcherPlaceholder": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -21740,6 +21808,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"commandPalette.search.switcherPlaceholderAllSurfaces": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Search workspaces and surfaces"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ワークスペースとサーフェスを検索"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"commandPalette.subtitle.browserWithName": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -41329,6 +41414,57 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"settings.app.commandPaletteSearchAllSurfaces": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Command Palette Searches All Surfaces"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "コマンドパレットですべてのサーフェスを検索"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.commandPaletteSearchAllSurfaces.subtitleOff": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Cmd+P matches workspace rows only."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Cmd+Pはワークスペース行だけを対象にします。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.commandPaletteSearchAllSurfaces.subtitleOn": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Cmd+Pでワークスペースをまたいだターミナル、ブラウザ、Markdownのサーフェスも検索できます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.app.closeWorkspaceOnLastSurfaceShortcut": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
|
|||
|
|
@ -1371,6 +1371,8 @@ struct ContentView: View {
|
|||
@State private var isFeedbackComposerPresented = false
|
||||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
@AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey)
|
||||
private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces
|
||||
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
|
||||
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
|
||||
@FocusState private var isCommandPaletteSearchFocused: Bool
|
||||
|
|
@ -1476,6 +1478,7 @@ struct ContentView: View {
|
|||
let title: String
|
||||
let subtitle: String
|
||||
let shortcutHint: String?
|
||||
let kindLabel: String?
|
||||
let keywords: [String]
|
||||
let dismissOnRun: Bool
|
||||
let action: () -> Void
|
||||
|
|
@ -1618,6 +1621,14 @@ struct ContentView: View {
|
|||
let id: UUID
|
||||
let displayName: String
|
||||
let metadata: CommandPaletteSwitcherSearchMetadata
|
||||
let surfaces: [CommandPaletteSwitcherFingerprintSurface]
|
||||
}
|
||||
|
||||
struct CommandPaletteSwitcherFingerprintSurface: Sendable {
|
||||
let id: UUID
|
||||
let displayName: String
|
||||
let kindLabel: String
|
||||
let metadata: CommandPaletteSwitcherSearchMetadata
|
||||
}
|
||||
|
||||
struct CommandPaletteSwitcherFingerprintContext: Sendable {
|
||||
|
|
@ -1632,7 +1643,7 @@ struct ContentView: View {
|
|||
hotSpot: NSCursor.resizeLeftRight.hotSpot
|
||||
)
|
||||
private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1"
|
||||
private static let commandPaletteCommandsPrefix = ">"
|
||||
nonisolated private static let commandPaletteCommandsPrefix = ">"
|
||||
private static let commandPaletteVisiblePreviewResultLimit = 48
|
||||
private static let commandPaletteVisiblePreviewCandidateLimit = 192
|
||||
private static let minimumSidebarWidth: CGFloat = 186
|
||||
|
|
@ -3197,20 +3208,38 @@ struct ContentView: View {
|
|||
updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false)
|
||||
resetCommandPaletteSearchFocus()
|
||||
}
|
||||
.onChange(of: commandPaletteQuery) { _ in
|
||||
.onChange(of: commandPaletteQuery) { oldValue, newValue in
|
||||
commandPaletteSelectedResultIndex = 0
|
||||
commandPaletteSelectionAnchorCommandID = nil
|
||||
commandPaletteHoveredResultIndex = nil
|
||||
commandPaletteScrollTargetIndex = nil
|
||||
commandPaletteScrollTargetAnchor = nil
|
||||
scheduleCommandPaletteResultsRefresh()
|
||||
if Self.commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: oldValue,
|
||||
newQuery: newValue,
|
||||
hasVisibleResults: commandPaletteVisibleResultsScope != nil
|
||||
) {
|
||||
cachedCommandPaletteResults = []
|
||||
commandPaletteVisibleResults = []
|
||||
commandPaletteVisibleResultsScope = nil
|
||||
commandPaletteVisibleResultsFingerprint = nil
|
||||
}
|
||||
scheduleCommandPaletteResultsRefresh(query: newValue)
|
||||
updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false)
|
||||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
}
|
||||
.onChange(of: commandPaletteCurrentSearchFingerprint) { _ in
|
||||
scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: true)
|
||||
updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false)
|
||||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
Task { @MainActor in
|
||||
// Let the query-state transition settle first so the forced corpus refresh
|
||||
// cannot rebuild the old command list after deleting the ">" prefix.
|
||||
await Task.yield()
|
||||
scheduleCommandPaletteResultsRefresh(
|
||||
query: commandPaletteQuery,
|
||||
forceSearchCorpusRefresh: true
|
||||
)
|
||||
updateCommandPaletteScrollTarget(resultCount: commandPaletteVisibleResults.count, animated: false)
|
||||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
}
|
||||
}
|
||||
.onChange(of: commandPaletteResultsRevision) { _ in
|
||||
let resultIDs = cachedCommandPaletteResults.map(\.id)
|
||||
|
|
@ -3335,14 +3364,36 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private var commandPaletteListScope: CommandPaletteListScope {
|
||||
if commandPaletteQuery.hasPrefix(Self.commandPaletteCommandsPrefix) {
|
||||
Self.commandPaletteListScope(for: commandPaletteQuery)
|
||||
}
|
||||
|
||||
private var commandPaletteCurrentSearchFingerprint: Int {
|
||||
commandPaletteEntriesFingerprint(
|
||||
for: commandPaletteListScope,
|
||||
includeSurfaces: commandPaletteSwitcherIncludesSurfaceEntries
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func commandPaletteListScope(for query: String) -> CommandPaletteListScope {
|
||||
if query.hasPrefix(Self.commandPaletteCommandsPrefix) {
|
||||
return .commands
|
||||
}
|
||||
return .switcher
|
||||
}
|
||||
|
||||
private var commandPaletteCurrentSearchFingerprint: Int {
|
||||
commandPaletteEntriesFingerprint(for: commandPaletteListScope)
|
||||
static func commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: String,
|
||||
newQuery: String,
|
||||
hasVisibleResults: Bool
|
||||
) -> Bool {
|
||||
hasVisibleResults && commandPaletteListScope(for: oldQuery) != commandPaletteListScope(for: newQuery)
|
||||
}
|
||||
|
||||
private var commandPaletteSwitcherIncludesSurfaceEntries: Bool {
|
||||
Self.commandPaletteSwitcherIncludesSurfaceEntries(
|
||||
searchAllSurfaces: commandPaletteSearchAllSurfaces,
|
||||
query: commandPaletteQuery
|
||||
)
|
||||
}
|
||||
|
||||
private var commandPaletteSearchPlaceholder: String {
|
||||
|
|
@ -3350,7 +3401,9 @@ struct ContentView: View {
|
|||
case .commands:
|
||||
return String(localized: "commandPalette.search.commandsPlaceholder", defaultValue: "Type a command")
|
||||
case .switcher:
|
||||
return String(localized: "commandPalette.search.switcherPlaceholder", defaultValue: "Search workspaces")
|
||||
return commandPaletteSearchAllSurfaces
|
||||
? String(localized: "commandPalette.search.switcherPlaceholderAllSurfaces", defaultValue: "Search workspaces and surfaces")
|
||||
: String(localized: "commandPalette.search.switcherPlaceholder", defaultValue: "Search workspaces")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3359,37 +3412,109 @@ struct ContentView: View {
|
|||
case .commands:
|
||||
return String(localized: "commandPalette.search.commandsEmpty", defaultValue: "No commands match your search.")
|
||||
case .switcher:
|
||||
return String(localized: "commandPalette.search.switcherEmpty", defaultValue: "No workspaces match your search.")
|
||||
return commandPaletteSearchAllSurfaces
|
||||
? String(localized: "commandPalette.search.switcherEmptyAllSurfaces", defaultValue: "No workspaces or surfaces match your search.")
|
||||
: String(localized: "commandPalette.search.switcherEmpty", defaultValue: "No workspaces match your search.")
|
||||
}
|
||||
}
|
||||
|
||||
private var commandPaletteQueryForMatching: String {
|
||||
switch commandPaletteListScope {
|
||||
Self.commandPaletteQueryForMatching(
|
||||
query: commandPaletteQuery,
|
||||
scope: commandPaletteListScope
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func commandPaletteRefreshQuery(
|
||||
stateQuery: String,
|
||||
observedQuery: String?
|
||||
) -> String {
|
||||
observedQuery ?? stateQuery
|
||||
}
|
||||
|
||||
nonisolated static func commandPaletteRefreshInputsForTests(
|
||||
stateQuery: String,
|
||||
observedQuery: String?,
|
||||
searchAllSurfaces: Bool
|
||||
) -> (scope: String, matchingQuery: String, includesSurfaces: Bool) {
|
||||
let effectiveQuery = commandPaletteRefreshQuery(
|
||||
stateQuery: stateQuery,
|
||||
observedQuery: observedQuery
|
||||
)
|
||||
let scope = commandPaletteListScope(for: effectiveQuery)
|
||||
return (
|
||||
scope: scope.rawValue,
|
||||
matchingQuery: commandPaletteQueryForMatching(query: effectiveQuery, scope: scope),
|
||||
includesSurfaces: commandPaletteSwitcherIncludesSurfaceEntries(
|
||||
searchAllSurfaces: searchAllSurfaces,
|
||||
query: effectiveQuery
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func commandPaletteQueryForMatching(
|
||||
query: String,
|
||||
scope: CommandPaletteListScope
|
||||
) -> String {
|
||||
switch scope {
|
||||
case .commands:
|
||||
let suffix = String(commandPaletteQuery.dropFirst(Self.commandPaletteCommandsPrefix.count))
|
||||
let suffix = String(query.dropFirst(Self.commandPaletteCommandsPrefix.count))
|
||||
return suffix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
case .switcher:
|
||||
return commandPaletteQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] {
|
||||
commandPaletteEntries(
|
||||
for: scope,
|
||||
includeSurfaces: commandPaletteSwitcherIncludesSurfaceEntries
|
||||
)
|
||||
}
|
||||
|
||||
private func commandPaletteEntries(
|
||||
for scope: CommandPaletteListScope,
|
||||
includeSurfaces: Bool
|
||||
) -> [CommandPaletteCommand] {
|
||||
switch scope {
|
||||
case .commands:
|
||||
return commandPaletteCommands()
|
||||
case .switcher:
|
||||
return commandPaletteSwitcherEntries()
|
||||
return commandPaletteSwitcherEntries(includeSurfaces: includeSurfaces)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshCommandPaletteSearchCorpus(force: Bool = false) {
|
||||
let scope = commandPaletteListScope
|
||||
let fingerprint = commandPaletteEntriesFingerprint(for: scope)
|
||||
nonisolated private static func commandPaletteSwitcherIncludesSurfaceEntries(
|
||||
searchAllSurfaces: Bool,
|
||||
query: String
|
||||
) -> Bool {
|
||||
let scope = commandPaletteListScope(for: query)
|
||||
guard scope == .switcher else { return false }
|
||||
return searchAllSurfaces && !commandPaletteQueryForMatching(query: query, scope: scope).isEmpty
|
||||
}
|
||||
|
||||
private func refreshCommandPaletteSearchCorpus(
|
||||
force: Bool = false,
|
||||
query: String? = nil
|
||||
) {
|
||||
let effectiveQuery = Self.commandPaletteRefreshQuery(
|
||||
stateQuery: commandPaletteQuery,
|
||||
observedQuery: query
|
||||
)
|
||||
let scope = Self.commandPaletteListScope(for: effectiveQuery)
|
||||
let includeSurfaces = Self.commandPaletteSwitcherIncludesSurfaceEntries(
|
||||
searchAllSurfaces: commandPaletteSearchAllSurfaces,
|
||||
query: effectiveQuery
|
||||
)
|
||||
let fingerprint = commandPaletteEntriesFingerprint(
|
||||
for: scope,
|
||||
includeSurfaces: includeSurfaces
|
||||
)
|
||||
guard force || cachedCommandPaletteScope != scope || cachedCommandPaletteFingerprint != fingerprint else {
|
||||
return
|
||||
}
|
||||
|
||||
let entries = commandPaletteEntries(for: scope)
|
||||
let entries = commandPaletteEntries(for: scope, includeSurfaces: includeSurfaces)
|
||||
commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) })
|
||||
let searchCorpus = entries.map { entry in
|
||||
CommandPaletteSearchCorpusEntry(
|
||||
|
|
@ -3597,18 +3722,32 @@ struct ContentView: View {
|
|||
!hasVisibleResultsForScope
|
||||
}
|
||||
|
||||
private func scheduleCommandPaletteResultsRefresh(forceSearchCorpusRefresh: Bool = false) {
|
||||
refreshCommandPaletteSearchCorpus(force: forceSearchCorpusRefresh)
|
||||
private func scheduleCommandPaletteResultsRefresh(
|
||||
query: String? = nil,
|
||||
forceSearchCorpusRefresh: Bool = false
|
||||
) {
|
||||
let effectiveQuery = Self.commandPaletteRefreshQuery(
|
||||
stateQuery: commandPaletteQuery,
|
||||
observedQuery: query
|
||||
)
|
||||
let scope = Self.commandPaletteListScope(for: effectiveQuery)
|
||||
let matchingQuery = Self.commandPaletteQueryForMatching(
|
||||
query: effectiveQuery,
|
||||
scope: scope
|
||||
)
|
||||
|
||||
refreshCommandPaletteSearchCorpus(
|
||||
force: forceSearchCorpusRefresh,
|
||||
query: effectiveQuery
|
||||
)
|
||||
|
||||
commandPaletteSearchRequestID &+= 1
|
||||
let requestID = commandPaletteSearchRequestID
|
||||
let query = commandPaletteQueryForMatching
|
||||
let scope = commandPaletteListScope
|
||||
let fingerprint = cachedCommandPaletteFingerprint
|
||||
let searchCorpus = commandPaletteSearchCorpus
|
||||
let commandsByID = commandPaletteSearchCommandsByID
|
||||
let usageHistory = commandPaletteUsageHistoryByCommandId
|
||||
let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(query).isEmpty
|
||||
let queryIsEmpty = CommandPaletteFuzzyMatcher.preparedQuery(matchingQuery).isEmpty
|
||||
let historyTimestamp = Date().timeIntervalSince1970
|
||||
commandPalettePendingActivation = nil
|
||||
cancelCommandPaletteSearch()
|
||||
|
|
@ -3617,7 +3756,7 @@ struct ContentView: View {
|
|||
) {
|
||||
let matches = Self.commandPaletteResolvedSearchMatches(
|
||||
searchCorpus: searchCorpus,
|
||||
query: query,
|
||||
query: matchingQuery,
|
||||
usageHistory: usageHistory,
|
||||
queryIsEmpty: queryIsEmpty,
|
||||
historyTimestamp: historyTimestamp
|
||||
|
|
@ -3641,7 +3780,7 @@ struct ContentView: View {
|
|||
refreshPendingCommandPaletteVisibleResults(
|
||||
scope: scope,
|
||||
fingerprint: fingerprint,
|
||||
query: query,
|
||||
query: matchingQuery,
|
||||
usageHistory: usageHistory,
|
||||
queryIsEmpty: queryIsEmpty,
|
||||
historyTimestamp: historyTimestamp
|
||||
|
|
@ -3651,7 +3790,7 @@ struct ContentView: View {
|
|||
commandPaletteSearchTask = Task.detached(priority: .userInitiated) {
|
||||
let matches = Self.commandPaletteResolvedSearchMatches(
|
||||
searchCorpus: searchCorpus,
|
||||
query: query,
|
||||
query: matchingQuery,
|
||||
usageHistory: usageHistory,
|
||||
queryIsEmpty: queryIsEmpty,
|
||||
historyTimestamp: historyTimestamp,
|
||||
|
|
@ -3661,10 +3800,14 @@ struct ContentView: View {
|
|||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
let currentScope = Self.commandPaletteListScope(for: commandPaletteQuery)
|
||||
guard commandPaletteSearchRequestID == requestID,
|
||||
isCommandPalettePresented,
|
||||
commandPaletteListScope == scope,
|
||||
commandPaletteQueryForMatching == query,
|
||||
currentScope == scope,
|
||||
Self.commandPaletteQueryForMatching(
|
||||
query: commandPaletteQuery,
|
||||
scope: currentScope
|
||||
) == matchingQuery,
|
||||
cachedCommandPaletteFingerprint == fingerprint else {
|
||||
return
|
||||
}
|
||||
|
|
@ -3704,11 +3847,21 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
private func commandPaletteEntriesFingerprint(for scope: CommandPaletteListScope) -> Int {
|
||||
commandPaletteEntriesFingerprint(
|
||||
for: scope,
|
||||
includeSurfaces: commandPaletteSwitcherIncludesSurfaceEntries
|
||||
)
|
||||
}
|
||||
|
||||
private func commandPaletteEntriesFingerprint(
|
||||
for scope: CommandPaletteListScope,
|
||||
includeSurfaces: Bool
|
||||
) -> Int {
|
||||
switch scope {
|
||||
case .commands:
|
||||
return commandPaletteCommandsFingerprint()
|
||||
case .switcher:
|
||||
return commandPaletteSwitcherEntriesFingerprint()
|
||||
return commandPaletteSwitcherEntriesFingerprint(includeSurfaces: includeSurfaces)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3719,7 +3872,7 @@ struct ContentView: View {
|
|||
return hasher.finalize()
|
||||
}
|
||||
|
||||
private func commandPaletteSwitcherEntriesFingerprint() -> Int {
|
||||
private func commandPaletteSwitcherEntriesFingerprint(includeSurfaces: Bool) -> Int {
|
||||
let windowContexts = commandPaletteSwitcherWindowContexts()
|
||||
let fingerprintContexts = windowContexts.map { context in
|
||||
CommandPaletteSwitcherFingerprintContext(
|
||||
|
|
@ -3730,7 +3883,25 @@ struct ContentView: View {
|
|||
CommandPaletteSwitcherFingerprintWorkspace(
|
||||
id: workspace.id,
|
||||
displayName: workspaceDisplayName(workspace),
|
||||
metadata: commandPaletteWorkspaceSearchMetadata(for: workspace)
|
||||
metadata: commandPaletteWorkspaceSearchMetadata(for: workspace),
|
||||
surfaces: includeSurfaces
|
||||
? commandPaletteOrderedSwitcherPanels(for: workspace).compactMap { panelId in
|
||||
guard let panel = workspace.panels[panelId] else { return nil }
|
||||
return CommandPaletteSwitcherFingerprintSurface(
|
||||
id: panelId,
|
||||
displayName: panelDisplayName(
|
||||
workspace: workspace,
|
||||
panelId: panelId,
|
||||
fallback: panel.displayTitle
|
||||
),
|
||||
kindLabel: commandPaletteSurfaceKindLabel(for: panel.panelType),
|
||||
metadata: commandPaletteSurfaceSearchMetadata(
|
||||
for: workspace,
|
||||
panelId: panelId
|
||||
)
|
||||
)
|
||||
}
|
||||
: []
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -3771,20 +3942,24 @@ struct ContentView: View {
|
|||
return CommandPaletteTrailingLabel(text: shortcutHint, style: .shortcut)
|
||||
}
|
||||
|
||||
guard commandPaletteListScope == .switcher else { return nil }
|
||||
if command.id.hasPrefix("switcher.workspace.") {
|
||||
return CommandPaletteTrailingLabel(text: String(localized: "commandPalette.kind.workspace", defaultValue: "Workspace"), style: .kind)
|
||||
if let kindLabel = command.kindLabel {
|
||||
return CommandPaletteTrailingLabel(text: kindLabel, style: .kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func commandPaletteSwitcherEntries() -> [CommandPaletteCommand] {
|
||||
private func commandPaletteSwitcherEntries(includeSurfaces: Bool) -> [CommandPaletteCommand] {
|
||||
let windowContexts = commandPaletteSwitcherWindowContexts()
|
||||
guard !windowContexts.isEmpty else { return [] }
|
||||
|
||||
var entries: [CommandPaletteCommand] = []
|
||||
let estimatedCount = windowContexts.reduce(0) { partial, context in
|
||||
partial + context.tabManager.tabs.count
|
||||
let workspaceCount = context.tabManager.tabs.count
|
||||
guard includeSurfaces else { return partial + workspaceCount }
|
||||
let surfaceCount = context.tabManager.tabs.reduce(0) { count, workspace in
|
||||
count + commandPaletteOrderedSwitcherPanels(for: workspace).count
|
||||
}
|
||||
return partial + workspaceCount + surfaceCount
|
||||
}
|
||||
entries.reserveCapacity(estimatedCount)
|
||||
var nextRank = 0
|
||||
|
|
@ -3818,6 +3993,7 @@ struct ContentView: View {
|
|||
title: workspaceName,
|
||||
subtitle: commandPaletteSwitcherSubtitle(base: String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace"), windowLabel: context.windowLabel),
|
||||
shortcutHint: nil,
|
||||
kindLabel: String(localized: "commandPalette.kind.workspace", defaultValue: "Workspace"),
|
||||
keywords: workspaceKeywords,
|
||||
dismissOnRun: true,
|
||||
action: {
|
||||
|
|
@ -3830,6 +4006,53 @@ struct ContentView: View {
|
|||
)
|
||||
)
|
||||
nextRank += 1
|
||||
|
||||
guard includeSurfaces else { continue }
|
||||
|
||||
for panelId in commandPaletteOrderedSwitcherPanels(for: workspace) {
|
||||
guard let panel = workspace.panels[panelId] else { continue }
|
||||
let surfaceName = panelDisplayName(
|
||||
workspace: workspace,
|
||||
panelId: panelId,
|
||||
fallback: panel.displayTitle
|
||||
)
|
||||
let surfaceKindLabel = commandPaletteSurfaceKindLabel(for: panel.panelType)
|
||||
let surfaceCommandId = "switcher.surface.\(panelId.uuidString.lowercased())"
|
||||
let surfaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: [
|
||||
"surface",
|
||||
"tab",
|
||||
"switch",
|
||||
"go",
|
||||
"open",
|
||||
surfaceName,
|
||||
workspaceName
|
||||
] + commandPaletteSurfaceKeywords(for: panel.panelType) + windowKeywords,
|
||||
metadata: commandPaletteSurfaceSearchMetadata(for: workspace, panelId: panelId),
|
||||
detail: .surface
|
||||
)
|
||||
entries.append(
|
||||
CommandPaletteCommand(
|
||||
id: surfaceCommandId,
|
||||
rank: nextRank,
|
||||
title: surfaceName,
|
||||
subtitle: commandPaletteSwitcherSubtitle(base: workspaceName, windowLabel: context.windowLabel),
|
||||
shortcutHint: nil,
|
||||
kindLabel: surfaceKindLabel,
|
||||
keywords: surfaceKeywords,
|
||||
dismissOnRun: true,
|
||||
action: {
|
||||
focusCommandPaletteSwitcherSurfaceTarget(
|
||||
windowId: windowId,
|
||||
tabManager: windowTabManager,
|
||||
workspaceId: workspace.id,
|
||||
panelId: panelId
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
nextRank += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3911,6 +4134,19 @@ struct ContentView: View {
|
|||
return workspaces
|
||||
}
|
||||
|
||||
private func commandPaletteOrderedSwitcherPanels(for workspace: Workspace) -> [UUID] {
|
||||
let orderedPanelIds = workspace.sidebarOrderedPanelIds()
|
||||
guard orderedPanelIds.count < workspace.panels.count else { return orderedPanelIds }
|
||||
|
||||
var panelIds = orderedPanelIds
|
||||
var seen = Set(orderedPanelIds)
|
||||
for panelId in workspace.panels.keys.sorted(by: { $0.uuidString < $1.uuidString })
|
||||
where seen.insert(panelId).inserted {
|
||||
panelIds.append(panelId)
|
||||
}
|
||||
return panelIds
|
||||
}
|
||||
|
||||
private func focusCommandPaletteSwitcherTarget(
|
||||
windowId: UUID,
|
||||
tabManager: TabManager,
|
||||
|
|
@ -3925,6 +4161,18 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func focusCommandPaletteSwitcherSurfaceTarget(
|
||||
windowId: UUID,
|
||||
tabManager: TabManager,
|
||||
workspaceId: UUID,
|
||||
panelId: UUID
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
|
||||
tabManager.focusTab(workspaceId, surfaceId: panelId, suppressFlash: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata {
|
||||
// Keep workspace rows coarse and stable for predictable workspace switching queries.
|
||||
let directories = [workspace.currentDirectory]
|
||||
|
|
@ -3937,6 +4185,42 @@ struct ContentView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private func commandPaletteSurfaceSearchMetadata(
|
||||
for workspace: Workspace,
|
||||
panelId: UUID
|
||||
) -> CommandPaletteSwitcherSearchMetadata {
|
||||
let directories = [workspace.panelDirectories[panelId]].compactMap { $0 }
|
||||
let branches = [workspace.panelGitBranches[panelId]?.branch].compactMap { $0 }
|
||||
let ports = workspace.surfaceListeningPorts[panelId] ?? []
|
||||
return CommandPaletteSwitcherSearchMetadata(
|
||||
directories: directories,
|
||||
branches: branches,
|
||||
ports: ports
|
||||
)
|
||||
}
|
||||
|
||||
private func commandPaletteSurfaceKindLabel(for panelType: PanelType) -> String {
|
||||
switch panelType {
|
||||
case .terminal:
|
||||
return String(localized: "commandPalette.kind.terminal", defaultValue: "Terminal")
|
||||
case .browser:
|
||||
return String(localized: "commandPalette.kind.browser", defaultValue: "Browser")
|
||||
case .markdown:
|
||||
return String(localized: "commandPalette.kind.markdown", defaultValue: "Markdown")
|
||||
}
|
||||
}
|
||||
|
||||
private func commandPaletteSurfaceKeywords(for panelType: PanelType) -> [String] {
|
||||
switch panelType {
|
||||
case .terminal:
|
||||
return ["terminal", "shell", "console"]
|
||||
case .browser:
|
||||
return ["browser", "web", "page"]
|
||||
case .markdown:
|
||||
return ["markdown", "note", "preview"]
|
||||
}
|
||||
}
|
||||
|
||||
private func commandPaletteCommands() -> [CommandPaletteCommand] {
|
||||
let context = commandPaletteContextSnapshot()
|
||||
let contributions = commandPaletteCommandContributions()
|
||||
|
|
@ -3960,6 +4244,7 @@ struct ContentView: View {
|
|||
title: contribution.title(context),
|
||||
subtitle: contribution.subtitle(context),
|
||||
shortcutHint: commandPaletteShortcutHint(for: contribution, context: context),
|
||||
kindLabel: nil,
|
||||
keywords: contribution.keywords,
|
||||
dismissOnRun: contribution.dismissOnRun,
|
||||
action: action
|
||||
|
|
@ -5285,6 +5570,13 @@ struct ContentView: View {
|
|||
hasher.combine(workspace.id)
|
||||
hasher.combine(workspace.displayName)
|
||||
combineCommandPaletteSwitcherSearchMetadata(workspace.metadata, into: &hasher)
|
||||
hasher.combine(workspace.surfaces.count)
|
||||
for surface in workspace.surfaces {
|
||||
hasher.combine(surface.id)
|
||||
hasher.combine(surface.displayName)
|
||||
hasher.combine(surface.kindLabel)
|
||||
combineCommandPaletteSwitcherSearchMetadata(surface.metadata, into: &hasher)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasher.finalize()
|
||||
|
|
|
|||
|
|
@ -3006,6 +3006,18 @@ enum CommandPaletteRenameSelectionSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum CommandPaletteSwitcherSearchSettings {
|
||||
static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces"
|
||||
static let defaultSearchAllSurfaces = false
|
||||
|
||||
static func searchAllSurfacesEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: searchAllSurfacesKey) == nil {
|
||||
return defaultSearchAllSurfaces
|
||||
}
|
||||
return defaults.bool(forKey: searchAllSurfacesKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum ClaudeCodeIntegrationSettings {
|
||||
static let hooksEnabledKey = "claudeCodeHooksEnabled"
|
||||
static let defaultHooksEnabled = true
|
||||
|
|
@ -3073,6 +3085,8 @@ struct SettingsView: View {
|
|||
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
@AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey)
|
||||
private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
|
||||
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
|
|
@ -3695,6 +3709,23 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"),
|
||||
subtitle: commandPaletteSearchAllSurfaces
|
||||
? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.")
|
||||
: String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.")
|
||||
) {
|
||||
Toggle("", isOn: $commandPaletteSearchAllSurfaces)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
.accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle")
|
||||
.accessibilityLabel(
|
||||
String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces")
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"),
|
||||
subtitle: sidebarHideAllDetails
|
||||
|
|
@ -4455,6 +4486,7 @@ struct SettingsView: View {
|
|||
showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar
|
||||
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces
|
||||
ShortcutHintDebugSettings.resetVisibilityDefaults()
|
||||
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
|
|
|
|||
|
|
@ -434,6 +434,78 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testVisibleResultsResetWhenQueryChangesCommandPaletteScope() {
|
||||
XCTAssertTrue(
|
||||
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: ">",
|
||||
newQuery: "",
|
||||
hasVisibleResults: true
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: "",
|
||||
newQuery: ">",
|
||||
hasVisibleResults: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: ">rename",
|
||||
newQuery: ">renam",
|
||||
hasVisibleResults: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: ">",
|
||||
newQuery: "",
|
||||
hasVisibleResults: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testRefreshInputsPreferObservedQueryOverStaleState() {
|
||||
let inputs = ContentView.commandPaletteRefreshInputsForTests(
|
||||
stateQuery: ">",
|
||||
observedQuery: "",
|
||||
searchAllSurfaces: true
|
||||
)
|
||||
|
||||
XCTAssertEqual(inputs.scope, "switcher")
|
||||
XCTAssertEqual(inputs.matchingQuery, "")
|
||||
XCTAssertFalse(inputs.includesSurfaces)
|
||||
}
|
||||
|
||||
func testRefreshInputsIncludeSurfacesOnlyForNonEmptySwitcherQuery() {
|
||||
let switcherInputs = ContentView.commandPaletteRefreshInputsForTests(
|
||||
stateQuery: "",
|
||||
observedQuery: " feature/search ",
|
||||
searchAllSurfaces: true
|
||||
)
|
||||
XCTAssertEqual(switcherInputs.scope, "switcher")
|
||||
XCTAssertEqual(switcherInputs.matchingQuery, "feature/search")
|
||||
XCTAssertTrue(switcherInputs.includesSurfaces)
|
||||
|
||||
let commandInputs = ContentView.commandPaletteRefreshInputsForTests(
|
||||
stateQuery: "",
|
||||
observedQuery: ">feature/search",
|
||||
searchAllSurfaces: true
|
||||
)
|
||||
XCTAssertEqual(commandInputs.scope, "commands")
|
||||
XCTAssertEqual(commandInputs.matchingQuery, "feature/search")
|
||||
XCTAssertFalse(commandInputs.includesSurfaces)
|
||||
|
||||
let workspaceOnlyInputs = ContentView.commandPaletteRefreshInputsForTests(
|
||||
stateQuery: "",
|
||||
observedQuery: "feature/search",
|
||||
searchAllSurfaces: false
|
||||
)
|
||||
XCTAssertEqual(workspaceOnlyInputs.scope, "switcher")
|
||||
XCTAssertEqual(workspaceOnlyInputs.matchingQuery, "feature/search")
|
||||
XCTAssertFalse(workspaceOnlyInputs.includesSurfaces)
|
||||
}
|
||||
|
||||
func testCommandContextFingerprintTracksExactContextValues() {
|
||||
let base = ContentView.commandPaletteContextFingerprint(
|
||||
boolValues: [
|
||||
|
|
@ -490,7 +562,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
directories: ["/Users/example/dev/cmuxterm"],
|
||||
branches: ["feature/search-speed"],
|
||||
ports: [3000]
|
||||
)
|
||||
),
|
||||
surfaces: []
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
@ -510,7 +583,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
directories: ["/Users/example/dev/other"],
|
||||
branches: ["feature/search-speed"],
|
||||
ports: [4000]
|
||||
)
|
||||
),
|
||||
surfaces: []
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
@ -530,7 +604,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
directories: ["/Users/example/dev/cmuxterm"],
|
||||
branches: ["feature/search-speed"],
|
||||
ports: [3000]
|
||||
)
|
||||
),
|
||||
surfaces: []
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
@ -541,6 +616,100 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
XCTAssertNotEqual(base, changedDisplayName)
|
||||
}
|
||||
|
||||
func testSwitcherFingerprintTracksSurfaceValuesAtSameCardinality() {
|
||||
let windowID = UUID()
|
||||
let workspaceID = UUID()
|
||||
let surfaceID = UUID()
|
||||
|
||||
let base = ContentView.commandPaletteSwitcherFingerprint(
|
||||
windowContexts: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintContext(
|
||||
windowId: windowID,
|
||||
windowLabel: nil,
|
||||
selectedWorkspaceId: workspaceID,
|
||||
workspaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintWorkspace(
|
||||
id: workspaceID,
|
||||
displayName: "Workspace Alpha",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(),
|
||||
surfaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintSurface(
|
||||
id: surfaceID,
|
||||
displayName: "Terminal",
|
||||
kindLabel: "Terminal",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/search-alpha"],
|
||||
branches: ["feature/a"],
|
||||
ports: [3000]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
let changedSurfaceMetadata = ContentView.commandPaletteSwitcherFingerprint(
|
||||
windowContexts: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintContext(
|
||||
windowId: windowID,
|
||||
windowLabel: nil,
|
||||
selectedWorkspaceId: workspaceID,
|
||||
workspaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintWorkspace(
|
||||
id: workspaceID,
|
||||
displayName: "Workspace Alpha",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(),
|
||||
surfaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintSurface(
|
||||
id: surfaceID,
|
||||
displayName: "Terminal",
|
||||
kindLabel: "Terminal",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/search-beta"],
|
||||
branches: ["feature/a"],
|
||||
ports: [3000]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
let changedSurfaceKind = ContentView.commandPaletteSwitcherFingerprint(
|
||||
windowContexts: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintContext(
|
||||
windowId: windowID,
|
||||
windowLabel: nil,
|
||||
selectedWorkspaceId: workspaceID,
|
||||
workspaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintWorkspace(
|
||||
id: workspaceID,
|
||||
displayName: "Workspace Alpha",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(),
|
||||
surfaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintSurface(
|
||||
id: surfaceID,
|
||||
displayName: "Terminal",
|
||||
kindLabel: "Browser",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/search-alpha"],
|
||||
branches: ["feature/a"],
|
||||
ports: [3000]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertNotEqual(base, changedSurfaceMetadata)
|
||||
XCTAssertNotEqual(base, changedSurfaceKind)
|
||||
}
|
||||
|
||||
func testCommandSearchBenchmarkBeatsLegacyPipeline() {
|
||||
let entries = makeCommandEntries(count: 900)
|
||||
let corpus = entries.map { entry in
|
||||
|
|
|
|||
|
|
@ -294,3 +294,456 @@ final class FeedbackComposerShortcutUITests: XCTestCase {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteAllSurfacesUITests: XCTestCase {
|
||||
private var socketPath = ""
|
||||
private let hiddenSurfaceToken = "cmux-command-palette-hidden-surface"
|
||||
private let visibleSurfaceToken = "cmux-command-palette-visible-surface"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
socketPath = "/tmp/cmux-ui-test-command-palette-\(UUID().uuidString).sock"
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCmdShiftPBackspaceReturnsToWorkspaceResults() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
launchAndActivate(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 8.0) {
|
||||
app.windows.count >= 1
|
||||
},
|
||||
"Expected the main window to be visible"
|
||||
)
|
||||
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
|
||||
|
||||
let mainWindowId = try XCTUnwrap(
|
||||
socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
|
||||
openCommandPaletteCommands(app: app)
|
||||
|
||||
_ = try XCTUnwrap(
|
||||
waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "commands", query: "", timeout: 5.0) { snapshot in
|
||||
self.commandPaletteResultRows(from: snapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
return !commandId.hasPrefix("switcher.")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
|
||||
let switcherSnapshot = try XCTUnwrap(
|
||||
waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "switcher", query: "", timeout: 5.0) { snapshot in
|
||||
self.commandPaletteResultRows(from: snapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.workspace.")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertTrue(
|
||||
commandPaletteResultRows(from: switcherSnapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.workspace.")
|
||||
},
|
||||
"Expected deleting the command prefix to restore workspace rows. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
|
||||
let rows = commandPaletteResultRows(from: switcherSnapshot)
|
||||
let firstRowCommandId = rows.first?["command_id"] as? String ?? ""
|
||||
XCTAssertTrue(
|
||||
firstRowCommandId.hasPrefix("switcher.workspace."),
|
||||
"Expected the first restored row to be a workspace. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
|
||||
let firstWorkspaceRow = try XCTUnwrap(
|
||||
rows.first(where: { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.workspace.")
|
||||
}),
|
||||
"Expected a workspace row in the restored switcher results. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
let workspaceTitle = try XCTUnwrap(
|
||||
firstWorkspaceRow["title"] as? String,
|
||||
"Expected the restored workspace row to include a title. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
let workspaceLabel = app.staticTexts[workspaceTitle].firstMatch
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 2.0) {
|
||||
workspaceLabel.exists && workspaceLabel.isHittable
|
||||
},
|
||||
"Expected the restored workspace row to be visibly rendered. title=\(workspaceTitle) snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
|
||||
let staleCommandLabel = app.staticTexts["Close Other Workspaces"].firstMatch
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 2.0) {
|
||||
!staleCommandLabel.exists || !staleCommandLabel.isHittable
|
||||
},
|
||||
"Expected the stale command row to disappear after deleting the command prefix. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdPSearchCanIncludeSurfacesFromOtherWorkspacesWhenEnabled() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1"
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
launchAndActivate(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 8.0) {
|
||||
app.windows.count >= 2
|
||||
},
|
||||
"Expected the main window and Settings window to be visible"
|
||||
)
|
||||
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
|
||||
|
||||
let mainWindowId = try XCTUnwrap(socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
let secondaryWorkspaceId = try XCTUnwrap(okUUID(from: socketCommand("new_workspace")))
|
||||
let initialSurfaceId = try XCTUnwrap(waitForSurfaceIDs(minimumCount: 1, timeout: 5.0).first)
|
||||
let hiddenSurfaceId = try XCTUnwrap(okUUID(from: socketCommand("new_surface --type=terminal")))
|
||||
|
||||
XCTAssertEqual(
|
||||
socketCommand("report_pwd /tmp/\(hiddenSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(hiddenSurfaceId)"),
|
||||
"OK"
|
||||
)
|
||||
XCTAssertEqual(socketCommand("focus_surface \(initialSurfaceId)"), "OK")
|
||||
XCTAssertEqual(
|
||||
socketCommand("report_pwd /tmp/\(visibleSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(initialSurfaceId)"),
|
||||
"OK"
|
||||
)
|
||||
XCTAssertEqual(socketCommand("select_workspace 0"), "OK")
|
||||
XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK")
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
|
||||
openCommandPalette(app: app, query: hiddenSurfaceToken)
|
||||
let disabledSnapshot = try XCTUnwrap(
|
||||
waitForCommandPaletteSnapshot(windowId: mainWindowId, query: hiddenSurfaceToken, timeout: 5.0) { snapshot in
|
||||
self.commandPaletteResultRows(from: snapshot).isEmpty
|
||||
}
|
||||
)
|
||||
XCTAssertEqual(commandPaletteResultRows(from: disabledSnapshot).count, 0)
|
||||
dismissCommandPalette(app: app)
|
||||
|
||||
focusSettingsWindow(app: app)
|
||||
let toggle = try requireSearchAllSurfacesToggle(app: app)
|
||||
if !toggleIsOn(toggle) {
|
||||
toggle.click()
|
||||
}
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 3.0) {
|
||||
toggle.exists && toggleIsOn(toggle)
|
||||
},
|
||||
"Expected the all-surfaces search setting to be enabled"
|
||||
)
|
||||
|
||||
XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK")
|
||||
|
||||
openCommandPalette(app: app, query: hiddenSurfaceToken)
|
||||
let enabledSnapshot = try XCTUnwrap(
|
||||
waitForCommandPaletteSnapshot(windowId: mainWindowId, query: hiddenSurfaceToken, timeout: 5.0) { snapshot in
|
||||
self.commandPaletteResultRows(from: snapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
let trailingLabel = row["trailing_label"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.surface.") && trailingLabel == "Terminal"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertTrue(
|
||||
commandPaletteResultRows(from: enabledSnapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
let trailingLabel = row["trailing_label"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.surface.") && trailingLabel == "Terminal"
|
||||
},
|
||||
"Expected Cmd+P to surface the hidden terminal when all-surfaces search is enabled. snapshot=\(enabledSnapshot)"
|
||||
)
|
||||
}
|
||||
|
||||
private func launchAndActivate(_ app: XCUIApplication) {
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 4.0) {
|
||||
guard app.state != .runningForeground else { return true }
|
||||
app.activate()
|
||||
return app.state == .runningForeground
|
||||
},
|
||||
"App did not reach runningForeground before UI interactions"
|
||||
)
|
||||
}
|
||||
|
||||
private func openCommandPalette(app: XCUIApplication, query: String) {
|
||||
let searchField = app.textFields["CommandPaletteSearchField"]
|
||||
app.typeKey("p", modifierFlags: [.command])
|
||||
XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
|
||||
searchField.click()
|
||||
searchField.typeText(query)
|
||||
}
|
||||
|
||||
private func openCommandPaletteCommands(app: XCUIApplication) {
|
||||
let searchField = app.textFields["CommandPaletteSearchField"]
|
||||
app.typeKey("p", modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
|
||||
searchField.click()
|
||||
}
|
||||
|
||||
private func dismissCommandPalette(app: XCUIApplication) {
|
||||
let searchField = app.textFields["CommandPaletteSearchField"]
|
||||
for _ in 0..<2 {
|
||||
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
||||
if sidebarHelpPollUntil(timeout: 1.0) { !searchField.exists } {
|
||||
return
|
||||
}
|
||||
}
|
||||
XCTAssertFalse(searchField.exists, "Expected command palette to dismiss")
|
||||
}
|
||||
|
||||
private func focusSettingsWindow(app: XCUIApplication) {
|
||||
app.typeKey(",", modifierFlags: [.command])
|
||||
}
|
||||
|
||||
private func requireSearchAllSurfacesToggle(app: XCUIApplication) throws -> XCUIElement {
|
||||
let toggleId = "CommandPaletteSearchAllSurfacesToggle"
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
let candidates = [
|
||||
app.switches[toggleId],
|
||||
app.checkBoxes[toggleId],
|
||||
app.buttons[toggleId],
|
||||
app.otherElements[toggleId],
|
||||
]
|
||||
|
||||
for _ in 0..<8 {
|
||||
if let element = firstExistingElement(candidates: candidates, timeout: 0.4), element.isHittable {
|
||||
return element
|
||||
}
|
||||
if scrollView.exists {
|
||||
scrollView.swipeUp()
|
||||
}
|
||||
}
|
||||
|
||||
throw XCTSkip("Could not find the command palette all-surfaces toggle")
|
||||
}
|
||||
|
||||
private func toggleIsOn(_ element: XCUIElement) -> Bool {
|
||||
let value = String(describing: element.value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return value == "1" || value == "true" || value == "on"
|
||||
}
|
||||
|
||||
private func firstExistingElement(
|
||||
candidates: [XCUIElement],
|
||||
timeout: TimeInterval
|
||||
) -> XCUIElement? {
|
||||
var match: XCUIElement?
|
||||
let found = sidebarHelpPollUntil(timeout: timeout) {
|
||||
for candidate in candidates where candidate.exists {
|
||||
match = candidate
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return found ? match : nil
|
||||
}
|
||||
|
||||
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
|
||||
sidebarHelpPollUntil(timeout: timeout) {
|
||||
socketCommand("ping") == "PONG"
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForSurfaceIDs(minimumCount: Int, timeout: TimeInterval) -> [String] {
|
||||
var ids: [String] = []
|
||||
let found = sidebarHelpPollUntil(timeout: timeout) {
|
||||
ids = surfaceIDs()
|
||||
return ids.count >= minimumCount
|
||||
}
|
||||
return found ? ids : surfaceIDs()
|
||||
}
|
||||
|
||||
private func surfaceIDs() -> [String] {
|
||||
guard let response = socketCommand("list_surfaces"), !response.isEmpty, !response.hasPrefix("No surfaces") else {
|
||||
return []
|
||||
}
|
||||
return response
|
||||
.split(separator: "\n")
|
||||
.compactMap { line in
|
||||
guard let range = line.range(of: ": ") else { return nil }
|
||||
return String(line[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private func okUUID(from response: String?) -> String? {
|
||||
guard let response, response.hasPrefix("OK ") else { return nil }
|
||||
let value = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return UUID(uuidString: value) != nil ? value : nil
|
||||
}
|
||||
|
||||
private func socketCommand(_ command: String) -> String? {
|
||||
ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendLine(command)
|
||||
}
|
||||
|
||||
private func commandPaletteResultRows(from snapshot: [String: Any]) -> [[String: Any]] {
|
||||
snapshot["results"] as? [[String: Any]] ?? []
|
||||
}
|
||||
|
||||
private func waitForCommandPaletteSnapshot(
|
||||
windowId: String,
|
||||
mode: String = "switcher",
|
||||
query: String,
|
||||
timeout: TimeInterval,
|
||||
predicate: (([String: Any]) -> Bool)? = nil
|
||||
) -> [String: Any]? {
|
||||
var latest: [String: Any]?
|
||||
let matched = sidebarHelpPollUntil(timeout: timeout) {
|
||||
guard let snapshot = commandPaletteSnapshot(windowId: windowId) else { return false }
|
||||
latest = snapshot
|
||||
guard (snapshot["visible"] as? Bool) == true else { return false }
|
||||
guard (snapshot["mode"] as? String) == mode else { return false }
|
||||
guard (snapshot["query"] as? String) == query else { return false }
|
||||
return predicate?(snapshot) ?? true
|
||||
}
|
||||
return matched ? latest : latest
|
||||
}
|
||||
|
||||
private func commandPaletteSnapshot(windowId: String) -> [String: Any]? {
|
||||
let envelope = socketJSON(
|
||||
method: "debug.command_palette.results",
|
||||
params: [
|
||||
"window_id": windowId,
|
||||
"limit": 20,
|
||||
]
|
||||
)
|
||||
guard let ok = envelope?["ok"] as? Bool, ok else { return nil }
|
||||
return envelope?["result"] as? [String: Any]
|
||||
}
|
||||
|
||||
private func socketJSON(method: String, params: [String: Any]) -> [String: Any]? {
|
||||
let request: [String: Any] = [
|
||||
"id": UUID().uuidString,
|
||||
"method": method,
|
||||
"params": params,
|
||||
]
|
||||
return ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendJSON(request)
|
||||
}
|
||||
|
||||
private final class ControlSocketClient {
|
||||
private let path: String
|
||||
private let responseTimeout: TimeInterval
|
||||
|
||||
init(path: String, responseTimeout: TimeInterval) {
|
||||
self.path = path
|
||||
self.responseTimeout = responseTimeout
|
||||
}
|
||||
|
||||
func sendJSON(_ object: [String: Any]) -> [String: Any]? {
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object),
|
||||
let line = String(data: data, encoding: .utf8),
|
||||
let response = sendLine(line),
|
||||
let responseData = response.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func sendLine(_ line: String) -> String? {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return nil }
|
||||
defer { close(fd) }
|
||||
|
||||
#if os(macOS)
|
||||
var noSigPipe: Int32 = 1
|
||||
_ = withUnsafePointer(to: &noSigPipe) { ptr in
|
||||
setsockopt(
|
||||
fd,
|
||||
SOL_SOCKET,
|
||||
SO_NOSIGPIPE,
|
||||
ptr,
|
||||
socklen_t(MemoryLayout<Int32>.size)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
var addr = sockaddr_un()
|
||||
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
|
||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
let bytes = Array(path.utf8CString)
|
||||
guard bytes.count <= maxLen else { return nil }
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self)
|
||||
memset(raw, 0, maxLen)
|
||||
for index in 0..<bytes.count {
|
||||
raw[index] = bytes[index]
|
||||
}
|
||||
}
|
||||
|
||||
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
|
||||
let addrLen = socklen_t(pathOffset + bytes.count)
|
||||
#if os(macOS)
|
||||
addr.sun_len = UInt8(min(Int(addrLen), 255))
|
||||
#endif
|
||||
|
||||
let connected = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, addrLen)
|
||||
}
|
||||
}
|
||||
guard connected == 0 else { return nil }
|
||||
|
||||
let payload = line + "\n"
|
||||
let wrote: Bool = payload.withCString { cString in
|
||||
var remaining = strlen(cString)
|
||||
var pointer = UnsafeRawPointer(cString)
|
||||
while remaining > 0 {
|
||||
let written = write(fd, pointer, remaining)
|
||||
if written <= 0 { return false }
|
||||
remaining -= written
|
||||
pointer = pointer.advanced(by: written)
|
||||
}
|
||||
return true
|
||||
}
|
||||
guard wrote else { return nil }
|
||||
|
||||
let deadline = Date().addingTimeInterval(responseTimeout)
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
var accumulator = ""
|
||||
while Date() < deadline {
|
||||
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let ready = poll(&pollDescriptor, 1, 100)
|
||||
if ready < 0 {
|
||||
return nil
|
||||
}
|
||||
if ready == 0 {
|
||||
continue
|
||||
}
|
||||
let count = read(fd, &buffer, buffer.count)
|
||||
if count <= 0 { break }
|
||||
if let chunk = String(bytes: buffer[0..<count], encoding: .utf8) {
|
||||
accumulator.append(chunk)
|
||||
if let newline = accumulator.firstIndex(of: "\n") {
|
||||
return String(accumulator[..<newline])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator.isEmpty ? nil : accumulator.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue