From ddf31591c970eec458e3fb7ea7fb5d7f32599dea Mon Sep 17 00:00:00 2001 From: defia Date: Mon, 20 Jul 2026 18:17:07 +0800 Subject: [PATCH] fix: prevent stale surface repins after workspace switches --- Glint/Pane/PaneSurfaceRepresentable.swift | 22 ++++++++++++++++++--- Glint/Pane/PaneView.swift | 7 ++++++- GlintTests/PerformanceRegressionTests.swift | 13 ++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Glint/Pane/PaneSurfaceRepresentable.swift b/Glint/Pane/PaneSurfaceRepresentable.swift index 0e82bc6..183e392 100644 --- a/Glint/Pane/PaneSurfaceRepresentable.swift +++ b/Glint/Pane/PaneSurfaceRepresentable.swift @@ -1,6 +1,14 @@ import SwiftUI import AppKit +enum SurfaceReassertionPolicy { + static func shouldReassert(containerIsAttached: Bool, + expectedSurfaceMatches: Bool, + paneIsVisible: Bool) -> Bool { + containerIsAttached && expectedSurfaceMatches && paneIsVisible + } +} + /// Hosts a stable, store-owned `GhosttySurfaceView` inside a fresh container /// NSView. SwiftUI may rebuild the container any time the split tree reshapes; /// the surface itself outlives that and just re-parents. @@ -15,6 +23,9 @@ struct PaneSurfaceRepresentable: NSViewRepresentable { /// updateNSView re-runs ~1/s, and re-grabbing the terminal surface here /// races the palette's search field and yanks focus back off it. let deferFocus: Bool + /// Evaluated inside the deferred re-pin, not when SwiftUI builds the view: + /// workspace/tab selection may change before that callback runs. + let isPaneVisible: () -> Bool func makeNSView(context: Context) -> NoDragContainerView { let container = NoDragContainerView() @@ -131,10 +142,15 @@ struct PaneSurfaceRepresentable: NSViewRepresentable { // re-parenting the surface into a container that's torn down moments // later, leaving the live pane blank. Containers that survive the // commit re-assert their claim right after it; dismantled ones are out - // of the window by then and bail. + // of the window by then and bail. A recycled container can still be in + // the window after a workspace/tab switch, so also verify that this + // surface's pane is the one currently visible. DispatchQueue.main.async { - guard container.window != nil, - container.expectedSurface === surface else { return } + guard SurfaceReassertionPolicy.shouldReassert( + containerIsAttached: container.window != nil, + expectedSurfaceMatches: container.expectedSurface === surface, + paneIsVisible: isPaneVisible() + ) else { return } Self.pin(surface, in: container) } } diff --git a/Glint/Pane/PaneView.swift b/Glint/Pane/PaneView.swift index 36aa12d..30ef3bb 100644 --- a/Glint/Pane/PaneView.swift +++ b/Glint/Pane/PaneView.swift @@ -57,7 +57,12 @@ struct PaneView: View { PaneSurfaceRepresentable( surfaceView: store.surfaceView(workspaceID: workspaceID, paneID: paneID, cwd: cwd), focused: isFocused, - deferFocus: store.commandPaletteOpen || store.agentChooserIntent != nil + deferFocus: store.commandPaletteOpen || store.agentChooserIntent != nil, + isPaneVisible: { + guard store.selectedWorkspaceID == workspaceID, + let tab = store.selectedWorkspace?.selectedTab else { return false } + return tab.root.leaves.contains(paneID) + } ) if !isFocused { // Use a black wash so translucent panes stay translucent; tune diff --git a/GlintTests/PerformanceRegressionTests.swift b/GlintTests/PerformanceRegressionTests.swift index 5f01f8f..ec2f35d 100644 --- a/GlintTests/PerformanceRegressionTests.swift +++ b/GlintTests/PerformanceRegressionTests.swift @@ -145,6 +145,19 @@ final class PerformanceRegressionTests: XCTestCase { )) } + func testDelayedSurfaceReassertRejectsPaneAfterWorkspaceSwitch() { + XCTAssertTrue(SurfaceReassertionPolicy.shouldReassert( + containerIsAttached: true, + expectedSurfaceMatches: true, + paneIsVisible: true + )) + XCTAssertFalse(SurfaceReassertionPolicy.shouldReassert( + containerIsAttached: true, + expectedSurfaceMatches: true, + paneIsVisible: false + )) + } + func testBackgroundWorkspaceFirstResponderDoesNotPauseIdleClock() { XCTAssertTrue(TerminalFocusPolicy.protectsFromIdleOfflining( appIsActive: true,