diff --git a/Sources/Backport.swift b/Sources/Backport.swift index d1bb5461..b6a1ec3b 100644 --- a/Sources/Backport.swift +++ b/Sources/Backport.swift @@ -7,6 +7,15 @@ struct Backport { extension View { var backport: Backport { Backport(content: self) } + + @ViewBuilder + func safeHelp(_ text: String) -> some View { + if text.isEmpty { + self + } else { + self.help(text) + } + } } extension Scene { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3dfbd0c8..4f3c0725 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2591,10 +2591,14 @@ struct ContentView: View { if abs(sidebarState.persistedWidth - sanitized) > 0.5 { sidebarState.persistedWidth = sanitized } + // Sidebar width changes are pure SwiftUI layout updates, so portal-hosted + // terminals need an explicit post-layout geometry resync. + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() updateSidebarResizerBandState() }) view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() updateSidebarResizerBandState() }) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index a0b890f0..b44fbffb 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -724,7 +724,7 @@ final class WindowTerminalPortal: NSObject { return frameInContainer.width > 1 && frameInContainer.height > 1 } - private func synchronizeAllEntriesFromExternalGeometryChange() { + fileprivate func synchronizeAllEntriesFromExternalGeometryChange() { guard ensureInstalled() else { return } synchronizeLayoutHierarchy() synchronizeAllHostedViews(excluding: nil) @@ -1635,6 +1635,7 @@ final class WindowTerminalPortal: NSObject { enum TerminalWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + private static var hasPendingExternalGeometrySyncForAllWindows = false #if DEBUG private static var blockedBindCount: Int = 0 private static var blockedBindReasons: [String: Int] = [:] @@ -1780,6 +1781,17 @@ enum TerminalWindowPortalRegistry { portal.synchronizeHostedViewForAnchor(anchorView) } + static func scheduleExternalGeometrySynchronizeForAllWindows() { + guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } + Self.hasPendingExternalGeometrySyncForAllWindows = true + DispatchQueue.main.async { + Self.hasPendingExternalGeometrySyncForAllWindows = false + for portal in Self.portalsByWindowId.values { + portal.synchronizeAllEntriesFromExternalGeometryChange() + } + } + } + static func hideHostedView(_ hostedView: GhosttySurfaceScrollView) { let hostedId = ObjectIdentifier(hostedView) guard let windowId = hostedToWindowId[hostedId], diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3d5501b1..902d085b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -11861,6 +11861,80 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { portal.synchronizeHostedViewForAnchor(anchor) XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable") } + + func testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 220, height: 160)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56)) + shiftedContainer.addSubview(anchor) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + shiftedContainer.frame.origin.x += 96 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + + let shiftedWindowPoint = anchor.convert(anchorCenter, to: nil) + XCTAssertNotEqual(originalWindowPoint.x, shiftedWindowPoint.x, accuracy: 0.5) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Before the external geometry sync, hit-testing should still point at the stale portal location" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "The stale portal position should be cleared after the scheduled external geometry sync" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" + ) + } } @MainActor