diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c0e41b90af1d..40906f0b0629 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -770,6 +770,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var ghosttyGotoSplitRightShortcut: StoredShortcut? var ghosttyGotoSplitUpShortcut: StoredShortcut? var ghosttyGotoSplitDownShortcut: StoredShortcut? + private var ghosttyGotoSplitPreviousShortcut: StoredShortcut? + private var ghosttyGotoSplitNextShortcut: StoredShortcut? private var browserAddressBarFocusedPanelId: UUID? /// Owns the browser omnibar selection-repeat state machine, extracted into /// `CmuxBrowser`. The app delegate is the composition root: it injects @@ -10643,6 +10645,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } guard let tabManager = self.tabManager else { return } + let layout = env["CMUX_UI_TEST_GOTO_SPLIT_LAYOUT"]? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if layout == "three_pane_terminal" { + self.setupThreePaneTerminalLayout(tabManager: tabManager) + return + } + let tab = tabManager.addTab() guard let initialPanelId = tab.focusedPanelId else { self.writeGotoSplitTestData(["setupError": "Missing initial panel id"]) @@ -10678,6 +10688,81 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + /// Create a 3-pane terminal-only layout: one horizontal split (right) and one vertical split (down). + /// Used by `CMUX_UI_TEST_GOTO_SPLIT_LAYOUT=three_pane_terminal`. + /// Focus changes are recorded by `recordGotoSplitCycleMoveIfNeeded` in the Ghostty action handler. + private func setupThreePaneTerminalLayout(tabManager: TabManager) { + let tab = tabManager.addTab() + guard let initialPanelId = tab.focusedPanelId else { + writeGotoSplitTestData(["setupError": "Missing initial panel id"]) + return + } + + // Create horizontal split (right) + guard tabManager.createSplit( + tabId: tab.id, surfaceId: initialPanelId, direction: .right + ) != nil else { + writeGotoSplitTestData(["setupError": "Failed to create horizontal split"]) + return + } + + // Focus back to initial pane, then create vertical split (down) + tab.focusPanel(initialPanelId) + guard tabManager.createSplit( + tabId: tab.id, surfaceId: initialPanelId, direction: .down + ) != nil else { + writeGotoSplitTestData(["setupError": "Failed to create vertical split"]) + return + } + + // Wait for a terminal surface to become first responder before signaling + // setup complete. Ghostty keybinds only fire when GhosttyNSView has focus. + var observer: NSObjectProtocol? + var resolved = false + let deadline = Date().addingTimeInterval(6.0) + + func checkAndSignal() { + guard !resolved else { return } + guard Date() < deadline else { + if let observer { NotificationCenter.default.removeObserver(observer) } + resolved = true + self.writeGotoSplitTestData(["setupError": "Timed out waiting for terminal focus"]) + return + } + guard let focusedPanelId = tab.focusedPanelId, + tab.terminalPanel(for: focusedPanelId) != nil, + let window = NSApp.mainWindow ?? NSApp.keyWindow, + window.firstResponder is NSView else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { checkAndSignal() } + return + } + + if let observer { NotificationCenter.default.removeObserver(observer) } + resolved = true + + let allPaneIds = tab.bonsplitController.allPaneIds.map(\.description) + let focusedPaneId = tab.bonsplitController.focusedPaneId?.description ?? "" + + self.writeGotoSplitTestData([ + "paneCount": String(allPaneIds.count), + "allPaneIds": allPaneIds.joined(separator: ","), + "focusedPaneId": focusedPaneId, + "ghosttyGotoSplitPreviousShortcut": ghosttyGotoSplitPreviousShortcut?.displayString ?? "", + "ghosttyGotoSplitNextShortcut": ghosttyGotoSplitNextShortcut?.displayString ?? "", + "setupComplete": "true", + ]) + } + + observer = NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { _ in checkAndSignal() } + + // Also poll in case the notification already fired before we observed. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { checkAndSignal() } + } + private func setupBonsplitTabDragUITestIfNeeded() { guard !didSetupBonsplitTabDragUITest else { return } didSetupBonsplitTabDragUITest = true @@ -11041,6 +11126,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "ghosttyGotoSplitRightShortcut": ghosttyGotoSplitRightShortcut?.displayString ?? "", "ghosttyGotoSplitUpShortcut": ghosttyGotoSplitUpShortcut?.displayString ?? "", "ghosttyGotoSplitDownShortcut": ghosttyGotoSplitDownShortcut?.displayString ?? "", + "ghosttyGotoSplitPreviousShortcut": ghosttyGotoSplitPreviousShortcut?.displayString ?? "", + "ghosttyGotoSplitNextShortcut": ghosttyGotoSplitNextShortcut?.displayString ?? "", "webViewFocused": "true" ]) if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] == "1" { @@ -11696,6 +11783,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent writeGotoSplitTestData(updates) } + func recordGotoSplitCycleMoveIfNeeded(tabId: UUID, forward: Bool) { + guard isGotoSplitUITestRecordingEnabled() else { return } + guard let tabManager = tabManagerFor(tabId: tabId), + let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + + var updates = gotoSplitFindStateSnapshot(for: workspace) + updates["lastMoveDirection"] = forward ? "next" : "previous" + writeGotoSplitTestData(updates) + } + private func recordGotoSplitSplitIfNeeded(direction: SplitDirection) { guard isGotoSplitUITestRecordingEnabled() else { return } guard let workspace = tabManager?.selectedWorkspace else { return } @@ -12822,6 +12919,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ghosttyGotoSplitRightShortcut = nil ghosttyGotoSplitUpShortcut = nil ghosttyGotoSplitDownShortcut = nil + ghosttyGotoSplitPreviousShortcut = nil + ghosttyGotoSplitNextShortcut = nil return } @@ -12837,6 +12936,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger( ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count)) ) + ghosttyGotoSplitPreviousShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:previous", UInt("goto_split:previous".utf8.count)) + ) + ghosttyGotoSplitNextShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:next", UInt("goto_split:next".utf8.count)) + ) } private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { @@ -13995,6 +14100,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchesGhosttyGotoSplitPreviousShortcut(event) { + cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.cyclePaneFocus(forward: false) +#if DEBUG + if let workspace = tabManager?.selectedWorkspace { + recordGotoSplitCycleMoveIfNeeded(tabId: workspace.id, forward: false) + } +#endif + return true + } + + if matchesGhosttyGotoSplitNextShortcut(event) { + cmuxRememberFindSelectionBeforePanelFocusMove(tabManager: tabManager, window: NSApp.keyWindow); tabManager?.cyclePaneFocus(forward: true) +#if DEBUG + if let workspace = tabManager?.selectedWorkspace { + recordGotoSplitCycleMoveIfNeeded(tabId: workspace.id, forward: true) + } +#endif + return true + } + if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { if performFocusedDockShortcut(.togglePaneZoom, event: event) { return true } let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager @@ -15654,6 +15779,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent shortcut.matches(event: event, layoutCharacterProvider: shortcutLayoutCharacterProvider) } + fileprivate func shouldRouteGhosttyGotoSplitCycleShortcutToTerminal(_ event: NSEvent) -> Bool { + guard event.type == .keyDown else { return false } + return matchesGhosttyGotoSplitPreviousShortcut(event) + || matchesGhosttyGotoSplitNextShortcut(event) + } + + private func matchesGhosttyGotoSplitPreviousShortcut(_ event: NSEvent) -> Bool { + guard let ghosttyGotoSplitPreviousShortcut else { return false } + return matchShortcut(event: event, shortcut: ghosttyGotoSplitPreviousShortcut) + } + + private func matchesGhosttyGotoSplitNextShortcut(_ event: NSEvent) -> Bool { + guard let ghosttyGotoSplitNextShortcut else { return false } + return matchShortcut(event: event, shortcut: ghosttyGotoSplitNextShortcut) + } + private func matchesKeyboardShortcutEvent( _ event: NSEvent, action: KeyboardShortcutSettings.Action, @@ -17490,6 +17631,13 @@ private extension NSWindow { } return false } + if AppDelegate.shared?.shouldRouteGhosttyGotoSplitCycleShortcutToTerminal(event) == true, + firstResponderGhosttyView.performKeyEquivalentAfterMenuMiss(with: event) { +#if DEBUG + cmuxDebugLog(" → terminal goto_split cycle handled before mainMenu") +#endif + return true + } guard let mainMenu = NSApp.mainMenu else { return false } let consumedByMenu = mainMenu.performKeyEquivalent(with: event) #if DEBUG diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a20a7e5aa1d2..bd27aa1094a9 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2515,10 +2515,6 @@ class GhosttyApp { private func focusDirection(from direction: ghostty_action_goto_split_e) -> NavigationDirection? { switch direction { - // For previous/next, we use left/right as a reasonable default - // Bonsplit doesn't have cycle-based navigation - case GHOSTTY_GOTO_SPLIT_PREVIOUS: return .left - case GHOSTTY_GOTO_SPLIT_NEXT: return .right case GHOSTTY_GOTO_SPLIT_UP: return .up case GHOSTTY_GOTO_SPLIT_DOWN: return .down case GHOSTTY_GOTO_SPLIT_LEFT: return .left @@ -2685,9 +2681,25 @@ class GhosttyApp { surfaceView.selectionAccessibilitySignal.request() return true case GHOSTTY_ACTION_GOTO_SPLIT: + let gotoDirection = action.action.goto_split + // Previous/next use cycle-based navigation through all panes in tree order + if gotoDirection == GHOSTTY_GOTO_SPLIT_PREVIOUS || gotoDirection == GHOSTTY_GOTO_SPLIT_NEXT { + guard let tabId = surfaceView.tabId else { return false } + let forward = gotoDirection == GHOSTTY_GOTO_SPLIT_NEXT + return performOnMain { + guard let app = AppDelegate.shared, + let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } + let result = tabManager.cycleSplitFocus(tabId: tabId, forward: forward) +#if DEBUG + app.recordGotoSplitCycleMoveIfNeeded(tabId: tabId, forward: forward) +#endif + return result + } + } + // Directional navigation uses spatial positioning guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, - let direction = focusDirection(from: action.action.goto_split) else { + let direction = focusDirection(from: gotoDirection) else { return false } return performOnMain { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index db105608a16e..ef8da32c599d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3745,6 +3745,13 @@ class TabManager: ObservableObject { tab.moveFocus(direction: direction) } + /// Cycle focus through panes in tree order, wrapping at the ends. + func cyclePaneFocus(forward: Bool) { + guard let selectedTabId, + let tab = tabs.first(where: { $0.id == selectedTabId }) else { return } + tab.cycleFocus(forward: forward) + } + // MARK: - Focus History Navigation (CmuxWorkspaceNavigation) // The back/forward stack, suppression depth, and navigation logic live @@ -3848,6 +3855,13 @@ class TabManager: ObservableObject { return true } + /// Cycle focus to the next or previous pane in tree order, wrapping at the ends. + func cycleSplitFocus(tabId: UUID, forward: Bool) -> Bool { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + tab.cycleFocus(forward: forward) + return true + } + /// Resize split - not directly supported by bonsplit, but we can adjust divider positions func resizeSplit(tabId: UUID, surfaceId: UUID, direction: ResizeDirection, amount: UInt16) -> Bool { guard amount > 0, diff --git a/Sources/Workspace+SurfaceNavigation.swift b/Sources/Workspace+SurfaceNavigation.swift index 0a310fd66f04..047613da1970 100644 --- a/Sources/Workspace+SurfaceNavigation.swift +++ b/Sources/Workspace+SurfaceNavigation.swift @@ -196,6 +196,29 @@ extension Workspace { } } + /// Cycles focus to the next or previous split pane in tree order, wrapping at the ends. + func cycleFocus(forward: Bool) { + let allPaneIds = bonsplitController.allPaneIds + guard allPaneIds.count > 1, + let currentId = bonsplitController.focusedPaneId, + let currentIndex = allPaneIds.firstIndex(of: currentId) else { return } + + if let previousPanelId = focusedPanelId, + let previousPanel = panels[previousPanelId] { + previousPanel.unfocus() + } + + let targetIndex = forward + ? (currentIndex + 1) % allPaneIds.count + : (currentIndex - 1 + allPaneIds.count) % allPaneIds.count + bonsplitController.focusPane(allPaneIds[targetIndex]) + + if let paneId = bonsplitController.focusedPaneId, + let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { + applyTabSelection(tabId: tabId, inPane: paneId) + } + } + /// Moves the selected surface within its focused split or Canvas pane /// without wrapping. @discardableResult diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index a730cae1dc1e..3378377af679 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1017,6 +1017,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 8561A0048561A0048561A004 /* GlobalSearchShortcutSettingsModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561B0048561B0048561B004 /* GlobalSearchShortcutSettingsModelTests.swift */; }; 3865A0053865A0053865A005 /* GlobalSearchShortcutSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0053865B0053865B005 /* GlobalSearchShortcutSettingsTests.swift */; }; 8561A0038561A0038561A003 /* GlobalSearchVisiblePopoverShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561B0038561B0038561B003 /* GlobalSearchVisiblePopoverShortcutTests.swift */; }; + CC000000A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift */; }; 5B11E5A100000000000000B1 /* GPUSpinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B11E5A100000000000000B2 /* GPUSpinner.swift */; }; A6AC72010000000000000001 /* GPUSpinnerNSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC72010000000000000002 /* GPUSpinnerNSView.swift */; }; A6AC72020000000000000001 /* GPUSpinnerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC72020000000000000002 /* GPUSpinnerStyle.swift */; }; @@ -3342,6 +3343,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 8561B0048561B0048561B004 /* GlobalSearchShortcutSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchShortcutSettingsModelTests.swift; sourceTree = ""; }; 3865B0053865B0053865B005 /* GlobalSearchShortcutSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchShortcutSettingsTests.swift; sourceTree = ""; }; 8561B0038561B0038561B003 /* GlobalSearchVisiblePopoverShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchVisiblePopoverShortcutTests.swift; sourceTree = ""; }; + CC000001A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GotoSplitCycleUITests.swift; sourceTree = ""; }; 5B11E5A100000000000000B2 /* GPUSpinner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/GPUSpinner.swift; sourceTree = ""; }; A6AC72010000000000000002 /* GPUSpinnerNSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/GPUSpinnerNSView.swift; sourceTree = ""; }; A6AC72020000000000000002 /* GPUSpinnerStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/GPUSpinnerStyle.swift; sourceTree = ""; }; @@ -4813,6 +4815,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000131A1B2C3D4E5F60719 /* SidebarPullRequestInteractivityUITests.swift */, E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, + CC000001A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift */, D0E0F0B5A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, @@ -9169,6 +9172,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */, FEED0000000000000000F009 /* FeedSidebarUITests.swift in Sources */, D0E0F0B4A1B2C3D4E5F60718 /* FindSelectionShortcutUITests.swift in Sources */, + CC000000A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift in Sources */, C0DE34020000000000000005 /* HelpMenuUITests.swift in Sources */, B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */, E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */, diff --git a/cmuxUITests/GotoSplitCycleUITests.swift b/cmuxUITests/GotoSplitCycleUITests.swift new file mode 100644 index 000000000000..971492e1498d --- /dev/null +++ b/cmuxUITests/GotoSplitCycleUITests.swift @@ -0,0 +1,268 @@ +import XCTest +import Foundation + +/// Tests that goto_split:previous and goto_split:next cycle through ALL panes +/// regardless of split direction (horizontal and vertical), wrapping at the ends. +/// +/// Before the fix, goto_split:previous/next were mapped to directional left/right +/// navigation in Bonsplit, which skipped vertically-split panes and did not wrap. +final class GotoSplitCycleUITests: XCTestCase { + private var dataPath = "" + + override func setUp() { + super.setUp() + continueAfterFailure = false + dataPath = "/tmp/cmux-ui-test-goto-split-cycle-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: dataPath) + } + + // MARK: - Tests + + func testGotoSplitNextCyclesAllPanes() { + // Uses the Ghostty trigger loaded by the app for goto_split:next. + let (app, configCleanup) = launchWithThreePaneLayout() + defer { configCleanup() } + + XCTAssertTrue( + waitForData( + keys: ["setupComplete", "allPaneIds", "focusedPaneId", "ghosttyGotoSplitNextShortcut"], + timeout: 10.0 + ), + "Expected three-pane setup data to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing setup data") + return + } + XCTAssertEqual(setup["paneCount"], "3", "Expected 3 panes") + + let allPaneIds = Set(setup["allPaneIds"]!.split(separator: ",").map(String.init)) + XCTAssertEqual(allPaneIds.count, 3, "Expected 3 distinct pane IDs") + + let startPane = setup["focusedPaneId"]! + XCTAssertTrue(allPaneIds.contains(startPane), "Start pane should be in allPaneIds") + let nextShortcut = setup["ghosttyGotoSplitNextShortcut"] ?? "" + XCTAssertFalse(nextShortcut.isEmpty, "Expected Ghostty goto_split:next shortcut") + + // Send goto_split:next 3 times — should visit all panes and wrap. + var visited = [startPane] + for i in 0..<3 { + typeShortcut(nextShortcut, in: app) + + XCTAssertTrue( + waitForDataMatch(timeout: 3.0) { data in + guard let focused = data["focusedPaneId"], !focused.isEmpty else { return false } + return focused != visited.last + }, + "Focus did not change after goto_split:next #\(i + 1)" + ) + + guard let data = loadData(), let focused = data["focusedPaneId"] else { + XCTFail("Missing focusedPaneId after goto_split:next #\(i + 1)") + return + } + visited.append(focused) + } + + let visitedSet = Set(visited.prefix(3)) + XCTAssertEqual(visitedSet, allPaneIds, "goto_split:next should visit all 3 panes") + XCTAssertEqual(visited[3], visited[0], "goto_split:next should wrap back to start") + } + + func testGotoSplitPreviousCyclesAllPanes() { + // Uses the Ghostty trigger loaded by the app for goto_split:previous. + let (app, configCleanup) = launchWithThreePaneLayout() + defer { configCleanup() } + + XCTAssertTrue( + waitForData( + keys: ["setupComplete", "allPaneIds", "focusedPaneId", "ghosttyGotoSplitPreviousShortcut"], + timeout: 10.0 + ), + "Expected three-pane setup data to be written" + ) + + guard let setup = loadData() else { + XCTFail("Missing setup data") + return + } + XCTAssertEqual(setup["paneCount"], "3", "Expected 3 panes") + + let allPaneIds = Set(setup["allPaneIds"]!.split(separator: ",").map(String.init)) + XCTAssertEqual(allPaneIds.count, 3, "Expected 3 distinct pane IDs") + + let startPane = setup["focusedPaneId"]! + let previousShortcut = setup["ghosttyGotoSplitPreviousShortcut"] ?? "" + XCTAssertFalse(previousShortcut.isEmpty, "Expected Ghostty goto_split:previous shortcut") + + var visited = [startPane] + for i in 0..<3 { + typeShortcut(previousShortcut, in: app) + + XCTAssertTrue( + waitForDataMatch(timeout: 3.0) { data in + guard let focused = data["focusedPaneId"], !focused.isEmpty else { return false } + return focused != visited.last + }, + "Focus did not change after goto_split:previous #\(i + 1)" + ) + + guard let data = loadData(), let focused = data["focusedPaneId"] else { + XCTFail("Missing focusedPaneId after goto_split:previous #\(i + 1)") + return + } + visited.append(focused) + } + + let visitedSet = Set(visited.prefix(3)) + XCTAssertEqual(visitedSet, allPaneIds, "goto_split:previous should visit all 3 panes") + XCTAssertEqual(visited[3], visited[0], "goto_split:previous should wrap back to start") + } + + // MARK: - Launch Helpers + + private func launchWithThreePaneLayout() -> (XCUIApplication, () -> Void) { + let fileManager = FileManager.default + guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + XCTFail("Missing Application Support directory") + return (XCUIApplication(), {}) + } + + let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) + let nativeConfigURL = ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false) + let cmuxConfigURLs = [ + appSupport + .appendingPathComponent("com.cmuxterm.app.debug.goto.split.cycle", isDirectory: true) + .appendingPathComponent("config.ghostty", isDirectory: false), + appSupport + .appendingPathComponent("com.cmuxterm.app", isDirectory: true) + .appendingPathComponent("config.ghostty", isDirectory: false), + ] + let configURLs = [nativeConfigURL] + cmuxConfigURLs + + do { + try fileManager.createDirectory(at: ghosttyDir, withIntermediateDirectories: true) + for url in cmuxConfigURLs { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + } + } catch { + XCTFail("Failed to create Ghostty config dir: \(error)") + return (XCUIApplication(), {}) + } + + let originalConfigData = configURLs.map { url in + (url, try? Data(contentsOf: url)) + } + let cleanup: () -> Void = { + for (url, data) in originalConfigData { + if let data { + try? data.write(to: url, options: .atomic) + } else { + try? fileManager.removeItem(at: url) + } + } + } + + let home = fileManager.homeDirectoryForCurrentUser + let configContents = """ + # cmux goto_split cycle UI test + working-directory = \(home.path) + + """ + + do { + for url in configURLs { + try configContents.write(to: url, atomically: true, encoding: .utf8) + } + } catch { + XCTFail("Failed to write Ghostty config: \(error)") + return (XCUIApplication(), {}) + } + + let app = XCUIApplication() + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_LAYOUT"] = "three_pane_terminal" + app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_USE_GHOSTTY_CONFIG"] = "1" + launchAndEnsureForeground(app) + + return (app, cleanup) + } + + // MARK: - Data Polling + + private func typeShortcut( + _ shortcut: String, + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + var flags: XCUIElement.KeyModifierFlags = [] + if shortcut.contains("⌘") { flags.insert(.command) } + if shortcut.contains("⌃") { flags.insert(.control) } + if shortcut.contains("⌥") { flags.insert(.option) } + if shortcut.contains("⇧") { flags.insert(.shift) } + + let key: String + if shortcut.contains("→") { + key = XCUIKeyboardKey.rightArrow.rawValue + } else if shortcut.contains("←") { + key = XCUIKeyboardKey.leftArrow.rawValue + } else if shortcut.contains("]") { + key = "]" + } else if shortcut.contains("[") { + key = "[" + } else if shortcut.localizedCaseInsensitiveContains("n") { + key = "n" + } else if shortcut.localizedCaseInsensitiveContains("p") { + key = "p" + } else { + XCTFail("Unsupported goto_split shortcut: \(shortcut)", file: file, line: line) + return + } + + app.typeKey(key, modifierFlags: flags) + } + + private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + guard let data = self.loadData() else { return false } + return keys.allSatisfy { data[$0] != nil } + } + } + + private func waitForDataMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { + waitForCondition(timeout: timeout) { + guard let data = self.loadData() else { return false } + return predicate(data) + } + } + + private func loadData() -> [String: String]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else { + return nil + } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: String] + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + + private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) { + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("App activation may fail on headless CI runners", options: options) { + app.launch() + } + + if app.state == .runningForeground { return } + if app.state == .runningBackground { return } + XCTFail("App failed to start. state=\(app.state.rawValue)") + } +}