diff --git a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift index ef2eb6f4..d982f397 100644 --- a/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift +++ b/GhosttyTabsTests/CmuxWebViewKeyEquivalentTests.swift @@ -1787,3 +1787,57 @@ final class WindowTerminalHostViewTests: XCTestCase { XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100))) } } + +@MainActor +final class TerminalWindowPortalLifecycleTests: XCTestCase { + func testRegistryPrunesPortalWhenWindowCloses() { + let baseline = TerminalWindowPortalRegistry.debugPortalCount() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + _ = TerminalWindowPortalRegistry.viewAtWindowPoint(NSPoint(x: 1, y: 1), in: window) + XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline + 1) + + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline) + } + + func testPruneDeadEntriesDetachesAnchorlessHostedView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let portal = WindowTerminalPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hosted1 = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30)) + ) + + var anchor1: NSView? = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 80)) + contentView.addSubview(anchor1!) + portal.bind(hostedView: hosted1, to: anchor1!, visibleInUI: true) + + anchor1?.removeFromSuperview() + anchor1 = nil + + let hosted2 = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30)) + ) + let anchor2 = NSView(frame: NSRect(x: 180, y: 20, width: 120, height: 80)) + contentView.addSubview(anchor2) + portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true) + + XCTAssertEqual(portal.debugEntryCount(), 1, "Only the live anchored hosted view should remain tracked") + XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView") + } +} diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index aa60e937..f71197e4 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -2,6 +2,7 @@ import AppKit import ObjectiveC private var cmuxWindowTerminalPortalKey: UInt8 = 0 +private var cmuxWindowTerminalPortalCloseObserverKey: UInt8 = 0 final class WindowTerminalHostView: NSView { override var isOpaque: Bool { false } @@ -147,6 +148,7 @@ final class WindowTerminalPortal: NSObject { } func synchronizeHostedViewForAnchor(_ anchorView: NSView) { + pruneDeadEntries() guard let hostedId = hostedByAnchorId[ObjectIdentifier(anchorView)] else { return } synchronizeHostedView(withId: hostedId) } @@ -194,21 +196,18 @@ final class WindowTerminalPortal: NSObject { } private func pruneDeadEntries() { + let currentWindow = window let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in - if entry.hostedView == nil { - if let anchor = entry.anchorView { - hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) - } + guard entry.hostedView != nil else { return hostedId } + guard let anchor = entry.anchorView else { return hostedId } + if anchor.window !== currentWindow || anchor.superview == nil { return hostedId } - if entry.anchorView == nil { - entry.hostedView?.isHidden = true - } return nil } for hostedId in deadHostedIds { - entriesByHostedId.removeValue(forKey: hostedId) + detachHostedView(withId: hostedId) } let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in @@ -217,6 +216,31 @@ final class WindowTerminalPortal: NSObject { hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) } } + func hostedIds() -> Set { + Set(entriesByHostedId.keys) + } + + func tearDown() { + for hostedId in Array(entriesByHostedId.keys) { + detachHostedView(withId: hostedId) + } + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + hostView.removeFromSuperview() + installedContainerView = nil + installedReferenceView = nil + } + +#if DEBUG + func debugEntryCount() -> Int { + entriesByHostedId.count + } + + func debugHostedSubviewCount() -> Int { + hostView.subviews.count + } +#endif + func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? { guard ensureInstalled() else { return nil } let point = hostView.convert(windowPoint, from: nil) @@ -252,15 +276,65 @@ enum TerminalWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { + guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return } + let windowId = ObjectIdentifier(window) + let observer = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak window] _ in + MainActor.assumeIsolated { + if let window { + removePortal(for: window) + } else { + removePortal(windowId: windowId, window: nil) + } + } + } + objc_setAssociatedObject( + window, + &cmuxWindowTerminalPortalCloseObserverKey, + observer, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + private static func removePortal(for window: NSWindow) { + removePortal(windowId: ObjectIdentifier(window), window: window) + } + + private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) { + if let portal = portalsByWindowId.removeValue(forKey: windowId) { + portal.tearDown() + } + hostedToWindowId = hostedToWindowId.filter { $0.value != windowId } + + guard let window else { return } + if let observer = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) { + NotificationCenter.default.removeObserver(observer) + } + objc_setAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, nil, .OBJC_ASSOCIATION_RETAIN) + } + + private static func pruneHostedMappings(for windowId: ObjectIdentifier, validHostedIds: Set) { + hostedToWindowId = hostedToWindowId.filter { hostedId, mappedWindowId in + mappedWindowId != windowId || validHostedIds.contains(hostedId) + } + } + private static func portal(for window: NSWindow) -> WindowTerminalPortal { if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal { portalsByWindowId[ObjectIdentifier(window)] = existing + installWindowCloseObserverIfNeeded(for: window) return existing } let portal = WindowTerminalPortal(window: window) objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN) portalsByWindowId[ObjectIdentifier(window)] = portal + installWindowCloseObserverIfNeeded(for: window) return portal } @@ -278,6 +352,7 @@ enum TerminalWindowPortalRegistry { nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI) hostedToWindowId[hostedId] = windowId + pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds()) } static func synchronizeForAnchor(_ anchorView: NSView) { @@ -295,4 +370,10 @@ enum TerminalWindowPortalRegistry { let portal = portal(for: window) return portal.terminalViewAtWindowPoint(windowPoint) } + +#if DEBUG + static func debugPortalCount() -> Int { + portalsByWindowId.count + } +#endif }