diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7817856d..b887a15e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1328,6 +1328,13 @@ struct ContentView: View { var id: String { command.id } } + private struct CommandPaletteSwitcherWindowContext { + let windowId: UUID + let tabManager: TabManager + let selectedWorkspaceId: UUID? + let windowLabel: String? + } + private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot @@ -2792,94 +2799,193 @@ struct ContentView: View { } private func commandPaletteSwitcherEntries() -> [CommandPaletteCommand] { - var workspaces = tabManager.tabs - guard !workspaces.isEmpty else { return [] } - - if let selectedWorkspaceId = tabManager.selectedTabId, - let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { - let selectedWorkspace = workspaces.remove(at: selectedIndex) - workspaces.insert(selectedWorkspace, at: 0) - } + let windowContexts = commandPaletteSwitcherWindowContexts() + guard !windowContexts.isEmpty else { return [] } var entries: [CommandPaletteCommand] = [] - entries.reserveCapacity(workspaces.count * 4) + let estimatedCount = windowContexts.reduce(0) { partial, context in + partial + max(1, context.tabManager.tabs.count) * 4 + } + entries.reserveCapacity(estimatedCount) var nextRank = 0 - for workspace in workspaces { - let workspaceName = workspaceDisplayName(workspace) - let workspaceCommandId = "switcher.workspace.\(workspace.id.uuidString.lowercased())" - let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( - baseKeywords: [ - "workspace", - "switch", - "go", - "open", - workspaceName - ], - metadata: commandPaletteWorkspaceSearchMetadata(for: workspace), - detail: .workspace - ) - entries.append( - CommandPaletteCommand( - id: workspaceCommandId, - rank: nextRank, - title: workspaceName, - subtitle: "Workspace", - shortcutHint: nil, - keywords: workspaceKeywords, - dismissOnRun: true, - action: { - tabManager.focusTab(workspace.id, suppressFlash: true) - } - ) - ) - nextRank += 1 + for context in windowContexts { + var workspaces = context.tabManager.tabs + guard !workspaces.isEmpty else { continue } - var orderedPanelIds = workspace.sidebarOrderedPanelIds() - if let focusedPanelId = workspace.focusedPanelId, - let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) { - orderedPanelIds.remove(at: focusedIndex) - orderedPanelIds.insert(focusedPanelId, at: 0) + let selectedWorkspaceId = context.selectedWorkspaceId ?? context.tabManager.selectedTabId + if let selectedWorkspaceId, + let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { + let selectedWorkspace = workspaces.remove(at: selectedIndex) + workspaces.insert(selectedWorkspace, at: 0) } - for panelId in orderedPanelIds { - guard let panel = workspace.panels[panelId] else { continue } - let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle) - let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal" - let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + let windowId = context.windowId + let windowTabManager = context.tabManager + let windowKeywords = commandPaletteWindowKeywords(windowLabel: context.windowLabel) + for workspace in workspaces { + let workspaceName = workspaceDisplayName(workspace) + let workspaceCommandId = "switcher.workspace.\(workspace.id.uuidString.lowercased())" + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( baseKeywords: [ - "tab", - "surface", - "panel", + "workspace", "switch", "go", - workspaceName, - panelTitle, - typeLabel.lowercased() - ], - metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId) + "open", + workspaceName + ] + windowKeywords, + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace), + detail: .workspace ) + let workspaceId = workspace.id entries.append( CommandPaletteCommand( - id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())", + id: workspaceCommandId, rank: nextRank, - title: panelTitle, - subtitle: "\(typeLabel) • \(workspaceName)", + title: workspaceName, + subtitle: commandPaletteSwitcherSubtitle(base: "Workspace", windowLabel: context.windowLabel), shortcutHint: nil, - keywords: panelKeywords, + keywords: workspaceKeywords, dismissOnRun: true, action: { - tabManager.focusTab(workspace.id, surfaceId: panelId, suppressFlash: true) + focusCommandPaletteSwitcherTarget( + windowId: windowId, + tabManager: windowTabManager, + workspaceId: workspaceId, + panelId: nil + ) } ) ) nextRank += 1 + + var orderedPanelIds = workspace.sidebarOrderedPanelIds() + if let focusedPanelId = workspace.focusedPanelId, + let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) { + orderedPanelIds.remove(at: focusedIndex) + orderedPanelIds.insert(focusedPanelId, at: 0) + } + + for panelId in orderedPanelIds { + guard let panel = workspace.panels[panelId] else { continue } + let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle) + let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal" + let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "tab", + "surface", + "panel", + "switch", + "go", + workspaceName, + panelTitle, + typeLabel.lowercased() + ] + windowKeywords, + metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId) + ) + entries.append( + CommandPaletteCommand( + id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())", + rank: nextRank, + title: panelTitle, + subtitle: commandPaletteSwitcherSubtitle( + base: "\(typeLabel) • \(workspaceName)", + windowLabel: context.windowLabel + ), + shortcutHint: nil, + keywords: panelKeywords, + dismissOnRun: true, + action: { + focusCommandPaletteSwitcherTarget( + windowId: windowId, + tabManager: windowTabManager, + workspaceId: workspaceId, + panelId: panelId + ) + } + ) + ) + nextRank += 1 + } } } return entries } + private func commandPaletteSwitcherWindowContexts() -> [CommandPaletteSwitcherWindowContext] { + let fallback = CommandPaletteSwitcherWindowContext( + windowId: windowId, + tabManager: tabManager, + selectedWorkspaceId: tabManager.selectedTabId, + windowLabel: nil + ) + + guard let appDelegate = AppDelegate.shared else { return [fallback] } + let summaries = appDelegate.listMainWindowSummaries() + guard !summaries.isEmpty else { return [fallback] } + + let orderedSummaries = summaries.sorted { lhs, rhs in + let lhsIsCurrent = lhs.windowId == windowId + let rhsIsCurrent = rhs.windowId == windowId + if lhsIsCurrent != rhsIsCurrent { return lhsIsCurrent } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + var windowLabelById: [UUID: String] = [:] + if orderedSummaries.count > 1 { + for (index, summary) in orderedSummaries.enumerated() where summary.windowId != windowId { + windowLabelById[summary.windowId] = "Window \(index + 1)" + } + } + + var contexts: [CommandPaletteSwitcherWindowContext] = [] + var seenWindowIds: Set = [] + for summary in orderedSummaries { + guard let manager = appDelegate.tabManagerFor(windowId: summary.windowId) else { continue } + guard seenWindowIds.insert(summary.windowId).inserted else { continue } + contexts.append( + CommandPaletteSwitcherWindowContext( + windowId: summary.windowId, + tabManager: manager, + selectedWorkspaceId: summary.selectedWorkspaceId, + windowLabel: windowLabelById[summary.windowId] + ) + ) + } + + if contexts.isEmpty { + return [fallback] + } + return contexts + } + + private func commandPaletteSwitcherSubtitle(base: String, windowLabel: String?) -> String { + guard let windowLabel else { return base } + return "\(base) • \(windowLabel)" + } + + private func commandPaletteWindowKeywords(windowLabel: String?) -> [String] { + guard let windowLabel else { return [] } + return ["window", windowLabel.lowercased()] + } + + private func focusCommandPaletteSwitcherTarget( + windowId: UUID, + tabManager: TabManager, + workspaceId: UUID, + panelId: UUID? + ) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + if let panelId { + tabManager.focusTab(workspaceId, surfaceId: panelId, suppressFlash: true) + } else { + tabManager.focusTab(workspaceId, suppressFlash: true) + } + } + private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata { // Keep workspace rows coarse so surface rows win for directory/branch-specific queries. let directories = [workspace.currentDirectory] diff --git a/tests_v2/test_command_palette_switcher_all_windows.py b/tests_v2/test_command_palette_switcher_all_windows.py new file mode 100644 index 00000000..b779d383 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_all_windows.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Regression test: cmd+p switcher should include workspaces from every window. + +Why: switcher rows were sourced from the current window's TabManager only, so +Cmd+P could not jump to workspaces/tabs owned by other windows. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility in {window_id} did not become {visible}", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_a = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_a: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_a) + client.activate_app() + time.sleep(0.2) + + window_b = client.new_window() + time.sleep(0.25) + + token_suffix = f"{int(time.time() * 1000)}" + token_a = f"cmdp-window-a-{token_suffix}" + token_b = f"cmdp-window-b-{token_suffix}" + + workspace_a = client.new_workspace(window_id=window_a) + client.rename_workspace(token_a, workspace=workspace_a) + + workspace_b = client.new_workspace(window_id=window_b) + client.rename_workspace(token_b, workspace=workspace_b) + time.sleep(0.25) + + client.focus_window(window_a) + client.activate_app() + time.sleep(0.2) + _set_palette_visible(client, window_a, False) + _set_palette_visible(client, window_b, False) + + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_a), + message="cmd+p did not open palette in window A", + ) + _wait_until( + lambda: str(_palette_results(client, window_a).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode in window A", + ) + + client.simulate_type(token_b) + _wait_until( + lambda: token_b in str(_palette_results(client, window_a).get("query") or "").strip().lower(), + message="switcher query did not update with window B token", + ) + + result_rows = (_palette_results(client, window_a, limit=64).get("results") or []) + target_workspace_command = f"switcher.workspace.{workspace_b.lower()}" + if not any(str((row or {}).get("command_id") or "") == target_workspace_command for row in result_rows): + raise cmuxError( + f"cmd+p switcher in window A did not include workspace from window B " + f"(expected {target_workspace_command}); rows={result_rows[:8]}" + ) + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_a), + message="palette did not close after selecting cross-window switcher row", + ) + _wait_until( + lambda: client.current_workspace().lower() == workspace_b.lower(), + message="Enter on cross-window switcher row did not move to window B workspace", + ) + _wait_until( + lambda: client.current_window().lower() == window_b.lower(), + message="Enter on cross-window switcher row did not focus window B", + ) + + print("PASS: cmd+p switcher includes and navigates to workspaces from other windows") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())