Merge pull request #9329 from manaflow-ai/feat-cmd-bracket-workspace-history-2
Cmd+[ / Cmd+] traverse global workspace focus history; pane cycling becomes rebindable
This commit is contained in:
@@ -89,6 +89,8 @@ extension ShortcutAction {
|
||||
case .focusRight: return ShortcutStroke(key: "→", command: true, option: true)
|
||||
case .focusUp: return ShortcutStroke(key: "↑", command: true, option: true)
|
||||
case .focusDown: return ShortcutStroke(key: "↓", command: true, option: true)
|
||||
case .focusPreviousPane: return nil
|
||||
case .focusNextPane: return nil
|
||||
case .splitRight: return ShortcutStroke(key: "d", command: true)
|
||||
case .splitDown: return ShortcutStroke(key: "d", command: true, shift: true)
|
||||
case .toggleSplitZoom: return ShortcutStroke(key: "\r", command: true, shift: true)
|
||||
|
||||
+4
@@ -93,6 +93,10 @@ extension ShortcutAction {
|
||||
case .focusRight: return "Focus Pane Right"
|
||||
case .focusUp: return "Focus Pane Up"
|
||||
case .focusDown: return "Focus Pane Down"
|
||||
case .focusPreviousPane:
|
||||
return String(localized: "shortcut.focusPreviousPane.label", defaultValue: "Focus Previous Pane")
|
||||
case .focusNextPane:
|
||||
return String(localized: "shortcut.focusNextPane.label", defaultValue: "Focus Next Pane")
|
||||
case .splitRight: return "Split Right"
|
||||
case .splitDown: return "Split Down"
|
||||
case .toggleSplitZoom: return "Toggle Pane Zoom"
|
||||
|
||||
@@ -28,7 +28,8 @@ extension ShortcutAction {
|
||||
.cycleTextBoxSubmitAction, .attachTextBoxFile, .sendCtrlFToTerminal,
|
||||
.clearScreenKeepScrollback:
|
||||
return .navigation
|
||||
case .focusLeft, .focusRight, .focusUp, .focusDown, .splitRight, .splitDown,
|
||||
case .focusLeft, .focusRight, .focusUp, .focusDown,
|
||||
.focusPreviousPane, .focusNextPane, .splitRight, .splitDown,
|
||||
.toggleSplitZoom, .increaseWorkspaceTerminalFontSize,
|
||||
.decreaseWorkspaceTerminalFontSize, .resetWorkspaceTerminalFontSize,
|
||||
.equalizeSplits, .splitBrowserRight, .splitBrowserDown,
|
||||
|
||||
@@ -106,6 +106,8 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod
|
||||
case focusRight
|
||||
case focusUp
|
||||
case focusDown
|
||||
case focusPreviousPane
|
||||
case focusNextPane
|
||||
case splitRight
|
||||
case splitDown
|
||||
case toggleSplitZoom
|
||||
|
||||
@@ -204427,6 +204427,125 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortcut.focusNextPane.label": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "التركيز على اللوحة التالية"
|
||||
}
|
||||
},
|
||||
"bs": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fokusiraj sljedeći panel"
|
||||
}
|
||||
},
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fokuser næste panel"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nächsten Bereich fokussieren"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Focus Next Pane"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Enfocar panel siguiente"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Activer le panneau suivant"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sposta focus pannello successivo"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "次のペインにフォーカス"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "다음 패널로 포커스"
|
||||
}
|
||||
},
|
||||
"nb": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fokuser neste panel"
|
||||
}
|
||||
},
|
||||
"pl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fokus na następny panel"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Focar Próximo Painel"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Фокус на следующую панель"
|
||||
}
|
||||
},
|
||||
"th": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "โฟกัสบานหน้าต่างถัดไป"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sonraki Bölmeye Odaklan"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Фокус на наступну панель"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "聚焦下一个面板"
|
||||
}
|
||||
},
|
||||
"zh-Hant": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "聚焦下一個面板"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortcut.focusPaneDown.label": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
@@ -204903,6 +205022,125 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortcut.focusPreviousPane.label": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "التركيز على اللوحة السابقة"
|
||||
}
|
||||
},
|
||||
"bs": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fokusiraj prethodni panel"
|
||||
}
|
||||
},
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fokuser forrige panel"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Vorherigen Bereich fokussieren"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Focus Previous Pane"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Enfocar panel anterior"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Activer le panneau précédent"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sposta focus pannello precedente"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "前のペインにフォーカス"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "이전 패널로 포커스"
|
||||
}
|
||||
},
|
||||
"nb": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fokuser forrige panel"
|
||||
}
|
||||
},
|
||||
"pl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Fokus na poprzedni panel"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Focar Painel Anterior"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Фокус на предыдущую панель"
|
||||
}
|
||||
},
|
||||
"th": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "โฟกัสบานหน้าต่างก่อนหน้า"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Önceki Bölmeye Odaklan"
|
||||
}
|
||||
},
|
||||
"uk": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Фокус на попередню панель"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "聚焦上一个面板"
|
||||
}
|
||||
},
|
||||
"zh-Hant": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "聚焦上一個面板"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortcut.focusRightSidebar.label": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
||||
@@ -14199,7 +14199,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||
return true
|
||||
}
|
||||
|
||||
if matchesGhosttyGotoSplitFallback(event: event, route: .previous) {
|
||||
// Pane focus cycling. `focusPreviousPane` / `focusNextPane` are the
|
||||
// cmux-owned rebindable entries (default unbound). Ghostty's imported
|
||||
// goto_split:previous/next triggers (⌘[ / ⌘] in Ghostty's macOS
|
||||
// defaults) remain compatibility fallbacks that yield to any live
|
||||
// configured shortcut (matchesGhosttyGotoSplitFallback), so a bound
|
||||
// Focus Back/Forward keeps ⌘[ / ⌘] on global focus history.
|
||||
if matchConfiguredShortcut(event: event, action: .focusPreviousPane) ||
|
||||
matchesGhosttyGotoSplitFallback(event: event, route: .previous) {
|
||||
let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager
|
||||
cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow)
|
||||
let moved = routedTabs?.cyclePaneFocus(forward: false) ?? false
|
||||
@@ -14215,7 +14222,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||
return true
|
||||
}
|
||||
|
||||
if matchesGhosttyGotoSplitFallback(event: event, route: .next) {
|
||||
if matchConfiguredShortcut(event: event, action: .focusNextPane) ||
|
||||
matchesGhosttyGotoSplitFallback(event: event, route: .next) {
|
||||
let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager
|
||||
cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow)
|
||||
let moved = routedTabs?.cyclePaneFocus(forward: true) ?? false
|
||||
|
||||
@@ -135,6 +135,8 @@ enum KeyboardShortcutSettings {
|
||||
case focusRight
|
||||
case focusUp
|
||||
case focusDown
|
||||
case focusPreviousPane
|
||||
case focusNextPane
|
||||
case splitRight
|
||||
case splitDown, toggleSplitZoom
|
||||
case increaseWorkspaceTerminalFontSize
|
||||
@@ -281,6 +283,8 @@ enum KeyboardShortcutSettings {
|
||||
case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right")
|
||||
case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up")
|
||||
case .focusDown: return String(localized: "shortcut.focusPaneDown.label", defaultValue: "Focus Pane Down")
|
||||
case .focusPreviousPane: return String(localized: "shortcut.focusPreviousPane.label", defaultValue: "Focus Previous Pane")
|
||||
case .focusNextPane: return String(localized: "shortcut.focusNextPane.label", defaultValue: "Focus Next Pane")
|
||||
case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right")
|
||||
case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down")
|
||||
case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom")
|
||||
@@ -485,6 +489,14 @@ enum KeyboardShortcutSettings {
|
||||
return StoredShortcut(key: "↑", command: true, shift: false, option: true, control: false)
|
||||
case .focusDown:
|
||||
return StoredShortcut(key: "↓", command: true, shift: false, option: true, control: false)
|
||||
// Unbound by default: Ghostty's goto_split:previous/next mirror still
|
||||
// cycles panes on the terminal-config keys when Focus Back/Forward do
|
||||
// not claim them; these entries exist so pane cycling stays rebindable
|
||||
// now that ⌘[ / ⌘] reach global focus history.
|
||||
case .focusPreviousPane:
|
||||
return .unbound
|
||||
case .focusNextPane:
|
||||
return .unbound
|
||||
case .splitRight:
|
||||
return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
|
||||
case .splitDown: return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false)
|
||||
|
||||
@@ -1096,8 +1096,10 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
|
||||
C0F15A000000000000000001 /* FishShellIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0F15A000000000000000002 /* FishShellIntegrationTests.swift */; };
|
||||
F17A5F010000000000000001 /* FocusedPanelFlashShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A5F020000000000000002 /* FocusedPanelFlashShortcutTests.swift */; };
|
||||
F0C05170000000000000002 /* FocusHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C05170000000000000001 /* FocusHistory.swift */; };
|
||||
FE00AA20C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE00AA21C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift */; };
|
||||
C4160A020000000000000001 /* FocusHistoryMenuInvalidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A020000000000000002 /* FocusHistoryMenuInvalidator.swift */; };
|
||||
C0DE86540000000000000002 /* FocusHistoryScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE86540000000000000001 /* FocusHistoryScopeTests.swift */; };
|
||||
FE00AA10C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE00AA11C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift */; };
|
||||
5E55400000000000000000D1 /* FocusStealingResponderConformances.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E55400000000000000000D2 /* FocusStealingResponderConformances.swift */; };
|
||||
C0DEFB100000000000000001 /* FocusSurfaceBroadcaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFB100000000000000002 /* FocusSurfaceBroadcaster.swift */; };
|
||||
C0DEFB300000000000000001 /* FocusSurfaceBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEFB300000000000000002 /* FocusSurfaceBroadcasterTests.swift */; };
|
||||
@@ -3682,8 +3684,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
|
||||
C0F15A000000000000000002 /* FishShellIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FishShellIntegrationTests.swift; sourceTree = "<group>"; };
|
||||
F17A5F020000000000000002 /* FocusedPanelFlashShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedPanelFlashShortcutTests.swift; sourceTree = "<group>"; };
|
||||
F0C05170000000000000001 /* FocusHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistory.swift; sourceTree = "<group>"; };
|
||||
FE00AA21C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryBracketShortcutRoutingTests.swift; sourceTree = "<group>"; };
|
||||
C4160A020000000000000002 /* FocusHistoryMenuInvalidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryMenuInvalidator.swift; sourceTree = "<group>"; };
|
||||
C0DE86540000000000000001 /* FocusHistoryScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryScopeTests.swift; sourceTree = "<group>"; };
|
||||
FE00AA11C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryShortcutUITests.swift; sourceTree = "<group>"; };
|
||||
5E55400000000000000000D2 /* FocusStealingResponderConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusStealingResponderConformances.swift; sourceTree = "<group>"; };
|
||||
C0DEFB100000000000000002 /* FocusSurfaceBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSurfaceBroadcaster.swift; sourceTree = "<group>"; };
|
||||
C0DEFB300000000000000002 /* FocusSurfaceBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSurfaceBroadcasterTests.swift; sourceTree = "<group>"; };
|
||||
@@ -5372,6 +5376,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
|
||||
E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */,
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
|
||||
CC000001A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift */,
|
||||
FE00AA11C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift */,
|
||||
D0E0F0B5A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift */,
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
|
||||
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
|
||||
@@ -7327,6 +7332,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
|
||||
D77800010000000000000002 /* DockPortalReconcileTests.swift */,
|
||||
8777D0028777D0028777D002 /* DockRuntimeParityTests.swift */,
|
||||
D81390010000000000000002 /* DockShortcutRoutingTests.swift */,
|
||||
FE00AA21C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift */,
|
||||
D86860000000000000000002 /* DockWorkingDirectoryInheritanceTests.swift */,
|
||||
D7054D0C0000000000000002 /* DockTerminalReattachTests.swift */,
|
||||
A9042D0C0000000000000002 /* DockTerminalPointerFocusTests.swift */,
|
||||
@@ -10191,6 +10197,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
|
||||
B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */,
|
||||
FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */,
|
||||
D0E0F0B4A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift in Sources */,
|
||||
FE00AA10C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift in Sources */,
|
||||
CC000000A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift in Sources */,
|
||||
C0DE34020000000000000005 /* HelpMenuUITests.swift in Sources */,
|
||||
B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */,
|
||||
@@ -10498,6 +10505,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
|
||||
C35610000000000000000001 /* FinderFileDropRegressionTests.swift in Sources */,
|
||||
C0F15A000000000000000001 /* FishShellIntegrationTests.swift in Sources */,
|
||||
F17A5F010000000000000001 /* FocusedPanelFlashShortcutTests.swift in Sources */,
|
||||
FE00AA20C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift in Sources */,
|
||||
C0DE86540000000000000002 /* FocusHistoryScopeTests.swift in Sources */,
|
||||
C0DEFB300000000000000001 /* FocusSurfaceBroadcasterTests.swift in Sources */,
|
||||
AA5269A0C0DE0004FACE0004 /* ForeignFirstResponderPolicyTests.swift in Sources */,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import AppKit
|
||||
import CmuxSettings
|
||||
import Testing
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
private typealias AppStoredShortcut = cmux_DEV.StoredShortcut
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
private typealias AppStoredShortcut = cmux.StoredShortcut
|
||||
#endif
|
||||
|
||||
/// Regression: Cmd+[ / Cmd+] must reach the focus-history branch of the
|
||||
/// shortcut dispatch even though the mirrored Ghostty goto_split:previous/next
|
||||
/// triggers sit on the same keys (Ghostty's macOS defaults) and are checked
|
||||
/// earlier. Before the fix the mirror consumed the keys unconditionally, so the
|
||||
/// shortcut cycled panes inside the current workspace while the titlebar arrow
|
||||
/// buttons (same `TabManager.navigateBack()/navigateForward()` model) navigated
|
||||
/// across workspaces.
|
||||
@Suite("Focus history bracket shortcut routing", .serialized)
|
||||
struct FocusHistoryBracketShortcutRoutingTests {
|
||||
@Test("Cmd+[ / Cmd+] navigate workspace focus history despite the Ghostty goto_split mirror")
|
||||
@MainActor
|
||||
func bracketsNavigateWorkspaceHistoryDespiteGotoSplitMirror() async throws {
|
||||
try await AppContextSerialGate.withExclusiveAppContext {
|
||||
try Self.withHarness { harness in
|
||||
// Mirror Ghostty's macOS defaults: goto_split:previous/next on ⌘[ / ⌘].
|
||||
harness.appDelegate.ghosttyGotoSplitPreviousShortcut = Self.commandBracketShortcut("[")
|
||||
harness.appDelegate.ghosttyGotoSplitNextShortcut = Self.commandBracketShortcut("]")
|
||||
|
||||
let firstWorkspace = harness.firstWorkspace
|
||||
let secondWorkspace = harness.tabManager.addWorkspace(select: true, eagerLoadTerminal: false)
|
||||
#expect(harness.tabManager.selectedTabId == secondWorkspace.id)
|
||||
|
||||
#expect(Self.dispatch(Self.commandBracketShortcut("["), in: harness))
|
||||
#expect(
|
||||
harness.tabManager.selectedTabId == firstWorkspace.id,
|
||||
"Cmd+[ must walk focus history back across workspaces, not cycle panes"
|
||||
)
|
||||
|
||||
#expect(Self.dispatch(Self.commandBracketShortcut("]"), in: harness))
|
||||
#expect(
|
||||
harness.tabManager.selectedTabId == secondWorkspace.id,
|
||||
"Cmd+] must walk focus history forward across workspaces"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Unbinding Focus Back/Forward hands the bracket keys back to the goto_split mirror")
|
||||
@MainActor
|
||||
func unboundFocusHistoryYieldsBracketsToGotoSplitMirror() async throws {
|
||||
try await AppContextSerialGate.withExclusiveAppContext {
|
||||
try Self.withHarness { harness in
|
||||
harness.appDelegate.ghosttyGotoSplitPreviousShortcut = Self.commandBracketShortcut("[")
|
||||
harness.appDelegate.ghosttyGotoSplitNextShortcut = Self.commandBracketShortcut("]")
|
||||
|
||||
_ = harness.tabManager.addWorkspace(select: true, eagerLoadTerminal: false)
|
||||
let selectedBefore = harness.tabManager.selectedTabId
|
||||
|
||||
KeyboardShortcutSettings.clearShortcut(for: .focusHistoryBack)
|
||||
KeyboardShortcutSettings.clearShortcut(for: .focusHistoryForward)
|
||||
|
||||
#expect(
|
||||
Self.dispatch(Self.commandBracketShortcut("["), in: harness),
|
||||
"The goto_split mirror should still consume ⌘[ once focus history is unbound"
|
||||
)
|
||||
#expect(
|
||||
harness.tabManager.selectedTabId == selectedBefore,
|
||||
"Pane cycling stays inside the current workspace"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Harness (main-area sibling of DockShortcutRoutingTests.withHarness)
|
||||
|
||||
struct Harness {
|
||||
let appDelegate: AppDelegate
|
||||
let tabManager: TabManager
|
||||
let firstWorkspace: Workspace
|
||||
let window: NSWindow
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func withHarness(_ body: (Harness) throws -> Void) throws {
|
||||
let previousAppDelegate = AppDelegate.shared
|
||||
let previousManager = TerminalController.shared.activeTabManagerForCallerNotification()
|
||||
let originalSettingsFileStore = KeyboardShortcutSettings.installIsolatedTestFileStore(
|
||||
prefix: "cmux-focus-history-bracket-routing"
|
||||
)
|
||||
KeyboardShortcutSettings.resetAll()
|
||||
|
||||
let appDelegate = AppDelegate()
|
||||
let suiteName = "FocusHistoryBracketShortcutRoutingTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
let settings = UserDefaultsSettingsClient(defaults: defaults)
|
||||
let manager = TabManager(autoWelcomeIfNeeded: false, settings: settings)
|
||||
let windowId = UUID()
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(windowId.uuidString)")
|
||||
|
||||
AppDelegate.shared = appDelegate
|
||||
appDelegate.tabManager = manager
|
||||
TerminalController.shared.setActiveTabManager(manager)
|
||||
appDelegate.registerMainWindow(
|
||||
window,
|
||||
windowId: windowId,
|
||||
tabManager: manager,
|
||||
sidebarState: SidebarState(),
|
||||
sidebarSelectionState: SidebarSelectionState(),
|
||||
fileExplorerState: FileExplorerState()
|
||||
)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
let firstWorkspace = try #require(manager.tabs.first)
|
||||
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
KeyboardShortcutSettings.resetAll()
|
||||
KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore
|
||||
TerminalController.shared.setActiveTabManager(previousManager)
|
||||
appDelegate.unregisterMainWindowContextForTesting(windowId: windowId)
|
||||
manager.tabs.forEach { $0.teardownAllPanels() }
|
||||
window.orderOut(nil)
|
||||
window.close()
|
||||
AppDelegate.shared = previousAppDelegate
|
||||
}
|
||||
|
||||
try body(Harness(
|
||||
appDelegate: appDelegate,
|
||||
tabManager: manager,
|
||||
firstWorkspace: firstWorkspace,
|
||||
window: window
|
||||
))
|
||||
}
|
||||
|
||||
fileprivate static func commandBracketShortcut(_ key: String) -> AppStoredShortcut {
|
||||
AppStoredShortcut(key: key, command: true, shift: false, option: false, control: false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
fileprivate static func dispatch(_ shortcut: AppStoredShortcut, in harness: Harness) -> Bool {
|
||||
guard let keyCode = shortcut.firstStroke.resolvedKeyCode() else { return false }
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: shortcut.modifierFlags,
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: harness.window.windowNumber,
|
||||
context: nil,
|
||||
characters: shortcut.key,
|
||||
charactersIgnoringModifiers: shortcut.key,
|
||||
isARepeat: false,
|
||||
keyCode: keyCode
|
||||
) else { return false }
|
||||
#if DEBUG
|
||||
return harness.appDelegate.debugHandleCustomShortcut(event: event)
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
/// Regression coverage for https://github.com/manaflow-ai/cmux issue: Cmd+[ /
|
||||
/// Cmd+] must traverse the GLOBAL workspace focus history (the same
|
||||
/// `TabManager.navigateBack()/navigateForward()` path as the titlebar arrow
|
||||
/// buttons), not cycle panes inside the current workspace.
|
||||
///
|
||||
/// Ghostty's macOS defaults put `goto_split:previous/next` on ⌘[ / ⌘], the same
|
||||
/// keys as cmux's Focus Back/Forward defaults. The app-level dispatch mirrors
|
||||
/// those Ghostty triggers to cycle pane focus and runs that mirror before the
|
||||
/// focus-history branch, so before the fix the mirror consumed ⌘[ / ⌘]
|
||||
/// unconditionally: pressing the keys cycled panes within the workspace (or did
|
||||
/// nothing in a single-pane workspace) while the titlebar arrows navigated
|
||||
/// across workspaces.
|
||||
///
|
||||
/// The tests drive the shortcut through `simulate_shortcut`, which routes
|
||||
/// through `AppDelegate.debugHandleCustomShortcut` — the exact same matcher and
|
||||
/// dispatch order as real keystrokes from the app-level event monitor — so the
|
||||
/// old bug reproduces deterministically on headless CI runners where real
|
||||
/// keystroke foregrounding is flaky.
|
||||
final class FocusHistoryShortcutUITests: XCTestCase {
|
||||
private var socketPath = ""
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
socketPath = "/tmp/cmux-ui-test-focus-history-\(UUID().uuidString).sock"
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCmdBracketWalksWorkspaceFocusHistoryBackAndForward() {
|
||||
let (_, cleanup) = launchIsolatedApp()
|
||||
defer { cleanup() }
|
||||
|
||||
guard let workspaces = createAndVisitWorkspaces(count: 3) else { return }
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForCurrentWorkspace(workspaces[2], timeout: 10.0),
|
||||
"Expected focus on the last created workspace before navigating"
|
||||
)
|
||||
|
||||
// Back: ws3 -> ws2 -> ws1. Before the fix the Ghostty goto_split mirror
|
||||
// consumed ⌘[ and the current workspace never changed.
|
||||
simulateShortcut("cmd+[")
|
||||
XCTAssertTrue(
|
||||
waitForCurrentWorkspace(workspaces[1], timeout: 10.0),
|
||||
"Cmd+[ should navigate focus history back across workspaces (ws3 -> ws2). current=\(currentWorkspace() ?? "nil")"
|
||||
)
|
||||
simulateShortcut("cmd+[")
|
||||
XCTAssertTrue(
|
||||
waitForCurrentWorkspace(workspaces[0], timeout: 10.0),
|
||||
"Second Cmd+[ should continue back across workspaces (ws2 -> ws1). current=\(currentWorkspace() ?? "nil")"
|
||||
)
|
||||
|
||||
// Forward: ws1 -> ws2 -> ws3, proving the forward stack survives.
|
||||
simulateShortcut("cmd+]")
|
||||
XCTAssertTrue(
|
||||
waitForCurrentWorkspace(workspaces[1], timeout: 10.0),
|
||||
"Cmd+] should navigate focus history forward (ws1 -> ws2). current=\(currentWorkspace() ?? "nil")"
|
||||
)
|
||||
simulateShortcut("cmd+]")
|
||||
XCTAssertTrue(
|
||||
waitForCurrentWorkspace(workspaces[2], timeout: 10.0),
|
||||
"Second Cmd+] should continue forward (ws2 -> ws3). current=\(currentWorkspace() ?? "nil")"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdBracketSkipsClosedWorkspacesLikeTheArrowButtons() {
|
||||
let (_, cleanup) = launchIsolatedApp()
|
||||
defer { cleanup() }
|
||||
|
||||
guard let workspaces = createAndVisitWorkspaces(count: 3) else { return }
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForCurrentWorkspace(workspaces[2], timeout: 10.0),
|
||||
"Expected focus on the last created workspace before closing ws2"
|
||||
)
|
||||
|
||||
let closeReply = socketCommand("close_workspace \(workspaces[1])")
|
||||
XCTAssertEqual(closeReply, "OK", "Expected ws2 to close non-interactively, got \(closeReply ?? "nil")")
|
||||
|
||||
// Back from ws3 must skip the closed ws2 and land on ws1, matching the
|
||||
// titlebar arrow buttons' closed-workspace pruning.
|
||||
simulateShortcut("cmd+[")
|
||||
XCTAssertTrue(
|
||||
waitForCurrentWorkspace(workspaces[0], timeout: 10.0),
|
||||
"Cmd+[ should skip the closed workspace and land on ws1. current=\(currentWorkspace() ?? "nil")"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Workspace setup over the control socket
|
||||
|
||||
/// Creates `count` workspaces and visits each in creation order so the
|
||||
/// focus-history stack ends as [start, ws1, ..., wsN] with wsN current.
|
||||
private func createAndVisitWorkspaces(count: Int) -> [String]? {
|
||||
var created: [String] = []
|
||||
for index in 1...count {
|
||||
let reply = socketCommand("new_workspace focus-history-ws\(index)")
|
||||
guard let reply,
|
||||
reply.hasPrefix("OK "),
|
||||
let id = reply.split(separator: " ").last.map(String.init),
|
||||
UUID(uuidString: id) != nil else {
|
||||
XCTFail("new_workspace #\(index) failed: \(reply ?? "nil")")
|
||||
return nil
|
||||
}
|
||||
created.append(id)
|
||||
}
|
||||
for id in created {
|
||||
guard socketCommand("select_workspace \(id)") == "OK" else {
|
||||
XCTFail("select_workspace \(id) failed")
|
||||
return nil
|
||||
}
|
||||
// Selection records focus history synchronously in the
|
||||
// selectedTabId didSet; a short settle keeps ordering deterministic.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.15))
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
private func simulateShortcut(_ combo: String) {
|
||||
let reply = socketCommand("simulate_shortcut \(combo)", responseTimeout: 30.0)
|
||||
XCTAssertEqual(reply, "OK", "simulate_shortcut \(combo) failed: \(reply ?? "nil")")
|
||||
}
|
||||
|
||||
private func currentWorkspace() -> String? {
|
||||
guard let reply = socketCommand("current_workspace"),
|
||||
UUID(uuidString: reply) != nil else { return nil }
|
||||
return reply
|
||||
}
|
||||
|
||||
private func waitForCurrentWorkspace(_ workspaceId: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if currentWorkspace() == workspaceId { return true }
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
return currentWorkspace() == workspaceId
|
||||
}
|
||||
|
||||
// MARK: - Launch
|
||||
|
||||
private func launchIsolatedApp() -> (XCUIApplication, () -> Void) {
|
||||
let fileManager = FileManager.default
|
||||
let isolatedHome = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
"cmux-ui-test-focus-history-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
try? fileManager.createDirectory(at: isolatedHome, withIntermediateDirectories: true)
|
||||
|
||||
let app = XCUIApplication.cmuxTestApplication()
|
||||
// Isolated HOME: no user cmux.json shortcut overrides, no user Ghostty
|
||||
// keybinds, no restored session — the test exercises factory defaults.
|
||||
app.launchEnvironment["HOME"] = isolatedHome.path
|
||||
app.launchEnvironment["CFFIXED_USER_HOME"] = isolatedHome.path
|
||||
app.launchEnvironment["XDG_CONFIG_HOME"] =
|
||||
isolatedHome.appendingPathComponent(".config", isDirectory: true).path
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_TAG"] = "ui-tests-focus-history-\(UUID().uuidString.prefix(8))"
|
||||
// Backgrounded apps on CI runners get App Nap throttled and this test
|
||||
// drives everything through the control socket, which replies after
|
||||
// main-thread hops.
|
||||
app.launchArguments += ["-NSAppSleepDisabled", "YES"]
|
||||
|
||||
// The whole flow runs over the control socket, so a backgrounded app is
|
||||
// fine; launch() itself raises when activation cannot win on headless
|
||||
// CI runners (state stays Running Background), so tolerate that.
|
||||
let launchOptions = XCTExpectedFailure.Options()
|
||||
launchOptions.isStrict = false
|
||||
XCTExpectFailure("App activation may fail on headless CI runners", options: launchOptions) {
|
||||
app.launch()
|
||||
}
|
||||
|
||||
let launched = XCTWaiter().wait(
|
||||
for: [XCTNSPredicateExpectation(
|
||||
predicate: NSPredicate { _, _ in
|
||||
app.state == .runningForeground || app.state == .runningBackground
|
||||
},
|
||||
object: NSObject()
|
||||
)],
|
||||
timeout: 15.0
|
||||
) == .completed
|
||||
XCTAssertTrue(launched, "App failed to start. state=\(app.state.rawValue)")
|
||||
|
||||
let socketReady = waitForControlSocketReady(
|
||||
pingTimeout: 20.0,
|
||||
socketFileExists: { FileManager.default.fileExists(atPath: self.socketPath) },
|
||||
pingReturnsPong: { self.socketCommand("ping") == "PONG" }
|
||||
)
|
||||
XCTAssertTrue(socketReady, "Control socket never answered ping at \(socketPath)")
|
||||
|
||||
// App-side activation (socket main-hop) so synthetic shortcut events
|
||||
// land in a prepared key window even when XCUI activation lost above.
|
||||
// Also proves a main-thread hop completes before the interesting
|
||||
// commands run (first launch on a clean runner can wedge for a while).
|
||||
var mainHopReady = false
|
||||
for _ in 0..<12 where !mainHopReady {
|
||||
mainHopReady = socketCommand("activate_app", responseTimeout: 10.0) == "OK"
|
||||
}
|
||||
XCTAssertTrue(mainHopReady, "Main thread never serviced an activate_app socket hop")
|
||||
|
||||
return (app, {
|
||||
app.terminate()
|
||||
try? fileManager.removeItem(at: isolatedHome)
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Control socket plumbing
|
||||
|
||||
private func socketCommand(_ command: String, responseTimeout: TimeInterval = 10.0) -> String? {
|
||||
ControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(command)
|
||||
}
|
||||
|
||||
private final class ControlSocketClient {
|
||||
private let path: String
|
||||
private let responseTimeout: TimeInterval
|
||||
|
||||
init(path: String, responseTimeout: TimeInterval) {
|
||||
self.path = path
|
||||
self.responseTimeout = responseTimeout
|
||||
}
|
||||
|
||||
func sendLine(_ line: String) -> String? {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return nil }
|
||||
defer { close(fd) }
|
||||
|
||||
var timeout = timeval(
|
||||
tv_sec: Int(responseTimeout),
|
||||
tv_usec: Int32((responseTimeout - floor(responseTimeout)) * 1_000_000)
|
||||
)
|
||||
withUnsafePointer(to: &timeout) { ptr in
|
||||
_ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, ptr, socklen_t(MemoryLayout<timeval>.size))
|
||||
_ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, ptr, socklen_t(MemoryLayout<timeval>.size))
|
||||
}
|
||||
|
||||
var addr = sockaddr_un()
|
||||
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
|
||||
let pathBytes = Array(path.utf8CString)
|
||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
guard pathBytes.count <= maxLen else { return nil }
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self)
|
||||
for index in 0..<pathBytes.count {
|
||||
raw[index] = pathBytes[index]
|
||||
}
|
||||
}
|
||||
|
||||
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
|
||||
let addrLen = socklen_t(pathOffset + pathBytes.count)
|
||||
let connected = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||
Darwin.connect(fd, sockaddrPtr, addrLen)
|
||||
}
|
||||
}
|
||||
guard connected == 0 else { return nil }
|
||||
|
||||
let payload = Array((line + "\n").utf8)
|
||||
let wrote = payload.withUnsafeBytes { rawBuffer in
|
||||
guard let baseAddress = rawBuffer.baseAddress else { return true }
|
||||
return Darwin.write(fd, baseAddress, rawBuffer.count) == rawBuffer.count
|
||||
}
|
||||
guard wrote else { return nil }
|
||||
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
var accumulator = ""
|
||||
let deadline = Date().addingTimeInterval(responseTimeout)
|
||||
while Date() < deadline {
|
||||
let count = Darwin.read(fd, &buffer, buffer.count)
|
||||
guard count > 0 else { 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])
|
||||
}
|
||||
if count < buffer.count {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return accumulator.isEmpty ? nil : accumulator.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,8 @@ Values for `shortcuts.bindings.<action>`:
|
||||
- `shortcuts.bindings.focusRight`
|
||||
- `shortcuts.bindings.focusUp`
|
||||
- `shortcuts.bindings.focusDown`
|
||||
- `shortcuts.bindings.focusPreviousPane`
|
||||
- `shortcuts.bindings.focusNextPane`
|
||||
- `shortcuts.bindings.splitRight`
|
||||
- `shortcuts.bindings.splitDown`
|
||||
- `shortcuts.bindings.toggleSplitZoom`
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression: Cmd+[ / Cmd+] traverse the GLOBAL workspace focus history.
|
||||
|
||||
Ghostty's macOS defaults bind goto_split:previous/next to cmd+[ / cmd+], the
|
||||
same keys as cmux's Focus Back/Forward defaults. The app-level dispatch mirrors
|
||||
those Ghostty triggers to cycle pane focus and used to run that mirror BEFORE
|
||||
the focus-history branch, so cmd+[ / cmd+] cycled panes within the current
|
||||
workspace (or did nothing) while the titlebar arrow buttons navigated across
|
||||
workspaces. The mirror must yield the keys to a bound Focus Back/Forward
|
||||
shortcut.
|
||||
|
||||
Run against a TAGGED build only (never the user's default socket):
|
||||
CMUX_SOCKET_PATH=/tmp/cmux-debug-<tag>.sock python3 tests_v2/test_focus_history_shortcut_cross_workspace.py
|
||||
|
||||
simulate_shortcut routes through AppDelegate.debugHandleCustomShortcut, the
|
||||
same matcher and dispatch order as real keystrokes from the app-level monitor.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _selected_workspace(c: cmux) -> str:
|
||||
for _idx, wsid, _title, selected in c.list_workspaces():
|
||||
if selected:
|
||||
return wsid
|
||||
raise cmuxError("no selected workspace")
|
||||
|
||||
|
||||
def _press_and_wait(c: cmux, combo: str, expected_ws: str, timeout: float = 5.0) -> str:
|
||||
c.simulate_shortcut(combo)
|
||||
deadline = time.time() + timeout
|
||||
current = _selected_workspace(c)
|
||||
while time.time() < deadline:
|
||||
current = _selected_workspace(c)
|
||||
if current == expected_ws:
|
||||
return current
|
||||
time.sleep(0.1)
|
||||
return current
|
||||
|
||||
|
||||
def _wait_selected(c: cmux, wsid: str, timeout: float = 5.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if _selected_workspace(c) == wsid:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise cmuxError(f"workspace {wsid} never became selected")
|
||||
|
||||
|
||||
def _wait_closed(c: cmux, wsid: str, timeout: float = 5.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if all(listed != wsid for _i, listed, _t, _s in c.list_workspaces()):
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise cmuxError(f"workspace {wsid} still listed after close")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
c = cmux()
|
||||
c.connect()
|
||||
_must(c.ping(), "socket ping failed")
|
||||
c.activate_app()
|
||||
time.sleep(0.3)
|
||||
|
||||
created = []
|
||||
for index in range(3):
|
||||
wsid = c.new_workspace()
|
||||
c.select_workspace(wsid)
|
||||
c.rename_workspace(f"fhist-ws{index + 1}", wsid)
|
||||
created.append(wsid)
|
||||
_wait_selected(c, wsid)
|
||||
|
||||
# Visit ws1 -> ws2 -> ws3 so the focus-history stack is deterministic.
|
||||
for wsid in created:
|
||||
c.select_workspace(wsid)
|
||||
_wait_selected(c, wsid)
|
||||
_must(_selected_workspace(c) == created[2], "expected focus on ws3 before navigating")
|
||||
|
||||
# Back across workspaces: ws3 -> ws2 -> ws1.
|
||||
got = _press_and_wait(c, "cmd+[", created[1])
|
||||
_must(got == created[1], f"cmd+[ should land on ws2, got {got}")
|
||||
got = _press_and_wait(c, "cmd+[", created[0])
|
||||
_must(got == created[0], f"second cmd+[ should land on ws1, got {got}")
|
||||
|
||||
# Forward again: ws1 -> ws2 -> ws3.
|
||||
got = _press_and_wait(c, "cmd+]", created[1])
|
||||
_must(got == created[1], f"cmd+] should land on ws2, got {got}")
|
||||
got = _press_and_wait(c, "cmd+]", created[2])
|
||||
_must(got == created[2], f"second cmd+] should land on ws3, got {got}")
|
||||
|
||||
# Closed workspaces are skipped, matching the arrow buttons' pruning.
|
||||
c.close_workspace(created[1])
|
||||
_wait_closed(c, created[1])
|
||||
got = _press_and_wait(c, "cmd+[", created[0])
|
||||
_must(got == created[0], f"cmd+[ should skip closed ws2 and land on ws1, got {got}")
|
||||
|
||||
# Cleanup the workspaces this test created.
|
||||
for wsid in (created[0], created[2]):
|
||||
try:
|
||||
c.close_workspace(wsid)
|
||||
except cmuxError:
|
||||
pass
|
||||
|
||||
print("PASS: cmd+[ / cmd+] traverse global workspace focus history")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -312,6 +312,24 @@ export const shortcutCategories: ShortcutCategory[] = [
|
||||
{ id: "focusRight", combos: [["⌥", "⌘", "→"]], description: { en: "Focus pane right", ja: "右のペインにフォーカス" } },
|
||||
{ id: "focusUp", combos: [["⌥", "⌘", "↑"]], description: { en: "Focus pane up", ja: "上のペインにフォーカス" } },
|
||||
{ id: "focusDown", combos: [["⌥", "⌘", "↓"]], description: { en: "Focus pane down", ja: "下のペインにフォーカス" } },
|
||||
{
|
||||
id: "focusPreviousPane",
|
||||
combos: [],
|
||||
description: { en: "Focus previous pane (cycle)", ja: "前のペインにフォーカス(循環)" },
|
||||
note: {
|
||||
en: "unbound by default; a Ghostty goto_split:previous keybind also cycles panes while Focus Back does not claim the same keys",
|
||||
ja: "デフォルトでは未割り当て。Ghostty の goto_split:previous のキーバインドでも循環できます(Focus Back が同じキーを使っていない場合)",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "focusNextPane",
|
||||
combos: [],
|
||||
description: { en: "Focus next pane (cycle)", ja: "次のペインにフォーカス(循環)" },
|
||||
note: {
|
||||
en: "unbound by default; a Ghostty goto_split:next keybind also cycles panes while Focus Forward does not claim the same keys",
|
||||
ja: "デフォルトでは未割り当て。Ghostty の goto_split:next のキーバインドでも循環できます(Focus Forward が同じキーを使っていない場合)",
|
||||
},
|
||||
},
|
||||
{ id: "splitRight", combos: [["⌘", "D"]], description: { en: "Split right", ja: "右に分割" } },
|
||||
{ id: "splitDown", combos: [["⌘", "⇧", "D"]], description: { en: "Split down", ja: "下に分割" } },
|
||||
{ id: "splitBrowserRight", combos: [["⌥", "⌘", "D"]], description: { en: "Split browser right", ja: "右にブラウザ分割" } },
|
||||
|
||||
@@ -1661,6 +1661,8 @@
|
||||
"focusRight",
|
||||
"focusUp",
|
||||
"focusDown",
|
||||
"focusPreviousPane",
|
||||
"focusNextPane",
|
||||
"splitRight",
|
||||
"splitDown",
|
||||
"toggleSplitZoom",
|
||||
|
||||
Reference in New Issue
Block a user