Keep focus on destination after cross-window surface move
This commit is contained in:
parent
b0b73e8878
commit
bcd024d8f8
2 changed files with 77 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue