From e76a4b0bddd18eed5db03a4c89f6fa6a51e36a30 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 6 Aug 2026 21:17:21 -0700 Subject: [PATCH 1/2] test: cover drag-to-split cursor focus --- cmux.xcodeproj/project.pbxproj | 4 ++ cmuxTests/WorkspaceDragSplitFocusTests.swift | 75 ++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 cmuxTests/WorkspaceDragSplitFocusTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f726e4cda183..9680921fb45f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2531,6 +2531,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 901000010000000000000001 /* WorkspaceCustomLayoutOrderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901000010000000000000002 /* WorkspaceCustomLayoutOrderTests.swift */; }; 281F3AEA52379AF45C5C1189 /* WorkspaceCustomSidebarPullRequestContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE74DFAC5946F9C2EEDBE577 /* WorkspaceCustomSidebarPullRequestContextTests.swift */; }; E6FA9084A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */; }; + 9504D5F00000000000000001 /* WorkspaceDragSplitFocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9504D5F00000000000000002 /* WorkspaceDragSplitFocusTests.swift */; }; 72DB1838A0F1B710BFB45DC1 /* WorkspaceEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B561A95C6DC21BA9C4B2BCDD /* WorkspaceEnvironmentTests.swift */; }; C0DE36230000000000000001 /* WorkspaceFinderDirectoryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE36230000000000000002 /* WorkspaceFinderDirectoryResolver.swift */; }; F0ACC0DE000000000000000B /* WorkspaceForkAgentConversationAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE000000000000000C /* WorkspaceForkAgentConversationAvailability.swift */; }; @@ -5140,6 +5141,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 901000010000000000000002 /* WorkspaceCustomLayoutOrderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCustomLayoutOrderTests.swift; sourceTree = ""; }; CE74DFAC5946F9C2EEDBE577 /* WorkspaceCustomSidebarPullRequestContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCustomSidebarPullRequestContextTests.swift; sourceTree = ""; }; E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDescriptionUITests.swift; sourceTree = ""; }; + 9504D5F00000000000000002 /* WorkspaceDragSplitFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDragSplitFocusTests.swift; sourceTree = ""; }; B561A95C6DC21BA9C4B2BCDD /* WorkspaceEnvironmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceEnvironmentTests.swift; sourceTree = ""; }; C0DE36230000000000000002 /* WorkspaceFinderDirectoryResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceFinderDirectoryResolver.swift; sourceTree = ""; }; F0ACC0DE000000000000000C /* WorkspaceForkAgentConversationAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceForkAgentConversationAvailability.swift; sourceTree = ""; }; @@ -7618,6 +7620,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE00000000000000000D37 /* WorkspaceCreateIdempotencyTombstoneTests.swift */, D1A700000000000000000009 /* WorkspaceCreateReviewRegressionTests.swift */, D1A700000000000000000007 /* WorkspaceCreateIdempotencyRecoveryTests.swift */, + 9504D5F00000000000000002 /* WorkspaceDragSplitFocusTests.swift */, 71F8ED92A4B55D34BE6A0668 /* WorkspaceSplitStartupCommandTests.swift */, 6342F0C20000000000000001 /* WorkspaceTerminalFocusRecoveryTests.swift */, 6047C0DE6047C0DE60470002 /* WorkspaceTerminalTabWorkingDirectoryTests.swift */, @@ -11022,6 +11025,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E60812C95246E275F29D3C1B /* WorkspaceCreationWorkingDirectorySpawnPolicyTests.swift in Sources */, 901000010000000000000001 /* WorkspaceCustomLayoutOrderTests.swift in Sources */, 281F3AEA52379AF45C5C1189 /* WorkspaceCustomSidebarPullRequestContextTests.swift in Sources */, + 9504D5F00000000000000001 /* WorkspaceDragSplitFocusTests.swift in Sources */, 72DB1838A0F1B710BFB45DC1 /* WorkspaceEnvironmentTests.swift in Sources */, F0ACC0DE0000000000000005 /* WorkspaceForkConversationContextMenuTests.swift in Sources */, C9A57401C9A57401C9A57401 /* WorkspaceGroupMoveToMenuStateTests.swift in Sources */, diff --git a/cmuxTests/WorkspaceDragSplitFocusTests.swift b/cmuxTests/WorkspaceDragSplitFocusTests.swift new file mode 100644 index 000000000000..15de6b3455dd --- /dev/null +++ b/cmuxTests/WorkspaceDragSplitFocusTests.swift @@ -0,0 +1,75 @@ +import AppKit +import Testing +import CmuxTerminal + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct WorkspaceDragSplitFocusSwiftTests { +#if DEBUG + @Test + func movedTerminalBecomesSoleFocusedCursor() throws { + let originalAppDelegate = AppDelegate.shared + AppDelegate.shared = nil + defer { AppDelegate.shared = originalAppDelegate } + + let workspace = Workspace() + let originalPanelId = try #require(workspace.focusedPanelId) + let originalPanel = try #require(workspace.terminalPanel(for: originalPanelId)) + let sourcePane = try #require(workspace.paneId(forPanelId: originalPanelId)) + let movedPanel = try #require( + workspace.newTerminalSurface(inPane: sourcePane, focus: false) + ) + let movedTab = try #require(workspace.surfaceIdFromPanelId(movedPanel.id)) + + let window = makeWindow() + defer { window.orderOut(nil) } + let contentView = try #require(window.contentView, "Expected content view") + + originalPanel.hostedView.frame = contentView.bounds + movedPanel.hostedView.frame = contentView.bounds + contentView.addSubview(originalPanel.hostedView) + contentView.addSubview(movedPanel.hostedView) + originalPanel.hostedView.setVisibleInUI(true) + movedPanel.hostedView.setVisibleInUI(true) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + originalPanel.hostedView.layoutSubtreeIfNeeded() + movedPanel.hostedView.layoutSubtreeIfNeeded() + + workspace.focusPanel(originalPanelId) + #expect(originalPanel.surface.debugDesiredFocusState()) + #expect(!movedPanel.surface.debugDesiredFocusState()) + + let newPane = try #require( + workspace.bonsplitController.splitPane( + sourcePane, + orientation: .vertical, + movingTab: movedTab, + insertFirst: false + ) + ) + + #expect(workspace.bonsplitController.focusedPaneId == newPane) + #expect(workspace.focusedPanelId == movedPanel.id) + #expect(!originalPanel.surface.debugDesiredFocusState()) + #expect(movedPanel.surface.debugDesiredFocusState()) + } +#endif + + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 220), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + } +} From 5f19f1682fd4eabf2b40355cc59bd5f844895c6c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 6 Aug 2026 22:35:30 -0700 Subject: [PATCH 2/2] fix: reconcile focus after drag-to-split --- Sources/AppDelegate+DockSurfaceMove.swift | 5 +- Sources/AppDelegate.swift | 10 +- ...nalController+ControlSidebarContext3.swift | 5 +- ...inalController+MoveTabToNewWorkspace.swift | 8 +- Sources/Workspace+MovingTabSplit.swift | 102 +++++++++ Sources/Workspace+SurfaceNavigation.swift | 5 +- Sources/Workspace.swift | 33 ++- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/WorkspaceDragSplitFocusTests.swift | 207 ++++++++++++++++-- 9 files changed, 336 insertions(+), 43 deletions(-) create mode 100644 Sources/Workspace+MovingTabSplit.swift diff --git a/Sources/AppDelegate+DockSurfaceMove.swift b/Sources/AppDelegate+DockSurfaceMove.swift index 268146e4f08b..7a1c168dfdfc 100644 --- a/Sources/AppDelegate+DockSurfaceMove.swift +++ b/Sources/AppDelegate+DockSurfaceMove.swift @@ -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() diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 16e9dfb48371..10b8baa8fcdd 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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( @@ -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( diff --git a/Sources/TerminalController+ControlSidebarContext3.swift b/Sources/TerminalController+ControlSidebarContext3.swift index e17a31a4b9ca..8815b58f521f 100644 --- a/Sources/TerminalController+ControlSidebarContext3.swift +++ b/Sources/TerminalController+ControlSidebarContext3.swift @@ -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 } diff --git a/Sources/TerminalController+MoveTabToNewWorkspace.swift b/Sources/TerminalController+MoveTabToNewWorkspace.swift index b886b3a221d5..d9ac077d4843 100644 --- a/Sources/TerminalController+MoveTabToNewWorkspace.swift +++ b/Sources/TerminalController+MoveTabToNewWorkspace.swift @@ -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 @@ -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([ diff --git a/Sources/Workspace+MovingTabSplit.swift b/Sources/Workspace+MovingTabSplit.swift new file mode 100644 index 000000000000..e013d833dcd9 --- /dev/null +++ b/Sources/Workspace+MovingTabSplit.swift @@ -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 + ) + } + } + + 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 + ) + 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 + } +} diff --git a/Sources/Workspace+SurfaceNavigation.swift b/Sources/Workspace+SurfaceNavigation.swift index 89a6f21448ff..14f8d06b79b4 100644 --- a/Sources/Workspace+SurfaceNavigation.swift +++ b/Sources/Workspace+SurfaceNavigation.swift @@ -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) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7b58f7bbdfd2..341d4a4a5224 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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). @@ -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) { @@ -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) } @@ -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 @@ -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 } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 9680921fb45f..54f5a44f1bf5 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2478,6 +2478,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources F0ACC0DE0000000000000003 /* Workspace+ForkConversationContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE0000000000000004 /* Workspace+ForkConversationContextMenu.swift */; }; F17F00000000000000000005 /* Workspace+FullWidthTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17F00000000000000000006 /* Workspace+FullWidthTab.swift */; }; C0DE1A050000000000000001 /* Workspace+LayoutCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A050000000000000002 /* Workspace+LayoutCapture.swift */; }; + 9504A0010000000000000001 /* Workspace+MovingTabSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9504A0010000000000000002 /* Workspace+MovingTabSplit.swift */; }; 8B45A4329C234E6FB261E635 /* Workspace+NotificationsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 688C3F45DAA548B3B57DE780 /* Workspace+NotificationsPane.swift */; }; C3744E010000000000000001 /* Workspace+PanelLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */; }; 797800030000000000000001 /* Workspace+PersistentRemotePTYReattach.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797800030000000000000002 /* Workspace+PersistentRemotePTYReattach.swift */; }; @@ -5088,6 +5089,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F0ACC0DE0000000000000004 /* Workspace+ForkConversationContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+ForkConversationContextMenu.swift"; sourceTree = ""; }; F17F00000000000000000006 /* Workspace+FullWidthTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+FullWidthTab.swift"; sourceTree = ""; }; C0DE1A050000000000000002 /* Workspace+LayoutCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+LayoutCapture.swift"; sourceTree = ""; }; + 9504A0010000000000000002 /* Workspace+MovingTabSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+MovingTabSplit.swift"; sourceTree = ""; }; 688C3F45DAA548B3B57DE780 /* Workspace+NotificationsPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+NotificationsPane.swift"; sourceTree = ""; }; C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+PanelLifecycle.swift"; sourceTree = ""; }; 797800030000000000000002 /* Workspace+PersistentRemotePTYReattach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+PersistentRemotePTYReattach.swift"; sourceTree = ""; }; @@ -6078,6 +6080,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 804100000000000000000002 /* TabManager+AdjacentWorkspaceReordering.swift */, E3B7A400000000000000020B /* Workspace+WorkspaceSurfaceTreeReading.swift */, CA52C0170000000000000000 /* Workspace+SurfaceNavigation.swift */, + 9504A0010000000000000002 /* Workspace+MovingTabSplit.swift */, 875200000000000000000004 /* SurfacePaneMovement.swift */, 822900000000000000000002 /* Workspace+TerminalResizeInteraction.swift */, CC2639000000000000000002 /* GotoSplitCycleUITestSupport.swift */, @@ -10070,6 +10073,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F0ACC0DE0000000000000003 /* Workspace+ForkConversationContextMenu.swift in Sources */, F17F00000000000000000005 /* Workspace+FullWidthTab.swift in Sources */, C0DE1A050000000000000001 /* Workspace+LayoutCapture.swift in Sources */, + 9504A0010000000000000001 /* Workspace+MovingTabSplit.swift in Sources */, 8B45A4329C234E6FB261E635 /* Workspace+NotificationsPane.swift in Sources */, C3744E010000000000000001 /* Workspace+PanelLifecycle.swift in Sources */, 797800030000000000000001 /* Workspace+PersistentRemotePTYReattach.swift in Sources */, diff --git a/cmuxTests/WorkspaceDragSplitFocusTests.swift b/cmuxTests/WorkspaceDragSplitFocusTests.swift index 15de6b3455dd..626476d4ddc1 100644 --- a/cmuxTests/WorkspaceDragSplitFocusTests.swift +++ b/cmuxTests/WorkspaceDragSplitFocusTests.swift @@ -1,6 +1,7 @@ import AppKit -import Testing +import Bonsplit import CmuxTerminal +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV @@ -18,7 +19,168 @@ struct WorkspaceDragSplitFocusSwiftTests { AppDelegate.shared = nil defer { AppDelegate.shared = originalAppDelegate } - let workspace = Workspace() + let fixture = try makeFixture() + defer { + fixture.window.orderOut(nil) + fixture.previousKeyWindow?.makeKey() + } + + fixture.workspace.focusPanel(fixture.originalPanel.id) + #expect(fixture.originalPanel.surface.debugDesiredFocusState()) + #expect(!fixture.movedPanel.surface.debugDesiredFocusState()) + #expect(fixture.originalPanel.hostedView.isSurfaceViewFirstResponder()) + + let newPane = try #require( + fixture.workspace.bonsplitController.splitPane( + fixture.sourcePane, + orientation: .vertical, + movingTab: fixture.movedTab, + insertFirst: false + ) + ) + + #expect(fixture.workspace.bonsplitController.focusedPaneId == newPane) + #expect(fixture.workspace.focusedPanelId == fixture.movedPanel.id) + #expect(!fixture.originalPanel.surface.debugDesiredFocusState()) + #expect(fixture.movedPanel.surface.debugDesiredFocusState()) + #expect(fixture.movedPanel.hostedView.isSurfaceViewFirstResponder()) + + fixture.workspace.debugAttemptEventDrivenLayoutFollowUpForTesting() + + #expect(!fixture.originalPanel.hostedView.debugIsSuppressingReparentFocusForTesting()) + #expect(fixture.workspace.bonsplitController.focusedPaneId == newPane) + #expect(fixture.workspace.focusedPanelId == fixture.movedPanel.id) + #expect(!fixture.originalPanel.surface.debugDesiredFocusState()) + #expect(fixture.movedPanel.surface.debugDesiredFocusState()) + #expect(fixture.movedPanel.hostedView.isSurfaceViewFirstResponder()) + } + + @Test + func activatedSharedSplitReassertsFocusWithoutPreviousTerminalResponder() throws { + let originalAppDelegate = AppDelegate.shared + let appDelegate = AppDelegate() + let tabManager = TabManager(autoWelcomeIfNeeded: false) + appDelegate.tabManager = tabManager + AppDelegate.shared = appDelegate + defer { + tabManager.tabs.forEach { $0.teardownAllPanels() } + appDelegate.tabManager = nil + AppDelegate.shared = originalAppDelegate + } + + let workspace = try #require(tabManager.selectedWorkspace) + let fixture = try makeFixture(workspace: workspace) + defer { + fixture.window.orderOut(nil) + fixture.previousKeyWindow?.makeKey() + } + + fixture.workspace.focusPanel(fixture.originalPanel.id) + #expect(fixture.window.makeFirstResponder(fixture.focusSink)) + #expect(!fixture.originalPanel.hostedView.isSurfaceViewFirstResponder()) + #expect(!fixture.movedPanel.hostedView.isSurfaceViewFirstResponder()) + + let newPane = try #require( + fixture.workspace.splitPaneMovingTab( + fixture.sourcePane, + orientation: .vertical, + movingTab: fixture.movedTab, + insertFirst: false, + focusIntent: .activateMovedTab + ) + ) + + #expect(fixture.workspace.bonsplitController.focusedPaneId == newPane) + #expect(fixture.workspace.focusedPanelId == fixture.movedPanel.id) + #expect(fixture.movedPanel.hostedView.isSurfaceViewFirstResponder()) + #expect(!fixture.workspace.debugHasPendingReparentFocusSuppressionsForTesting()) + + // Model the responder loss that SwiftUI reparenting can cause after the + // synchronous split delegate returns, then run its event-driven follow-up. + #expect(fixture.window.makeFirstResponder(fixture.focusSink)) + #expect(!fixture.movedPanel.hostedView.isSurfaceViewFirstResponder()) + + fixture.workspace.debugAttemptEventDrivenLayoutFollowUpForTesting() + + #expect(fixture.workspace.bonsplitController.focusedPaneId == newPane) + #expect(fixture.workspace.focusedPanelId == fixture.movedPanel.id) + #expect(!fixture.originalPanel.surface.debugDesiredFocusState()) + #expect(fixture.movedPanel.surface.debugDesiredFocusState()) + #expect(fixture.movedPanel.hostedView.isSurfaceViewFirstResponder()) + } + + @Test + func nonFocusSplitPreservesCursorAndHibernation() throws { + let originalAppDelegate = AppDelegate.shared + AppDelegate.shared = nil + defer { AppDelegate.shared = originalAppDelegate } + + let fixture = try makeFixture() + defer { + fixture.window.orderOut(nil) + fixture.previousKeyWindow?.makeKey() + } + + fixture.workspace.focusPanel(fixture.originalPanel.id) + #expect(fixture.originalPanel.hostedView.isSurfaceViewFirstResponder()) + let hibernatedAgent = SessionRestorableAgentSnapshot( + kind: .codex, + sessionId: "issue-9504-non-focus-split", + workingDirectory: "/tmp/issue-9504", + launchCommand: AgentLaunchCommandSnapshot( + launcher: "codex", + executablePath: "/usr/local/bin/codex", + arguments: ["/usr/local/bin/codex"], + workingDirectory: "/tmp/issue-9504", + environment: nil, + capturedAt: nil, + source: nil + ) + ) + #expect( + fixture.workspace.enterAgentHibernation( + panelId: fixture.movedPanel.id, + agent: hibernatedAgent, + lastActivityAt: Date(timeIntervalSince1970: 0) + ) + ) + + let newPane = try #require( + fixture.workspace.splitPaneMovingTab( + fixture.sourcePane, + orientation: .vertical, + movingTab: fixture.movedTab, + insertFirst: false, + focusIntent: .preserveCurrent + ) + ) + + #expect( + fixture.workspace.bonsplitController.tabs(inPane: newPane) + .contains { $0.id == fixture.movedTab } + ) + #expect(fixture.workspace.bonsplitController.focusedPaneId == fixture.sourcePane) + #expect(fixture.workspace.focusedPanelId == fixture.originalPanel.id) + #expect(fixture.originalPanel.surface.debugDesiredFocusState()) + #expect(!fixture.movedPanel.surface.debugDesiredFocusState()) + #expect(fixture.movedPanel.isAgentHibernated) + #expect(fixture.originalPanel.hostedView.isSurfaceViewFirstResponder()) + } +#endif + + private struct Fixture { + let workspace: Workspace + let originalPanel: TerminalPanel + let movedPanel: TerminalPanel + let sourcePane: PaneID + let movedTab: TabID + let window: NSWindow + let previousKeyWindow: NSWindow? + let focusSink: FocusSinkView + } + + private func makeFixture(workspace: Workspace? = nil) throws -> Fixture { + let workspace = workspace ?? Workspace() let originalPanelId = try #require(workspace.focusedPanelId) let originalPanel = try #require(workspace.terminalPanel(for: originalPanelId)) let sourcePane = try #require(workspace.paneId(forPanelId: originalPanelId)) @@ -26,15 +188,23 @@ struct WorkspaceDragSplitFocusSwiftTests { workspace.newTerminalSurface(inPane: sourcePane, focus: false) ) let movedTab = try #require(workspace.surfaceIdFromPanelId(movedPanel.id)) - + let previousKeyWindow = NSApp.keyWindow let window = makeWindow() - defer { window.orderOut(nil) } + var didBuildFixture = false + defer { + if !didBuildFixture { + window.orderOut(nil) + previousKeyWindow?.makeKey() + } + } let contentView = try #require(window.contentView, "Expected content view") + let focusSink = FocusSinkView(frame: .zero) originalPanel.hostedView.frame = contentView.bounds movedPanel.hostedView.frame = contentView.bounds contentView.addSubview(originalPanel.hostedView) contentView.addSubview(movedPanel.hostedView) + contentView.addSubview(focusSink) originalPanel.hostedView.setVisibleInUI(true) movedPanel.hostedView.setVisibleInUI(true) @@ -44,25 +214,22 @@ struct WorkspaceDragSplitFocusSwiftTests { originalPanel.hostedView.layoutSubtreeIfNeeded() movedPanel.hostedView.layoutSubtreeIfNeeded() - workspace.focusPanel(originalPanelId) - #expect(originalPanel.surface.debugDesiredFocusState()) - #expect(!movedPanel.surface.debugDesiredFocusState()) - - let newPane = try #require( - workspace.bonsplitController.splitPane( - sourcePane, - orientation: .vertical, - movingTab: movedTab, - insertFirst: false - ) + didBuildFixture = true + return Fixture( + workspace: workspace, + originalPanel: originalPanel, + movedPanel: movedPanel, + sourcePane: sourcePane, + movedTab: movedTab, + window: window, + previousKeyWindow: previousKeyWindow, + focusSink: focusSink ) + } - #expect(workspace.bonsplitController.focusedPaneId == newPane) - #expect(workspace.focusedPanelId == movedPanel.id) - #expect(!originalPanel.surface.debugDesiredFocusState()) - #expect(movedPanel.surface.debugDesiredFocusState()) + private final class FocusSinkView: NSView { + override var acceptsFirstResponder: Bool { true } } -#endif private func makeWindow() -> NSWindow { NSWindow(