Keep focus on destination after cross-window surface move

This commit is contained in:
Lawrence Chen 2026-02-23 19:36:17 -08:00
parent b0b73e8878
commit bcd024d8f8
2 changed files with 77 additions and 8 deletions

View file

@ -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<TabID> = []
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.

View file

@ -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 {