diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index dc4fdc850469..2e574a60877a 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -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) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift index cfcb42cdf7dc..22be5a15dc81 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift @@ -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" diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift index d53df1d92546..373912067fe9 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -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, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 594df5f88009..fd22d62a6765 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -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 diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 344e18af7c7d..798a71f5e6bc 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -202977,6 +202977,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": { @@ -203453,6 +203572,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": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f5faddec3e95..76dcb336284a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -783,6 +783,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var ghosttyGotoSplitDownShortcut: StoredShortcut? private var ghosttyGotoSplitPreviousShortcut: StoredShortcut? private var ghosttyGotoSplitNextShortcut: StoredShortcut? + +#if DEBUG + /// Test seam: unit tests can install the mirrored Ghostty + /// goto_split:previous/next triggers without loading a Ghostty config + /// (Ghostty's macOS defaults put them on ⌘[ / ⌘], colliding with the + /// focus-history defaults this dispatch must win). + func debugSetGhosttyGotoSplitCycleShortcuts(previous: StoredShortcut?, next: StoredShortcut?) { + ghosttyGotoSplitPreviousShortcut = previous + ghosttyGotoSplitNextShortcut = next + } +#endif private var browserAddressBarFocusedPanelId: UUID? /// Owns the browser omnibar selection-repeat state machine, extracted into /// `CmuxBrowser`. The app delegate is the composition root: it injects @@ -14036,7 +14047,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchesGhosttyGotoSplitPreviousShortcut(event) { + // Pane focus cycling. `focusPreviousPane` / `focusNextPane` are the + // cmux-owned rebindable entries (default unbound); Ghostty's + // goto_split:previous/next triggers (⌘[ / ⌘] in Ghostty's macOS + // defaults) stay honored as a fallback so terminal-config bindings + // keep working. The Ghostty mirror must yield to a bound Focus + // Back/Forward shortcut: both default to ⌘[ / ⌘], and without the + // yield the mirror consumed the key here, cycling panes within the + // workspace while the focus-history branch below became unreachable + // (the titlebar arrows navigated globally; the shortcut never did). + if matchConfiguredShortcut(event: event, action: .focusPreviousPane) || + (matchesGhosttyGotoSplitPreviousShortcut(event) && + !ghosttyGotoSplitCycleShortcutYieldsToFocusHistory(event)) { let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow) let moved = routedTabs?.cyclePaneFocus(forward: false) ?? false @@ -14052,7 +14074,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchesGhosttyGotoSplitNextShortcut(event) { + if matchConfiguredShortcut(event: event, action: .focusNextPane) || + (matchesGhosttyGotoSplitNextShortcut(event) && + !ghosttyGotoSplitCycleShortcutYieldsToFocusHistory(event)) { let routedTabs = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: routedTabs, window: shortcutRoutingKeyWindow) let moved = routedTabs?.cyclePaneFocus(forward: true) ?? false @@ -15815,10 +15839,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent fileprivate func shouldRouteGhosttyGotoSplitCycleShortcutToTerminal(_ event: NSEvent) -> Bool { guard event.type == .keyDown else { return false } + guard !ghosttyGotoSplitCycleShortcutYieldsToFocusHistory(event) else { return false } return matchesGhosttyGotoSplitPreviousShortcut(event) || matchesGhosttyGotoSplitNextShortcut(event) } + /// Whether an event that matches Ghostty's mirrored goto_split:previous/next + /// trigger belongs to a bound Focus Back/Forward shortcut instead. Ghostty's + /// macOS defaults put goto_split:previous/next on ⌘[ / ⌘], the same keys as + /// cmux's focus-history defaults, and the mirror branches run earlier in the + /// dispatch, so without this yield the configured focus-history shortcut is + /// unreachable. Unbinding Focus Back/Forward hands the keys back to the + /// pane-cycle mirror. + func ghosttyGotoSplitCycleShortcutYieldsToFocusHistory(_ event: NSEvent) -> Bool { + matchConfiguredShortcut(event: event, action: .focusHistoryBack) + || matchConfiguredShortcut(event: event, action: .focusHistoryForward) + } + private func matchesGhosttyGotoSplitPreviousShortcut(_ event: NSEvent) -> Bool { guard let ghosttyGotoSplitPreviousShortcut else { return false } return matchShortcut(event: event, shortcut: ghosttyGotoSplitPreviousShortcut) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f77dc0747ec3..c93feb9eefa1 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -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) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 770d332fdb08..4792d60897dc 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1053,8 +1053,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 */; }; @@ -3599,8 +3601,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0F15A000000000000000002 /* FishShellIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FishShellIntegrationTests.swift; sourceTree = ""; }; F17A5F020000000000000002 /* FocusedPanelFlashShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedPanelFlashShortcutTests.swift; sourceTree = ""; }; F0C05170000000000000001 /* FocusHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistory.swift; sourceTree = ""; }; + FE00AA21C1B2C3D4E5F60718 /* FocusHistoryBracketShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryBracketShortcutRoutingTests.swift; sourceTree = ""; }; C4160A020000000000000002 /* FocusHistoryMenuInvalidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryMenuInvalidator.swift; sourceTree = ""; }; C0DE86540000000000000001 /* FocusHistoryScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryScopeTests.swift; sourceTree = ""; }; + FE00AA11C1B2C3D4E5F60718 /* FocusHistoryShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusHistoryShortcutUITests.swift; sourceTree = ""; }; 5E55400000000000000000D2 /* FocusStealingResponderConformances.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusStealingResponderConformances.swift; sourceTree = ""; }; C0DEFB100000000000000002 /* FocusSurfaceBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSurfaceBroadcaster.swift; sourceTree = ""; }; C0DEFB300000000000000002 /* FocusSurfaceBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSurfaceBroadcasterTests.swift; sourceTree = ""; }; @@ -5260,6 +5264,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 */, @@ -7199,6 +7204,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 */, @@ -10018,6 +10024,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 */, @@ -10324,6 +10331,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 */, diff --git a/cmuxTests/FocusHistoryBracketShortcutRoutingTests.swift b/cmuxTests/FocusHistoryBracketShortcutRoutingTests.swift new file mode 100644 index 000000000000..72c2afb07a67 --- /dev/null +++ b/cmuxTests/FocusHistoryBracketShortcutRoutingTests.swift @@ -0,0 +1,173 @@ +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.debugSetGhosttyGotoSplitCycleShortcuts( + previous: Self.commandBracketShortcut("["), + next: 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.debugSetGhosttyGotoSplitCycleShortcuts( + previous: Self.commandBracketShortcut("["), + next: 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 + )) + } + + static func commandBracketShortcut(_ key: String) -> AppStoredShortcut { + AppStoredShortcut(key: key, command: true, shift: false, option: false, control: false) + } + + @MainActor + 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 + } +} diff --git a/cmuxUITests/FocusHistoryShortcutUITests.swift b/cmuxUITests/FocusHistoryShortcutUITests.swift new file mode 100644 index 000000000000..2dd76317a941 --- /dev/null +++ b/cmuxUITests/FocusHistoryShortcutUITests.swift @@ -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.size)) + _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, ptr, socklen_t(MemoryLayout.size)) + } + + var addr = sockaddr_un() + memset(&addr, 0, MemoryLayout.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...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..`: - `shortcuts.bindings.focusRight` - `shortcuts.bindings.focusUp` - `shortcuts.bindings.focusDown` +- `shortcuts.bindings.focusPreviousPane` +- `shortcuts.bindings.focusNextPane` - `shortcuts.bindings.splitRight` - `shortcuts.bindings.splitDown` - `shortcuts.bindings.toggleSplitZoom` diff --git a/tests_v2/test_focus_history_shortcut_cross_workspace.py b/tests_v2/test_focus_history_shortcut_cross_workspace.py new file mode 100644 index 000000000000..f57ddd3138ab --- /dev/null +++ b/tests_v2/test_focus_history_shortcut_cross_workspace.py @@ -0,0 +1,102 @@ +#!/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-.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 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) + time.sleep(0.15) + + # Visit ws1 -> ws2 -> ws3 so the focus-history stack is deterministic. + for wsid in created: + c.select_workspace(wsid) + time.sleep(0.15) + _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]) + time.sleep(0.3) + 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()) diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 6e2f57cd79c5..0a961486b00d 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -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: "右にブラウザ分割" } }, diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index e6a7a4284345..4148c4f7b0d7 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1661,6 +1661,8 @@ "focusRight", "focusUp", "focusDown", + "focusPreviousPane", + "focusNextPane", "splitRight", "splitDown", "toggleSplitZoom",