Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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() }
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private func setupBonsplitTabDragUITestIfNeeded() {
guard !didSetupBonsplitTabDragUITest else { return }
didSetupBonsplitTabDragUITest = true
Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -12822,6 +12919,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
ghosttyGotoSplitRightShortcut = nil
ghosttyGotoSplitUpShortcut = nil
ghosttyGotoSplitDownShortcut = nil
ghosttyGotoSplitPreviousShortcut = nil
ghosttyGotoSplitNextShortcut = nil
return
}

Expand All @@ -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? {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// 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,
Expand Down
23 changes: 23 additions & 0 deletions Sources/Workspace+SurfaceNavigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3342,6 +3343,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
8561B0048561B0048561B004 /* GlobalSearchShortcutSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchShortcutSettingsModelTests.swift; sourceTree = "<group>"; };
3865B0053865B0053865B005 /* GlobalSearchShortcutSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchShortcutSettingsTests.swift; sourceTree = "<group>"; };
8561B0038561B0038561B003 /* GlobalSearchVisiblePopoverShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchVisiblePopoverShortcutTests.swift; sourceTree = "<group>"; };
CC000001A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GotoSplitCycleUITests.swift; sourceTree = "<group>"; };
5B11E5A100000000000000B2 /* GPUSpinner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/GPUSpinner.swift; sourceTree = "<group>"; };
A6AC72010000000000000002 /* GPUSpinnerNSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/GPUSpinnerNSView.swift; sourceTree = "<group>"; };
A6AC72020000000000000002 /* GPUSpinnerStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/GPUSpinnerStyle.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Loading