Skip to content
Open
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
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; };
B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; };
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
CC000000A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift */; };
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; };
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
Expand Down Expand Up @@ -294,6 +295,7 @@
B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = "<group>"; };
B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; };
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
CC000001A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GotoSplitCycleUITests.swift; sourceTree = "<group>"; };
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; };
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -570,6 +572,7 @@
C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */,
E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */,
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
CC000001A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift */,
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
Expand Down Expand Up @@ -870,6 +873,7 @@
C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */,
E6FA9084A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift in Sources */,
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
CC000000A1B2C3D4E5F60718 /* GotoSplitCycleUITests.swift in Sources */,
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
Expand Down
86 changes: 86 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8513,6 +8513,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 @@ -8548,6 +8556,75 @@ 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?
let deadline = Date().addingTimeInterval(6.0)

func checkAndSignal() {
guard Date() < deadline else {
if let observer { NotificationCenter.default.removeObserver(observer) }
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) }

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,
"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
Expand Down Expand Up @@ -9483,6 +9560,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
writeGotoSplitTestData(updates)
}

func recordGotoSplitCycleMoveIfNeeded(forward: Bool) {
guard isGotoSplitUITestRecordingEnabled() else { return }
guard let tabManager, let workspace = tabManager.selectedWorkspace 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
22 changes: 17 additions & 5 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2643,10 +2643,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 @@ -2834,9 +2830,25 @@ class GhosttyApp {
}
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(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
7 changes: 7 additions & 0 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4014,6 +4014,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,
Expand Down
29 changes: 29 additions & 0 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10092,6 +10092,35 @@ final class Workspace: Identifiable, ObservableObject {

}

/// Cycle focus to the next or previous pane in tree order, wrapping at the ends.
/// Unlike moveFocus(direction:), this visits all panes regardless of spatial layout.
func cycleFocus(forward: Bool) {
let allPaneIds = bonsplitController.allPaneIds
guard allPaneIds.count > 1,
let currentId = bonsplitController.focusedPaneId,
let currentIndex = allPaneIds.firstIndex(of: currentId) else { return }

// Unfocus the currently-focused panel before navigating.
if let prevPanelId = focusedPanelId, let prev = panels[prevPanelId] {
prev.unfocus()
}

let targetIndex: Int
if forward {
targetIndex = (currentIndex + 1) % allPaneIds.count
} else {
targetIndex = currentIndex == 0 ? allPaneIds.count - 1 : currentIndex - 1
}
bonsplitController.focusPane(allPaneIds[targetIndex])

// Reconcile selection/focus after navigation so AppKit first-responder and
// bonsplit's focused pane stay aligned.
if let paneId = bonsplitController.focusedPaneId,
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
applyTabSelection(tabId: tabId, inPane: paneId)
}
}

// MARK: - Surface Navigation

/// Select the next surface in the currently focused pane
Expand Down
Loading