diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index ff9de5ea..2a67586c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -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": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8ba9ab34..eb2bcf48 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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() diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0df1321e..90899b56 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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 diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index fd9ada43..6976ad1d 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -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 diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index 9ba6cb4a..b27cee62 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -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.size) + ) + } +#endif + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.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...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..