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.