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:
Lawrence Chen 2026-03-13 17:55:10 -07:00 committed by GitHub
commit f90bcbc862
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1122 additions and 40 deletions

View file

@ -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": {

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)
}
}
}