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
5 changes: 3 additions & 2 deletions Sources/AppDelegate+DockSurfaceMove.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,12 @@ extension AppDelegate {
}

if let splitTarget, let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId) {
_ = destinationWorkspace.bonsplitController.splitPane(
_ = destinationWorkspace.splitPaneMovingTab(
resolvedPane,
orientation: splitTarget.orientation,
movingTab: movedTabId,
insertFirst: splitTarget.insertFirst
insertFirst: splitTarget.insertFirst,
focusIntent: focus ? .activateMovedTab : .preserveCurrent
)
}
destinationWorkspace.scheduleTerminalGeometryReconcile()
Expand Down
10 changes: 6 additions & 4 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5074,11 +5074,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if destinationWorkspace.id == sourceWorkspace.id {
if let splitTarget {
guard let sourceTabId = sourceWorkspace.surfaceIdFromPanelId(panelId),
sourceWorkspace.bonsplitController.splitPane(
sourceWorkspace.splitPaneMovingTab(
resolvedTargetPane,
orientation: splitTarget.orientation,
movingTab: sourceTabId,
insertFirst: splitTarget.insertFirst
insertFirst: splitTarget.insertFirst,
focusIntent: focus ? .activateMovedTab : .preserveCurrent
) != nil else {
#if DEBUG
cmuxDebugLog(
Expand Down Expand Up @@ -5167,11 +5168,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let splitStart = ProcessInfo.processInfo.systemUptime
#endif
guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId),
destinationWorkspace.bonsplitController.splitPane(
destinationWorkspace.splitPaneMovingTab(
resolvedTargetPane,
orientation: splitTarget.orientation,
movingTab: movedTabId,
insertFirst: splitTarget.insertFirst
insertFirst: splitTarget.insertFirst,
focusIntent: focus ? .activateMovedTab : .preserveCurrent
) != nil else {
if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) {
rollbackDetachedSurface(
Expand Down
5 changes: 3 additions & 2 deletions Sources/TerminalController+ControlSidebarContext3.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,11 @@ extension TerminalController {
}

let orientation: SplitOrientation = orientationIsHorizontal ? .horizontal : .vertical
guard let newPaneId = tab.bonsplitController.splitPane(
guard let newPaneId = tab.splitPaneMovingTab(
orientation: orientation,
movingTab: bonsplitTabId,
insertFirst: insertFirst
insertFirst: insertFirst,
focusIntent: .preserveCurrent
) else {
return .splitFailed
}
Expand Down
8 changes: 3 additions & 5 deletions Sources/TerminalController+MoveTabToNewWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,11 @@ extension TerminalController {
])
return
}
let previousFocusedPanelId = ws.focusedPanelId
guard let newPaneId = ws.bonsplitController.splitPane(
guard let newPaneId = ws.splitPaneMovingTab(
orientation: orientation,
movingTab: bonsplitTabId,
insertFirst: insertFirst
insertFirst: insertFirst,
focusIntent: focus ? .activateMovedTab : .preserveCurrent
) else {
result = .err(code: "internal_error", message: SurfaceSplitOffMessage.splitPaneFailed, data: nil)
return
Expand All @@ -192,8 +192,6 @@ extension TerminalController {
_ = app.focusMainWindow(windowId: located.windowId)
setActiveTabManager(located.tabManager)
located.tabManager.focusTab(ws.id, surfaceId: surfaceId, suppressFlash: true)
} else if let previousFocusedPanelId, ws.panels[previousFocusedPanelId] != nil {
ws.focusPanel(previousFocusedPanelId)
}
let windowId = located.windowId
result = .ok([
Expand Down
102 changes: 102 additions & 0 deletions Sources/Workspace+MovingTabSplit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import AppKit
import Bonsplit

extension Workspace {
enum MovingTabSplitFocusIntent {
case activateMovedTab
case preserveCurrent
}

/// Moves an existing tab into a new split while making focus intent explicit.
///
/// Bonsplit updates its focused pane synchronously but emits only `didSplitPane`
/// for this operation. The scoped intent lets that delegate callback either
/// commit the moved tab through Workspace's focus owner or restore the previous
/// Bonsplit pane and any responder focus that panel actually owned.
@discardableResult
func splitPaneMovingTab(
_ paneId: PaneID? = nil,
orientation: SplitOrientation,
movingTab tabId: TabID,
insertFirst: Bool,
focusIntent: MovingTabSplitFocusIntent
) -> PaneID? {
let previousIntent = activeMovingTabSplitFocusIntent
let previousFocusedPanelId = focusedPanelId
let previousOwnedFocusIntent = previousFocusedPanelId
.flatMap { panels[$0] }
.flatMap { ownedFocusIntent(for: $0) }
activeMovingTabSplitFocusIntent = focusIntent
defer { activeMovingTabSplitFocusIntent = previousIntent }

guard let newPaneId = bonsplitController.splitPane(
paneId,
orientation: orientation,
movingTab: tabId,
insertFirst: insertFirst
) else {
return nil
}

if case .preserveCurrent = focusIntent,
let previousFocusedPanelId,
let previousPaneId = self.paneId(forPanelId: previousFocusedPanelId),
let previousTabId = surfaceIdFromPanelId(previousFocusedPanelId) {
if bonsplitController.selectedTab(inPane: previousPaneId)?.id != previousTabId {
bonsplitController.selectTab(previousTabId)
}
if bonsplitController.focusedPaneId != previousPaneId {
bonsplitController.focusPane(previousPaneId)
}
if let previousOwnedFocusIntent {
focusPanel(
previousFocusedPanelId,
focusIntent: previousOwnedFocusIntent
)
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

return newPaneId
}

var preservesFocusDuringMovingTabSplit: Bool {
if case .preserveCurrent? = activeMovingTabSplitFocusIntent {
return true
}
return false
}

/// Commits the moved tab while protecting the terminal that owns AppKit focus
/// from the responder churn caused by the following SwiftUI reparent pass.
func activateMovedTabAfterSplit(_ tabId: TabID, inPane paneId: PaneID) {
let previousHostedView = terminalHostedViewOwningFirstResponder()
let terminalFocusPanelId = panelIdFromSurfaceId(tabId).flatMap { panelId in
terminalPanel(for: panelId) == nil ? nil : panelId
}

suppressReparentFocusUntilLayoutFollowUp(
previousHostedView,
reason: "workspace.movingTabSplitReparent",
terminalFocusPanelId: terminalFocusPanelId
)
Comment thread
cursor[bot] marked this conversation as resolved.
applyTabSelection(
tabId: tabId,
inPane: paneId,
previousTerminalHostedView: previousHostedView
)
}

private func ownedFocusIntent(for panel: any Panel) -> PanelFocusIntent? {
guard let window = NSApp.keyWindow,
let firstResponder = window.firstResponder else { return nil }
return panel.ownedFocusIntent(for: firstResponder, in: window)
}

private func terminalHostedViewOwningFirstResponder() -> GhosttySurfaceScrollView? {
guard let responder = (NSApp.keyWindow ?? NSApp.mainWindow)?.firstResponder,
let panelId = responder.cmuxTerminalFocusOwningGhosttyView()?.terminalSurface?.id else {
return nil
}
return terminalPanel(for: panelId)?.hostedView
}
}
5 changes: 3 additions & 2 deletions Sources/Workspace+SurfaceNavigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,12 @@ extension Workspace {
)
} else if let directionalSplit,
let tabId = surfaceIdFromPanelId(panelId),
let newPaneId = bonsplitController.splitPane(
let newPaneId = splitPaneMovingTab(
sourcePaneId,
orientation: directionalSplit.orientation,
movingTab: tabId,
insertFirst: directionalSplit.insertFirst
insertFirst: directionalSplit.insertFirst,
focusIntent: .activateMovedTab
) {
bonsplitController.focusPane(newPaneId)
bonsplitController.selectTab(tabId)
Expand Down
33 changes: 25 additions & 8 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2355,6 +2355,7 @@ final class Workspace: Identifiable, ObservableObject {
get { splitLayout.isProgrammaticSplit }
set { splitLayout.isProgrammaticSplit = newValue }
}
var activeMovingTabSplitFocusIntent: MovingTabSplitFocusIntent?
private var debugStressPreloadSelectionDepth = 0

/// Last terminal panel used as an inheritance source (typically last focused terminal).
Expand Down Expand Up @@ -10550,21 +10551,28 @@ final class Workspace: Identifiable, ObservableObject {

func suppressReparentFocusUntilLayoutFollowUp(
_ hostedView: GhosttySurfaceScrollView?,
reason: String
reason: String,
terminalFocusPanelId: UUID? = nil
) {
guard let hostedView else { return }
hostedView.suppressReparentFocus()
pendingReparentFocusSuppressionViews[ObjectIdentifier(hostedView)] = hostedView
guard hostedView != nil || terminalFocusPanelId != nil else { return }
if let hostedView {
hostedView.suppressReparentFocus()
pendingReparentFocusSuppressionViews[ObjectIdentifier(hostedView)] = hostedView
#if DEBUG
cmuxDebugLog("focus.reparent.suppressPending reason=\(reason) count=\(pendingReparentFocusSuppressionViews.count)")
cmuxDebugLog("focus.reparent.suppressPending reason=\(reason) count=\(pendingReparentFocusSuppressionViews.count)")
#endif
}

guard portalRenderingEnabled else {
clearPendingReparentFocusSuppressions(reason: "\(reason).portalDisabled")
return
}

beginEventDrivenLayoutFollowUp(reason: reason, includeGeometry: true)
beginEventDrivenLayoutFollowUp(
reason: reason,
terminalFocusPanelId: terminalFocusPanelId,
includeGeometry: true
)
}

private func clearPendingReparentFocusSuppressions(reason: String) {
Expand Down Expand Up @@ -12734,7 +12742,8 @@ extension Workspace: BonsplitDelegate {

func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) {
// Mirror bookkeeping restores selection from its transaction snapshot.
guard !remoteTmuxMirrorMutations.suppressesFocusActivation else { return }
guard !remoteTmuxMirrorMutations.suppressesFocusActivation,
!preservesFocusDuringMovingTabSplit else { return }
applyTabSelection(tabId: tab.id, inPane: pane)
}

Expand Down Expand Up @@ -12813,7 +12822,8 @@ extension Workspace: BonsplitDelegate {

func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) {
// Mirror bookkeeping restores pane focus without re-running activation.
guard !remoteTmuxMirrorMutations.suppressesFocusActivation else { return }
guard !remoteTmuxMirrorMutations.suppressesFocusActivation,
!preservesFocusDuringMovingTabSplit else { return }
// When a pane is focused, focus its selected tab's panel
guard let tab = controller.selectedTab(inPane: pane) else { return }
#if DEBUG
Expand Down Expand Up @@ -13044,6 +13054,13 @@ extension Workspace: BonsplitDelegate {
}
normalizePinnedTabs(in: originalPane)
normalizePinnedTabs(in: newPane)
// Moving a tab into a new split does not emit didMoveTab or didSelectTab.
// Interactive moves activate the selected destination through Workspace's
// focus owner; explicit non-focus transactions leave activation untouched.
if !preservesFocusDuringMovingTabSplit,
let movedTab = controller.selectedTab(inPane: newPane) {
activateMovedTabAfterSplit(movedTab.id, inPane: newPane)
}
scheduleTerminalGeometryReconcile()
return
}
Expand Down
Loading
Loading