Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Sources/Backport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ struct Backport<Content> {

extension View {
var backport: Backport<Self> { Backport(content: self) }

@ViewBuilder
func safeHelp(_ text: String) -> some View {
if text.isEmpty {
self
} else {
self.help(text)
}
}
}

extension Scene {
Expand Down
4 changes: 4 additions & 0 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down
14 changes: 13 additions & 1 deletion Sources/TerminalWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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] = [:]
Expand Down Expand Up @@ -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()
}
}
Comment on lines +1787 to +1792

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing MainActor.assumeIsolated in DispatchQueue.main.async closure

The DispatchQueue.main.async closure accesses @MainActor-isolated static properties (Self.hasPendingExternalGeometrySyncForAllWindows and Self.portalsByWindowId) without asserting main-actor isolation. The existing installWindowCloseObserverIfNeeded function in the same file already establishes the correct pattern: even when dispatching to .main, the compiler cannot statically prove the closure inherits @MainActor isolation, so MainActor.assumeIsolated { } is required.

Without it, in Swift 6 strict concurrency mode these accesses are unisolated captures of @MainActor-guarded state, which can generate errors. The fix mirrors the existing pattern used at line 1672:

Suggested change
DispatchQueue.main.async {
Self.hasPendingExternalGeometrySyncForAllWindows = false
for portal in Self.portalsByWindowId.values {
portal.synchronizeAllEntriesFromExternalGeometryChange()
}
}
DispatchQueue.main.async {
MainActor.assumeIsolated {
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],
Expand Down
74 changes: 74 additions & 0 deletions cmuxTests/CmuxWebViewKeyEquivalentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Comment on lines +11906 to +11909

@cubic-dev-ai cubic-dev-ai Bot Mar 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This regression test now only checks for a non-nil terminal view, so it can pass without proving hit-testing resolves the bound surface. Assert the returned terminal’s surface ID matches surface.id to keep the test specific.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmuxTests/CmuxWebViewKeyEquivalentTests.swift, line 11906:

<comment>This regression test now only checks for a non-nil terminal view, so it can pass without proving hit-testing resolves the bound surface. Assert the returned terminal’s surface ID matches `surface.id` to keep the test specific.</comment>

<file context>
@@ -11880,20 +11880,31 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
         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"
</file context>
Suggested change
XCTAssertNotNil(
TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
"Initial hit-testing should resolve the portal-hosted terminal at its original window position"
)
XCTAssertEqual(
TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window)?.terminalSurface?.id,
surface.id,
"Initial hit-testing should resolve the portal-hosted terminal at its original window position"
)
Fix with Cubic


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
Expand Down
Loading