Show Cmd+P switcher results across all windows
This commit is contained in:
parent
7b9f247aa8
commit
1e8859f922
2 changed files with 295 additions and 61 deletions
|
|
@ -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<UUID> = []
|
||||
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]
|
||||
|
|
|
|||
128
tests_v2/test_command_palette_switcher_all_windows.py
Normal file
128
tests_v2/test_command_palette_switcher_all_windows.py
Normal file
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue