Merge pull request #995 from manaflow-ai/issue-969-browser-drag-top-drop-targets

Fix browser portal anchor churn during pane drag
This commit is contained in:
Austin Wang 2026-03-06 00:18:18 -08:00 committed by GitHub
commit 0f14fc0cd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 483 additions and 152 deletions

View file

@ -1,6 +1,7 @@
import AppKit
import Bonsplit
import ObjectiveC
import SwiftUI
import WebKit
private var cmuxWindowBrowserPortalKey: UInt8 = 0
@ -905,6 +906,14 @@ private final class BrowserDropZoneOverlayView: NSView {
}
}
struct BrowserPortalSearchOverlayConfiguration {
let panelId: UUID
let searchState: BrowserSearchState
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
}
struct BrowserPaneDropContext: Equatable {
let workspaceId: UUID
let panelId: UUID
@ -965,16 +974,23 @@ enum BrowserPaneDropAction: Equatable {
}
enum BrowserPaneDropRouting {
static func zone(for location: CGPoint, in size: CGSize) -> DropZone {
private static let padding: CGFloat = 4
private static func fullPaneSize(for slotSize: CGSize, topChromeHeight: CGFloat) -> CGSize {
CGSize(width: slotSize.width, height: slotSize.height + max(0, topChromeHeight))
}
static func zone(for location: CGPoint, in size: CGSize, topChromeHeight: CGFloat = 0) -> DropZone {
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
let edgeRatio: CGFloat = 0.25
let horizontalEdge = max(80, size.width * edgeRatio)
let verticalEdge = max(80, size.height * edgeRatio)
let horizontalEdge = max(80, fullPaneSize.width * edgeRatio)
let verticalEdge = max(80, fullPaneSize.height * edgeRatio)
if location.x < horizontalEdge {
return .left
} else if location.x > size.width - horizontalEdge {
} else if location.x > fullPaneSize.width - horizontalEdge {
return .right
} else if location.y > size.height - verticalEdge {
} else if location.y > fullPaneSize.height - verticalEdge {
return .top
} else if location.y < verticalEdge {
return .bottom
@ -983,6 +999,47 @@ enum BrowserPaneDropRouting {
}
}
static func overlayFrame(for zone: DropZone, in size: CGSize, topChromeHeight: CGFloat = 0) -> CGRect {
let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight)
switch zone {
case .center:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height - padding * 2
)
case .left:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width / 2 - padding,
height: fullPaneSize.height - padding * 2
)
case .right:
return CGRect(
x: fullPaneSize.width / 2,
y: padding,
width: fullPaneSize.width / 2 - padding,
height: fullPaneSize.height - padding * 2
)
case .top:
return CGRect(
x: padding,
y: fullPaneSize.height / 2,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height / 2 - padding
)
case .bottom:
return CGRect(
x: padding,
y: padding,
width: fullPaneSize.width - padding * 2,
height: fullPaneSize.height / 2 - padding
)
}
}
static func action(
for transfer: BrowserPaneDragTransfer,
target: BrowserPaneDropContext,
@ -1102,7 +1159,11 @@ final class BrowserPaneDropTargetView: NSView {
}
let location = convert(sender.draggingLocation, from: nil)
let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size)
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
guard let action = BrowserPaneDropRouting.action(
for: transfer,
target: dropContext,
@ -1158,7 +1219,11 @@ final class BrowserPaneDropTargetView: NSView {
}
let location = convert(sender.draggingLocation, from: nil)
let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size)
let zone = BrowserPaneDropRouting.zone(
for: location,
in: bounds.size,
topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0
)
activeZone = zone
slotView?.setPortalDragDropZone(zone)
#if DEBUG
@ -1215,11 +1280,13 @@ final class WindowBrowserSlotView: NSView {
override var isOpaque: Bool { false }
private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero)
private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero)
private var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
private var forwardedDropZone: DropZone?
private var portalDragDropZone: DropZone?
private var displayedDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
private var isRefreshingInteractionLayers = false
private var paneTopChromeHeight: CGFloat = 0
var preferredHostedInspectorWidth: CGFloat?
var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)?
fileprivate var isApplyingHostedInspectorLayout = false
@ -1240,7 +1307,6 @@ final class WindowBrowserSlotView: NSView {
dropZoneOverlayView.layer?.cornerRadius = 8
dropZoneOverlayView.isHidden = true
addSubview(paneDropTargetView, positioned: .above, relativeTo: nil)
addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
}
@available(*, unavailable)
@ -1256,6 +1322,12 @@ final class WindowBrowserSlotView: NSView {
onHostedInspectorLayout?(self)
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
attachDropZoneOverlayIfNeeded()
applyResolvedDropZoneOverlay()
}
func setDropZoneOverlay(zone: DropZone?) {
forwardedDropZone = zone
applyResolvedDropZoneOverlay()
@ -1270,9 +1342,62 @@ final class WindowBrowserSlotView: NSView {
paneDropTargetView.dropContext = context
}
func setPaneTopChromeHeight(_ height: CGFloat) {
let resolvedHeight = max(0, height)
guard abs(paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
paneTopChromeHeight = resolvedHeight
applyResolvedDropZoneOverlay()
}
func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) {
guard let configuration else {
searchOverlayHostingView?.removeFromSuperview()
searchOverlayHostingView = nil
return
}
let rootView = BrowserSearchOverlay(
panelId: configuration.panelId,
searchState: configuration.searchState,
onNext: configuration.onNext,
onPrevious: configuration.onPrevious,
onClose: configuration.onClose
)
if let overlay = searchOverlayHostingView {
overlay.rootView = rootView
if overlay.superview !== self {
overlay.removeFromSuperview()
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
return
}
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
searchOverlayHostingView = overlay
}
func effectivePaneTopChromeHeight() -> CGFloat {
paneTopChromeHeight
}
override func didAddSubview(_ subview: NSView) {
super.didAddSubview(subview)
guard subview !== paneDropTargetView, subview !== dropZoneOverlayView else { return }
guard subview !== paneDropTargetView else { return }
bringInteractionLayersToFrontIfNeeded()
}
@ -1280,6 +1405,17 @@ final class WindowBrowserSlotView: NSView {
portalDragDropZone ?? forwardedDropZone
}
private func overlayContainerView() -> NSView {
superview ?? self
}
private func attachDropZoneOverlayIfNeeded() {
let container = overlayContainerView()
guard dropZoneOverlayView.superview !== container else { return }
dropZoneOverlayView.removeFromSuperview()
container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
}
private func applyResolvedDropZoneOverlay() {
let resolvedZone = activeDropZone
if resolvedZone != nil, (bounds.width <= 2 || bounds.height <= 2) {
@ -1315,6 +1451,7 @@ final class WindowBrowserSlotView: NSView {
}
return
}
attachDropZoneOverlayIfNeeded()
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame)
@ -1356,7 +1493,6 @@ final class WindowBrowserSlotView: NSView {
private func interactionLayerPriority(of view: NSView) -> Int {
if view === paneDropTargetView { return 1 }
if view === dropZoneOverlayView { return 2 }
return 0
}
@ -1368,8 +1504,11 @@ final class WindowBrowserSlotView: NSView {
if paneDropTargetView.superview !== self {
addSubview(paneDropTargetView, positioned: .above, relativeTo: nil)
}
if dropZoneOverlayView.superview !== self {
addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
let overlayContainer = overlayContainerView()
if dropZoneOverlayView.superview !== overlayContainer {
attachDropZoneOverlayIfNeeded()
} else if overlayContainer.subviews.last !== dropZoneOverlayView {
overlayContainer.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
}
let context = Unmanaged.passUnretained(self).toOpaque()
@ -1392,19 +1531,13 @@ final class WindowBrowserSlotView: NSView {
}
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
let padding: CGFloat = 4
switch zone {
case .center:
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
case .left:
return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .right:
return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
case .top:
return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
case .bottom:
return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
}
let localFrame = BrowserPaneDropRouting.overlayFrame(
for: zone,
in: size,
topChromeHeight: paneTopChromeHeight
)
guard let superview else { return localFrame }
return superview.convert(localFrame, from: self)
}
private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool {
@ -1435,6 +1568,9 @@ final class WindowBrowserPortal: NSObject {
var zPriority: Int
var dropZone: DropZone?
var paneDropContext: BrowserPaneDropContext?
var searchOverlay: BrowserPortalSearchOverlayConfiguration?
var paneTopChromeHeight: CGFloat
var transientRecoveryReason: String?
var transientRecoveryRetriesRemaining: Int
}
@ -1693,10 +1829,14 @@ final class WindowBrowserPortal: NSObject {
private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView {
if let existing = entry.containerView {
existing.setPaneDropContext(entry.paneDropContext)
existing.setSearchOverlay(entry.searchOverlay)
existing.setPaneTopChromeHeight(entry.paneTopChromeHeight)
return existing
}
let created = WindowBrowserSlotView(frame: .zero)
created.setPaneDropContext(entry.paneDropContext)
created.setSearchOverlay(entry.searchOverlay)
created.setPaneTopChromeHeight(entry.paneTopChromeHeight)
#if DEBUG
dlog(
"browser.portal.container.create web=\(browserPortalDebugToken(webView)) " +
@ -1818,6 +1958,14 @@ final class WindowBrowserPortal: NSObject {
entriesByWebViewId[webViewId] = entry
}
func hideWebView(withId webViewId: ObjectIdentifier, source: String = "externalHide") {
guard var entry = entriesByWebViewId[webViewId] else { return }
entry.visibleInUI = false
entry.zPriority = 0
entriesByWebViewId[webViewId] = entry
synchronizeWebView(withId: webViewId, source: source)
}
func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) {
guard var entry = entriesByWebViewId[webViewId] else { return }
entry.dropZone = zone
@ -1832,6 +1980,25 @@ final class WindowBrowserPortal: NSObject {
entry.containerView?.setPaneDropContext(context)
}
func updateSearchOverlay(
forWebViewId webViewId: ObjectIdentifier,
configuration: BrowserPortalSearchOverlayConfiguration?
) {
guard var entry = entriesByWebViewId[webViewId] else { return }
entry.searchOverlay = configuration
entriesByWebViewId[webViewId] = entry
entry.containerView?.setSearchOverlay(configuration)
}
func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) {
guard var entry = entriesByWebViewId[webViewId] else { return }
let resolvedHeight = max(0, height)
guard abs(entry.paneTopChromeHeight - resolvedHeight) > 0.5 else { return }
entry.paneTopChromeHeight = resolvedHeight
entriesByWebViewId[webViewId] = entry
entry.containerView?.setPaneTopChromeHeight(resolvedHeight)
}
func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard ensureInstalled() else { return }
@ -1847,6 +2014,9 @@ final class WindowBrowserPortal: NSObject {
zPriority: 0,
dropZone: nil,
paneDropContext: nil,
searchOverlay: nil,
paneTopChromeHeight: 0,
transientRecoveryReason: nil,
transientRecoveryRetriesRemaining: 0
),
webView: webView
@ -1880,6 +2050,9 @@ final class WindowBrowserPortal: NSObject {
zPriority: zPriority,
dropZone: previousEntry?.dropZone,
paneDropContext: previousEntry?.paneDropContext,
searchOverlay: previousEntry?.searchOverlay,
paneTopChromeHeight: previousEntry?.paneTopChromeHeight ?? 0,
transientRecoveryReason: previousEntry?.transientRecoveryReason,
transientRecoveryRetriesRemaining: previousEntry?.transientRecoveryRetriesRemaining ?? 0
)
@ -1997,7 +2170,8 @@ final class WindowBrowserPortal: NSObject {
}
private func resetTransientRecoveryRetryIfNeeded(forWebViewId webViewId: ObjectIdentifier, entry: inout Entry) {
guard entry.transientRecoveryRetriesRemaining != 0 else { return }
guard entry.transientRecoveryRetriesRemaining != 0 || entry.transientRecoveryReason != nil else { return }
entry.transientRecoveryReason = nil
entry.transientRecoveryRetriesRemaining = 0
entriesByWebViewId[webViewId] = entry
}
@ -2008,9 +2182,18 @@ final class WindowBrowserPortal: NSObject {
webView: WKWebView,
reason: String
) -> Bool {
if entry.transientRecoveryRetriesRemaining == 0 {
if entry.transientRecoveryReason != reason {
entry.transientRecoveryReason = reason
entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget
}
#if DEBUG
if entry.transientRecoveryRetriesRemaining <= 0 {
dlog(
"browser.portal.sync.deferRecover.skip web=\(browserPortalDebugToken(webView)) " +
"reason=\(reason) exhausted=1"
)
}
#endif
guard entry.transientRecoveryRetriesRemaining > 0 else { return false }
entry.transientRecoveryRetriesRemaining -= 1
@ -2045,15 +2228,24 @@ final class WindowBrowserPortal: NSObject {
}
return
}
func scheduleTransientDetachRecovery(reason: String) -> Bool {
guard entry.visibleInUI else { return false }
return scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: reason
)
}
guard let anchorView = entry.anchorView, let window else {
if entry.visibleInUI {
_ = scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: "missingAnchorOrWindow"
)
} else {
if scheduleTransientDetachRecovery(reason: "missingAnchorOrWindow") {
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
return
}
if !entry.visibleInUI {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
#if DEBUG
@ -2064,11 +2256,20 @@ final class WindowBrowserPortal: NSObject {
)
}
#endif
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
return
}
guard anchorView.window === window else {
if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") {
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
return
}
#if DEBUG
if !containerView.isHidden {
dlog(
@ -2078,16 +2279,11 @@ final class WindowBrowserPortal: NSObject {
)
}
#endif
if entry.visibleInUI {
_ = scheduleTransientRecoveryRetryIfNeeded(
forWebViewId: webViewId,
entry: &entry,
webView: webView,
reason: "anchorWindowMismatch"
)
} else {
if !entry.visibleInUI {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
return
@ -2168,6 +2364,7 @@ final class WindowBrowserPortal: NSObject {
} else {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
containerView.isHidden = true
if entry.visibleInUI {
@ -2180,6 +2377,7 @@ final class WindowBrowserPortal: NSObject {
} else {
scheduleDeferredFullSynchronizeAll()
}
containerView.setPaneTopChromeHeight(0)
return
}
let oldFrame = containerView.frame
@ -2339,6 +2537,8 @@ final class WindowBrowserPortal: NSObject {
#endif
containerView.isHidden = false
}
containerView.setPaneTopChromeHeight(shouldHide ? 0 : entry.paneTopChromeHeight)
containerView.setSearchOverlay(shouldHide ? nil : entry.searchOverlay)
containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone)
if revealedForDisplay {
refreshReasons.append("reveal")
@ -2549,6 +2749,13 @@ enum BrowserWindowPortalRegistry {
portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority)
}
static func hide(webView: WKWebView, source: String = "externalHide") {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.hideWebView(withId: webViewId, source: source)
}
static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
@ -2563,6 +2770,23 @@ enum BrowserWindowPortalRegistry {
portal.updatePaneDropContext(forWebViewId: webViewId, context: context)
}
static func updateSearchOverlay(
for webView: WKWebView,
configuration: BrowserPortalSearchOverlayConfiguration?
) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration)
}
static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return }
portal.updatePaneTopChromeHeight(forWebViewId: webViewId, height: height)
}
static func detach(webView: WKWebView) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }

View file

@ -2815,13 +2815,14 @@ struct ContentView: View {
workspaceHandoffFallbackTask = nil
let retiring = retiringWorkspaceId
// Hide terminal portal views for the retiring workspace BEFORE clearing
// Hide portal-hosted views for the retiring workspace BEFORE clearing
// retiringWorkspaceId. Once cleared, reconcileMountedWorkspaceIds unmounts
// the workspace but dismantleNSView intentionally doesn't hide portal views
// (to avoid blackouts during transient bonsplit dismantles). Hiding here
// prevents stale portal-hosted terminals from covering browser panes.
// during transient rebuilds. Hiding here prevents stale terminal/browser
// portals from covering the newly selected workspace.
if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) {
workspace.hideAllTerminalPortalViews()
workspace.hideAllBrowserPortalViews()
}
retiringWorkspaceId = nil

View file

@ -1244,6 +1244,15 @@ final class BrowserSearchState: ObservableObject {
}
}
final class BrowserPortalAnchorView: NSView {
override var acceptsFirstResponder: Bool { false }
override var isOpaque: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
}
@MainActor
final class BrowserPanel: Panel, ObservableObject {
/// Shared process pool for cookie sharing across all browser panels
@ -1481,6 +1490,7 @@ final class BrowserPanel: Panel, ObservableObject {
}
}
private var searchNeedleCancellable: AnyCancellable?
let portalAnchorView = BrowserPortalAnchorView(frame: .zero)
private var webViewCancellables = Set<AnyCancellable>()
private var navigationDelegate: BrowserNavigationDelegate?
private var uiDelegate: BrowserUIDelegate?

View file

@ -230,6 +230,7 @@ struct BrowserPanelView: View {
@State private var focusFlashOpacity: Double = 0.0
@State private var focusFlashAnimationGeneration: Int = 0
@State private var omnibarPillFrame: CGRect = .zero
@State private var addressBarHeight: CGFloat = 0
@State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var isBrowserThemeMenuPresented = false
@State private var ghosttyBackgroundGeneration: Int = 0
@ -310,21 +311,16 @@ struct BrowserPanelView: View {
}
var body: some View {
// Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit
// container. Rendering it here can hide it behind the portal-hosted WKWebView.
VStack(spacing: 0) {
addressBar
webView
}
.overlay {
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.overlay {
// Keep Cmd+F usable when the browser is still in the empty new-tab
// state (no WKWebView mounted yet). WebView-backed cases are hosted
// in AppKit by WebViewRepresentable to avoid layering/clipping issues.
// in AppKit by WindowBrowserPortal to avoid layering/clipping issues.
if !panel.shouldRenderWebView, let searchState = panel.searchState {
BrowserSearchOverlay(
panelId: panel.id,
@ -335,6 +331,13 @@ struct BrowserPanelView: View {
)
}
}
.overlay {
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.overlay(alignment: .topLeading) {
if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 {
OmnibarSuggestionsView(
@ -361,6 +364,9 @@ struct BrowserPanelView: View {
.onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in
omnibarPillFrame = frame
}
.onPreferenceChange(BrowserAddressBarHeightPreferenceKey.self) { height in
addressBarHeight = height
}
.onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in
// Only handle clicks from our own webview.
guard let webView = note.object as? CmuxWebView else { return false }
@ -502,6 +508,15 @@ struct BrowserPanelView: View {
.padding(.horizontal, 8)
.padding(.vertical, addressBarVerticalPadding)
.background(browserChromeBackground)
.background {
GeometryReader { geo in
Color.clear
.preference(
key: BrowserAddressBarHeightPreferenceKey.self,
value: geo.size.height
)
}
}
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
.zIndex(1)
.environment(\.colorScheme, browserChromeColorScheme)
@ -742,12 +757,21 @@ struct BrowserPanelView: View {
if panel.shouldRenderWebView {
WebViewRepresentable(
panel: panel,
browserSearchState: panel.searchState,
shouldAttachWebView: isVisibleInUI,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused,
portalZPriority: portalPriority,
paneDropZone: paneDropZone
paneDropZone: paneDropZone,
searchOverlay: panel.searchState.map { searchState in
BrowserPortalSearchOverlayConfiguration(
panelId: panel.id,
searchState: searchState,
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
onClose: { panel.hideFind() }
)
},
paneTopChromeHeight: addressBarHeight
)
// Keep the host stable for normal pane churn, but force a remount when
// BrowserPanel replaces its underlying WKWebView after process termination.
@ -1945,6 +1969,14 @@ private struct OmnibarPillFramePreferenceKey: PreferenceKey {
}
}
private struct BrowserAddressBarHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
// MARK: - Omnibar State Machine
struct OmnibarState: Equatable {
@ -3044,12 +3076,13 @@ private struct OmnibarSuggestionsView: View {
/// NSViewRepresentable wrapper for WKWebView
struct WebViewRepresentable: NSViewRepresentable {
let panel: BrowserPanel
let browserSearchState: BrowserSearchState?
let shouldAttachWebView: Bool
let shouldFocusWebView: Bool
let isPanelFocused: Bool
let portalZPriority: Int
let paneDropZone: DropZone?
let searchOverlay: BrowserPortalSearchOverlayConfiguration?
let paneTopChromeHeight: CGFloat
final class Coordinator {
weak var panel: BrowserPanel?
@ -3058,7 +3091,6 @@ struct WebViewRepresentable: NSViewRepresentable {
var desiredPortalVisibleInUI: Bool = true
var desiredPortalZPriority: Int = 0
var lastPortalHostId: ObjectIdentifier?
var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
}
final class HostContainerView: NSView {
@ -3771,65 +3803,16 @@ struct WebViewRepresentable: NSViewRepresentable {
host.onGeometryChanged = nil
}
private static func removeSearchOverlay(from coordinator: Coordinator) {
coordinator.searchOverlayHostingView?.removeFromSuperview()
coordinator.searchOverlayHostingView = nil
}
private static func updateSearchOverlay(
panel: BrowserPanel,
coordinator: Coordinator,
containerView: NSView?
) {
// Layering contract: keep browser Cmd+F UI in the portal-hosted AppKit layer.
// SwiftUI panel overlays can be covered by portal-hosted WKWebView content.
guard let searchState = panel.searchState,
let containerView else {
removeSearchOverlay(from: coordinator)
return
private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) {
if anchorView.superview !== host {
anchorView.removeFromSuperview()
anchorView.frame = host.bounds
anchorView.translatesAutoresizingMaskIntoConstraints = true
anchorView.autoresizingMask = [.width, .height]
host.addSubview(anchorView)
} else if anchorView.frame != host.bounds {
anchorView.frame = host.bounds
}
let rootView = BrowserSearchOverlay(
panelId: panel.id,
searchState: searchState,
onNext: { [weak panel] in
panel?.findNext()
},
onPrevious: { [weak panel] in
panel?.findPrevious()
},
onClose: { [weak panel] in
panel?.hideFind()
}
)
if let overlay = coordinator.searchOverlayHostingView {
overlay.rootView = rootView
if overlay.superview !== containerView {
overlay.removeFromSuperview()
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
])
} else if containerView.subviews.last !== overlay {
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
}
return
}
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
])
coordinator.searchOverlayHostingView = overlay
}
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
@ -3843,32 +3826,35 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil
let activeSearchOverlay = shouldAttachWebView ? searchOverlay : nil
let portalAnchorView = panel.portalAnchorView
Self.installPortalAnchorView(portalAnchorView, in: host)
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
guard let host, let webView, let coordinator else { return }
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in
guard let host, let webView, let coordinator, let portalAnchorView else { return }
guard coordinator.attachGeneration == generation else { return }
Self.installPortalAnchorView(portalAnchorView, in: host)
guard host.window != nil else { return }
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
to: portalAnchorView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
coordinator.lastPortalHostId = ObjectIdentifier(host)
if let panel = coordinator.panel {
Self.updateSearchOverlay(
panel: panel,
coordinator: coordinator,
containerView: webView.superview
)
}
}
host.onGeometryChanged = { [weak host, weak coordinator] in
guard let host, let coordinator else { return }
host.onGeometryChanged = { [weak host, weak coordinator, weak portalAnchorView] in
guard let host, let coordinator, let portalAnchorView else { return }
guard coordinator.attachGeneration == generation else { return }
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
Self.installPortalAnchorView(portalAnchorView, in: host)
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
}
if !shouldAttachWebView {
@ -3885,20 +3871,21 @@ struct WebViewRepresentable: NSViewRepresentable {
previousVisible != shouldAttachWebView ||
previousZPriority != portalZPriority
if shouldBindNow {
Self.installPortalAnchorView(portalAnchorView, in: host)
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
to: portalAnchorView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastPortalHostId = hostId
}
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
Self.updateSearchOverlay(
panel: panel,
coordinator: coordinator,
containerView: webView.superview
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: shouldAttachWebView ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
} else {
// Bind is deferred until host moves into a window. Keep the current
// portal entry's desired state in sync so stale callbacks cannot keep
@ -3908,17 +3895,21 @@ struct WebViewRepresentable: NSViewRepresentable {
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
Self.removeSearchOverlay(from: coordinator)
}
BrowserWindowPortalRegistry.updateDropZoneOverlay(
for: webView,
zone: shouldAttachWebView ? paneDropZone : nil
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: shouldAttachWebView ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(
for: webView,
context: paneDropContext
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
@ -3937,7 +3928,6 @@ struct WebViewRepresentable: NSViewRepresentable {
let webView = panel.webView
let coordinator = context.coordinator
if let previousWebView = coordinator.webView, previousWebView !== webView {
Self.removeSearchOverlay(from: coordinator)
BrowserWindowPortalRegistry.detach(webView: previousWebView)
coordinator.lastPortalHostId = nil
}
@ -4009,7 +3999,6 @@ struct WebViewRepresentable: NSViewRepresentable {
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachGeneration += 1
clearPortalCallbacks(for: nsView)
removeSearchOverlay(from: coordinator)
guard let webView = coordinator.webView else { return }
let panel = coordinator.panel
@ -4039,7 +4028,9 @@ struct WebViewRepresentable: NSViewRepresentable {
// rearrangement. Do not detach the portal-hosted WKWebView here; explicit detach
// still happens on real web view replacement and panel teardown.
BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(for: webView, height: 0)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: nil)
coordinator.lastPortalHostId = nil
}

View file

@ -3280,6 +3280,19 @@ final class Workspace: Identifiable, ObservableObject {
}
}
/// Hide all browser portal views for this workspace.
/// Called before the workspace is unmounted so a portal-hosted WKWebView
/// cannot remain visible after this workspace stops being selected.
func hideAllBrowserPortalViews() {
for panel in panels.values {
guard let browser = panel as? BrowserPanel else { continue }
BrowserWindowPortalRegistry.hide(
webView: browser.webView,
source: "workspaceRetire"
)
}
}
// MARK: - Utility
/// Create a new terminal panel (used when replacing the last panel)

View file

@ -2534,12 +2534,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
let representable = WebViewRepresentable(
panel: panel,
browserSearchState: nil,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0,
paneDropZone: nil
paneDropZone: nil,
searchOverlay: nil,
paneTopChromeHeight: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
@ -2572,12 +2573,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
let representable = WebViewRepresentable(
panel: panel,
browserSearchState: nil,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0,
paneDropZone: nil
paneDropZone: nil,
searchOverlay: nil,
paneTopChromeHeight: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
@ -8782,6 +8784,27 @@ final class BrowserPaneDropRoutingTests: XCTestCase {
)
}
func testTopChromeHeightPushesTopSplitThresholdIntoWebView() {
let size = CGSize(width: 240, height: 180)
XCTAssertEqual(
BrowserPaneDropRouting.zone(
for: CGPoint(x: size.width * 0.5, y: 110),
in: size,
topChromeHeight: 36
),
.center
)
XCTAssertEqual(
BrowserPaneDropRouting.zone(
for: CGPoint(x: size.width * 0.5, y: 150),
in: size,
topChromeHeight: 36
),
.top
)
}
func testHitTestingCapturesOnlyForRelevantDragEvents() {
XCTAssertTrue(
BrowserPaneDropTargetView.shouldCaptureHitTesting(
@ -8879,22 +8902,24 @@ final class WindowBrowserSlotViewTests: XCTestCase {
}
func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() {
let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
let slot = WindowBrowserSlotView(frame: container.bounds)
container.addSubview(slot)
let child = CapturingView(frame: slot.bounds)
child.autoresizingMask = [.width, .height]
slot.addSubview(child)
slot.setDropZoneOverlay(zone: .right)
slot.layoutSubtreeIfNeeded()
container.layoutSubtreeIfNeeded()
guard let overlay = slot.subviews.first(where: {
$0 !== child && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
guard let overlay = container.subviews.first(where: {
$0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
}) else {
XCTFail("Expected browser slot drop-zone overlay")
return
}
XCTAssertTrue(slot.subviews.last === overlay, "Overlay should stay above the hosted web view")
XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view")
XCTAssertFalse(overlay.isHidden)
XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5)
XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5)
@ -8907,6 +8932,35 @@ final class WindowBrowserSlotViewTests: XCTestCase {
advanceAnimations()
XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay")
}
func testTopDropZoneOverlayUsesFullBrowserContentHeight() {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
let slot = WindowBrowserSlotView(frame: container.bounds)
container.addSubview(slot)
slot.setPaneTopChromeHeight(20)
slot.setDropZoneOverlay(zone: .top)
container.layoutSubtreeIfNeeded()
guard let overlay = container.subviews.first(where: {
String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
}) else {
XCTFail("Expected browser slot drop-zone overlay")
return
}
XCTAssertFalse(overlay.isHidden)
XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5)
XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5)
XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5)
XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5)
XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY)
XCTAssertEqual(slot.layer?.masksToBounds, true)
slot.setDropZoneOverlay(zone: nil)
advanceAnimations()
XCTAssertEqual(slot.layer?.masksToBounds, true)
}
}
@MainActor
@ -10326,8 +10380,11 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
}
private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? {
slot.subviews.first(where: {
$0 !== webView && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
let candidates = slot.subviews + (slot.superview?.subviews ?? [])
return candidates.first(where: {
$0 !== slot &&
$0 !== webView &&
String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView")
})
}
@ -10638,9 +10695,9 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right)
slot.layoutSubtreeIfNeeded()
XCTAssertFalse(overlay.isHidden)
XCTAssertTrue(slot.subviews.last === overlay, "Overlay should remain above the hosted web view")
XCTAssertEqual(overlay.frame.origin.x, 110, accuracy: 0.5)
XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5)
XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view")
XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5)
XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5)
XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5)
XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5)
@ -10777,6 +10834,41 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
BrowserWindowPortalRegistry.detach(webView: webView)
XCTAssertNil(webView.superview)
}
func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
advanceAnimations()
guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected browser slot")
return
}
XCTAssertFalse(slot.isHidden)
BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest")
advanceAnimations()
XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment")
XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot")
}
}
@MainActor