diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a1228670..4354bf8a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1059,6 +1059,9 @@ final class Workspace: Identifiable, ObservableObject { private var pendingTabSelection: (tabId: TabID, pane: PaneID)? private var isReconcilingFocusState = false private var focusReconcileScheduled = false +#if DEBUG + private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 +#endif private var geometryReconcileScheduled = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? @@ -1087,6 +1090,8 @@ final class Workspace: Identifiable, ObservableObject { private var detachingTabIds: Set = [] private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] + private var activeDetachCloseTransactions: Int = 0 + private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { surfaceIdToPanelId[surfaceId] @@ -2188,6 +2193,8 @@ final class Workspace: Identifiable, ObservableObject { detachingTabIds.insert(tabId) forceCloseTabIds.insert(tabId) + activeDetachCloseTransactions += 1 + defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } guard bonsplitController.closeTab(tabId) else { detachingTabIds.remove(tabId) pendingDetachedSurfaces.removeValue(forKey: tabId) @@ -2633,6 +2640,11 @@ final class Workspace: Identifiable, ObservableObject { /// Reconcile focus/first-responder convergence. /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. private func scheduleFocusReconcile() { +#if DEBUG + if isDetachingCloseTransaction { + debugFocusReconcileScheduledDuringDetachCount += 1 + } +#endif guard !focusReconcileScheduled else { return } focusReconcileScheduled = true DispatchQueue.main.async { [weak self] in @@ -3117,6 +3129,7 @@ extension Workspace: BonsplitDelegate { forceCloseTabIds.remove(tabId) let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId) let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) + let isDetaching = detachingTabIds.remove(tabId) != nil || isDetachingCloseTransaction // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { @@ -3124,7 +3137,9 @@ extension Workspace: BonsplitDelegate { NSLog("[Workspace] didCloseTab: no panelId for tabId") #endif scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetaching { + scheduleFocusReconcile() + } return } @@ -3132,7 +3147,6 @@ extension Workspace: BonsplitDelegate { NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") #endif - let isDetaching = detachingTabIds.remove(tabId) != nil let panel = panels[panelId] if isDetaching, let panel { @@ -3182,7 +3196,6 @@ extension Workspace: BonsplitDelegate { if panels.isEmpty { if isDetaching { scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() return } @@ -3217,7 +3230,9 @@ extension Workspace: BonsplitDelegate { normalizePinnedTabs(in: pane) } scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetaching { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { @@ -3237,7 +3252,9 @@ extension Workspace: BonsplitDelegate { normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { @@ -3259,6 +3276,7 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] + let shouldScheduleFocusReconcile = !isDetachingCloseTransaction if !closedPanelIds.isEmpty { for panelId in closedPanelIds { @@ -3284,13 +3302,15 @@ extension Workspace: BonsplitDelegate { if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { applyTabSelection(tabId: focusedTabId, inPane: focusedPane) - } else { + } else if shouldScheduleFocusReconcile { scheduleFocusReconcile() } } scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if shouldScheduleFocusReconcile { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { @@ -3541,7 +3561,9 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { _ = snapshot scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } } // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups. diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e54d5401..162441d4 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2973,6 +2973,9 @@ final class WorkspacePanelGitBranchTests: XCTestCase { } XCTAssertEqual(workspace.panels.count, 1) +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif guard let detached = workspace.detachSurface(panelId: panelId) else { XCTFail("Expected detach of last surface to succeed") @@ -2987,11 +2990,55 @@ final class WorkspacePanelGitBranchTests: XCTestCase { XCTAssertNil(workspace.surfaceIdFromPanelId(panelId)) XCTAssertEqual(workspace.bonsplitController.tabs(inPane: paneId).count, 0) + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching during cross-workspace moves should not schedule delayed source focus reconciliation" + ) +#endif + let restoredPanelId = workspace.attachDetachedSurface(detached, inPane: paneId, focus: false) XCTAssertEqual(restoredPanelId, panelId) XCTAssertEqual(workspace.panels.count, 1) } + func testDetachSurfaceWithRemainingPanelsSkipsDelayedFocusReconcile() { + let workspace = Workspace() + guard let originalPanelId = workspace.focusedPanelId, + let movedPanel = workspace.newTerminalSplit(from: originalPanelId, orientation: .horizontal) else { + XCTFail("Expected two panels before detach") + return + } + + drainMainQueue() + drainMainQueue() +#if DEBUG + let baselineFocusReconcileDuringDetach = workspace.debugFocusReconcileScheduledDuringDetachCount +#endif + + guard let detached = workspace.detachSurface(panelId: movedPanel.id) else { + XCTFail("Expected detach to succeed") + return + } + + XCTAssertEqual(detached.panelId, movedPanel.id) + XCTAssertEqual(workspace.panels.count, 1, "Expected source workspace to retain only the surviving panel") + XCTAssertNotNil(workspace.panels[originalPanelId], "Expected the original panel to remain after detach") + + drainMainQueue() + drainMainQueue() +#if DEBUG + XCTAssertEqual( + workspace.debugFocusReconcileScheduledDuringDetachCount, + baselineFocusReconcileDuringDetach, + "Detaching into another workspace should not enqueue delayed source focus reconciliation" + ) +#endif + } + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else {