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
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
A5FF0054 /* GhosttyTerminalView+Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0044 /* GhosttyTerminalView+Accessibility.swift */; };
A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; };
A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; };
A5FF0008 /* HostedViewPortalRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0018 /* HostedViewPortalRegistry.swift */; };
A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; };
A5001542 /* TerminalImageTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001544 /* TerminalImageTransfer.swift */; };
A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; };
Expand Down Expand Up @@ -242,6 +243,7 @@
A5FF0044 /* GhosttyTerminalView+Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttyTerminalView+Accessibility.swift"; sourceTree = "<group>"; };
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = "<group>"; };
A5FF0018 /* HostedViewPortalRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedViewPortalRegistry.swift; sourceTree = "<group>"; };
A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = "<group>"; };
A5001544 /* TerminalImageTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransfer.swift; sourceTree = "<group>"; };
A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -514,6 +516,7 @@
A5FF0044 /* GhosttyTerminalView+Accessibility.swift */,
A5001531 /* TerminalWindowPortal.swift */,
A5001533 /* BrowserWindowPortal.swift */,
A5FF0018 /* HostedViewPortalRegistry.swift */,
A5001019 /* TerminalController.swift */,
A5FF0004 /* TerminalController+BrowserAutomation.swift */,
A5001541 /* PortScanner.swift */,
Expand Down Expand Up @@ -836,6 +839,7 @@
A5FF0054 /* GhosttyTerminalView+Accessibility.swift in Sources */,
A5001532 /* TerminalWindowPortal.swift in Sources */,
A5001534 /* BrowserWindowPortal.swift in Sources */,
A5FF0008 /* HostedViewPortalRegistry.swift in Sources */,
A5001007 /* TerminalController.swift in Sources */,
A5FF0014 /* TerminalController+BrowserAutomation.swift in Sources */,
A5001540 /* PortScanner.swift in Sources */,
Expand Down
124 changes: 11 additions & 113 deletions Sources/BrowserWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2098,7 +2098,7 @@ final class WindowBrowserSlotView: NSView {
}

@MainActor
final class WindowBrowserPortal: NSObject {
final class WindowBrowserPortal: HostedViewPortalRegistry {
private static let transientRecoveryRetryBudget: Int = 12

private static func dividerHitRectContains(_ point: NSPoint, rect: NSRect) -> Bool {
Expand All @@ -2108,17 +2108,15 @@ final class WindowBrowserPortal: NSObject {
point.y <= rect.maxY
}

private weak var window: NSWindow?
private let hostView = WindowBrowserHostView(frame: .zero)
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
// Keep generations monotonic even if a pending entry is cleared during hide/detach churn.
private var nextHostedWebViewRefreshGeneration: UInt64 = 0
private var pendingHostedWebViewRefreshes: [ObjectIdentifier: PendingHostedWebViewRefresh] = [:]

override var hostViewForGeometry: NSView { hostView }

private struct Entry {
weak var webView: WKWebView?
weak var containerView: WindowBrowserSlotView?
Expand All @@ -2142,9 +2140,8 @@ final class WindowBrowserPortal: NSObject {
private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:]
private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]

init(window: NSWindow) {
self.window = window
super.init()
override init(window: NSWindow) {
super.init(window: window)
hostView.wantsLayer = true
hostView.layer?.masksToBounds = true
hostView.translatesAutoresizingMaskIntoConstraints = true
Expand Down Expand Up @@ -2294,13 +2291,6 @@ final class WindowBrowserPortal: NSObject {
})
}

private func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}

private func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
Expand Down Expand Up @@ -2363,34 +2353,14 @@ final class WindowBrowserPortal: NSObject {
return true
}

@discardableResult
private func synchronizeHostFrameToReference() -> Bool {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return false
}
let frameInContainer = container.convert(reference.bounds, from: reference)
let hasFiniteFrame =
frameInContainer.origin.x.isFinite &&
frameInContainer.origin.y.isFinite &&
frameInContainer.size.width.isFinite &&
frameInContainer.size.height.isFinite
guard hasFiniteFrame else { return false }

if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostView.frame = frameInContainer
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " +
"frame=\(browserPortalDebugFrame(frameInContainer))"
)
#endif
}
return frameInContainer.width > 1 && frameInContainer.height > 1
override func logHostFrameUpdate(_ frame: NSRect) {
dlog(
"browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " +
"frame=\(browserPortalDebugFrame(frame))"
)
}
#endif

private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? {
guard let contentView = window.contentView else { return nil }
Expand All @@ -2404,42 +2374,6 @@ final class WindowBrowserPortal: NSObject {
return (themeFrame, contentView)
}

private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
if view.isHidden { return true }
var current = view.superview
while let v = current {
if v.isHidden { return true }
current = v.superview
}
return false
}

private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}

private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}

private static func searchOverlayConfigurationsEquivalent(
_ lhs: BrowserPortalSearchOverlayConfiguration?,
_ rhs: BrowserPortalSearchOverlayConfiguration?
Expand All @@ -2456,30 +2390,6 @@ final class WindowBrowserPortal: NSObject {
}
}

/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
/// SwiftUI/AppKit hosting layers can briefly report an anchor bounds rect larger than the
/// visible split pane during rearrangement; intersecting through ancestor bounds keeps the
/// portal locked to the pane the user can actually see.
private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect {
var frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
var current = anchorView.superview
while let ancestor = current {
let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil)
let finiteAncestorBounds =
ancestorBoundsInWindow.origin.x.isFinite &&
ancestorBoundsInWindow.origin.y.isFinite &&
ancestorBoundsInWindow.size.width.isFinite &&
ancestorBoundsInWindow.size.height.isFinite
if finiteAncestorBounds {
frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow)
if frameInWindow.isNull { return .zero }
}
if ancestor === installedReferenceView { break }
current = ancestor.superview
}
return frameInWindow
}

