From 8ec410fa796c96f8e35e4717017e21c42312da1a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:19:30 -0700 Subject: [PATCH 1/4] Add regression test for portal ancestor shifts --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3d5501b179cd..fd5b62617ef3 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -11861,6 +11861,69 @@ 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: 320, height: 220)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 220, height: 150)) + shiftedContainer.addSubview(anchor) + + let terminal = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + let hosted = GhosttySurfaceScrollView(surfaceView: terminal) + TerminalWindowPortalRegistry.bind(hostedView: hosted, to: anchor, visibleInUI: true) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + XCTAssertTrue( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window) === terminal, + "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" + ) + XCTAssertTrue( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window) === terminal, + "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" + ) + XCTAssertTrue( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window) === terminal, + "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" + ) + } } @MainActor From 1ddda0e68f4ffbef2dacfc236146dc6c857f43bb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:19:34 -0700 Subject: [PATCH 2/4] Resync terminal portals after sidebar changes --- Sources/ContentView.swift | 4 ++++ Sources/TerminalWindowPortal.swift | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3dfbd0c8aa70..4f3c0725cbc3 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 a0b890f00dd0..b44fbffb2c40 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], From 2e2b4e1f8184282630e1ac9487345092623f4577 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:28:53 -0700 Subject: [PATCH 3/4] Restore safeHelp view helper --- Sources/Backport.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/Backport.swift b/Sources/Backport.swift index d1bb5461f1b4..b6a1ec3b4401 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 { From 2f847cc80d6b403fd705e6ef2a5329630bb36af9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:39:07 -0700 Subject: [PATCH 4/4] Fix portal geometry regression test harness --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index fd5b62617ef3..902d085b4cf6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -11880,20 +11880,31 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { return } - let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 320, height: 220)) + 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: 220, height: 150)) + let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56)) shiftedContainer.addSubview(anchor) - let terminal = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) - let hosted = GhosttySurfaceScrollView(surfaceView: terminal) - TerminalWindowPortalRegistry.bind(hostedView: hosted, to: anchor, visibleInUI: true) + 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) - XCTAssertTrue( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window) === terminal, + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), "Initial hit-testing should resolve the portal-hosted terminal at its original window position" ) @@ -11907,8 +11918,8 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs" ) - XCTAssertTrue( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window) === terminal, + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), "Before the external geometry sync, hit-testing should still point at the stale portal location" ) @@ -11919,8 +11930,8 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), "The stale portal position should be cleared after the scheduled external geometry sync" ) - XCTAssertTrue( - TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window) === terminal, + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" ) }