private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
frame.minX < bounds.minX - epsilon ||
frame.minY < bounds.minY - epsilon ||
Expand Down Expand Up @@ -2568,14 +2478,6 @@ final class WindowBrowserPortal: NSObject {
}
#endif

private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
guard let viewIndex = container.subviews.firstIndex(of: view),
let referenceIndex = container.subviews.firstIndex(of: reference) else {
return false
}
return viewIndex > referenceIndex
}

private func preferredHostPlacementReference(in container: NSView, fallback reference: NSView) -> NSView {
container.subviews.last(where: {
$0 !== hostView && ($0 === reference || $0 is WindowTerminalHostView)
Expand Down Expand Up @@ -3917,10 +3819,6 @@ final class WindowBrowserPortal: NSObject {
func debugEntryCount() -> Int {
entriesByWebViewId.count
}

func debugHostedSubviewCount() -> Int {
hostView.subviews.count
}
#endif

func debugSnapshot(forWebViewId webViewId: ObjectIdentifier) -> BrowserWindowPortalRegistry.DebugSnapshot? {
Expand Down
147 changes: 147 additions & 0 deletions Sources/HostedViewPortalRegistry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import AppKit

/// Shared lifecycle base for the window-level, anchor-bound hosted-view registries:
/// `WindowTerminalPortal` (hosts `GhosttySurfaceScrollView`) and `WindowBrowserPortal`
/// (hosts `WKWebView`). Extracted per nuclear-review finding N4/WP3 to remove the
/// line-for-line duplication between the two registries.
///
/// Only the slice of the lifecycle that is provably identical between the two portals
/// lives here: installed-target bookkeeping storage, host-frame synchronization to a
/// reference view, anchor-frame-in-window resolution, geometry-observer teardown, and a
/// handful of pure NSRect/NSView helpers. Everything else — bind/detach/hide/visibility,
/// entry pruning, transient-recovery retry budgeting, deferred-sync coalescing,
/// `ensureInstalled()`, and `installGeometryObservers(for:)` — stays in each subclass
/// because their semantics differ in ways that are load-bearing (documented in the PR
/// that introduced this file). `WindowTerminalHostView` and `WindowBrowserHostView`
/// themselves are untouched by this refactor.
@MainActor
class HostedViewPortalRegistry: NSObject {
weak var window: NSWindow?
var installedContainerView: NSView?
var installedReferenceView: NSView?
var geometryObservers: [NSObjectProtocol] = []

init(window: NSWindow) {
self.window = window
super.init()
}

/// Subclasses override to expose their concrete hosted container view
/// (`WindowTerminalHostView` / `WindowBrowserHostView`) upcast to `NSView` so the
/// shared geometry bookkeeping below can read/write its frame and subviews without
/// this base class needing to know the concrete host view type.
var hostViewForGeometry: NSView {
preconditionFailure("HostedViewPortalRegistry subclasses must override hostViewForGeometry")
}

func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}

/// Convert an anchor view's bounds to window coordinates while honoring ancestor
/// clipping. SwiftUI/AppKit hosting layers can report an anchor bounds rect wider
/// than its split pane when intrinsic-size content overflows; intersecting through
/// ancestor bounds gives the effective visible rect that should drive portal geometry.
func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect {
var frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
var current = anchorView.superview
while let ancestor = current {
let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil)
let finiteAncestorBounds =
ancestorBoundsInWindow.origin.x.isFinite &&
ancestorBoundsInWindow.origin.y.isFinite &&
ancestorBoundsInWindow.size.width.isFinite &&
ancestorBoundsInWindow.size.height.isFinite
if finiteAncestorBounds {
frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow)
if frameInWindow.isNull { return .zero }
}
if ancestor === installedReferenceView { break }
current = ancestor.superview
}
return frameInWindow
}

@discardableResult
func synchronizeHostFrameToReference() -> Bool {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return false
}
let frameInContainer = container.convert(reference.bounds, from: reference)
let hasFiniteFrame =
frameInContainer.origin.x.isFinite &&
frameInContainer.origin.y.isFinite &&
frameInContainer.size.width.isFinite &&
frameInContainer.size.height.isFinite
guard hasFiniteFrame else { return false }

if !Self.rectApproximatelyEqual(hostViewForGeometry.frame, frameInContainer) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostViewForGeometry.frame = frameInContainer
CATransaction.commit()
#if DEBUG
logHostFrameUpdate(frameInContainer)
#endif
}
return frameInContainer.width > 1 && frameInContainer.height > 1
}

#if DEBUG
/// No-op by default; each subclass overrides to emit its own dlog line verbatim so
/// the existing greppable per-class diagnostic tokens/prefixes are unchanged.
func logHostFrameUpdate(_ frame: NSRect) {}

func debugHostedSubviewCount() -> Int {
hostViewForGeometry.subviews.count
}
#endif

static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
if view.isHidden { return true }
var current = view.superview
while let v = current {
if v.isHidden { return true }
current = v.superview
}
return false
}

static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}

static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}

static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
guard let viewIndex = container.subviews.firstIndex(of: view),
let referenceIndex = container.subviews.firstIndex(of: reference) else {
return false
}
return viewIndex > referenceIndex
}
}
Loading
Loading