cmux/Sources/Panels/BrowserPanelView.swift
Lawrence Chen dd54927cb9
Add React Grab inject button to browser toolbar (#2373)
* Add React Grab inject button to browser toolbar

Adds a toolbar button (cursor click icon) that injects the react-grab
script (unpkg.com/react-grab/dist/index.global.js) into the current
page. Hover over React elements and Cmd+C to copy component context
(file, component name, line number) for AI agents.

Button highlights when active, resets on navigation.

* Auto-activate selection mode on React Grab inject

First click: injects the script and auto-activates selection mode via
the react-grab:init event. Subsequent clicks toggle selection mode
on/off via window.__REACT_GRAB__.toggle().

* Bridge React Grab state back to Swift via WKScriptMessageHandler

Register a cmux-bridge plugin after injecting react-grab that posts
state changes back to Swift via webkit.messageHandlers. The button
now highlights accent color only when selection mode is actually
active (not just when the script is loaded), and deactivates when
the user exits selection mode via Escape or the react-grab toolbar.

* Fetch react-grab script via URLSession to bypass CSP

Sites like vercel.com block loading external scripts via CSP headers.
Fetch the script with URLSession (not subject to page CSP), cache it,
and inject inline via evaluateJavaScript. Also guard against duplicate
injection on repeated clicks.

* Prefetch react-grab script on first browser panel init

Kick off a low-priority background fetch of the react-grab script
when the first BrowserPanel is created. The script is cached
statically so clicking the button is instant.

* Eliminate react-grab button and callback lag

Three changes:
1. Fire-and-forget: use evaluateJavaScript with completionHandler
   instead of await, so button taps return immediately.
2. Single JS payload: combine bootstrap listener + script source
   into one evaluateJavaScript call (one IPC round-trip, not two).
3. Dedupe state callbacks: only post webkit message when isActive
   actually changes, not on every hover/drag state update.

* Fix duplicate state callback on react-grab toggle

toggleReactGrab was sending an explicit postMessage AND the plugin's
onStateChange hook was firing too, causing two @Published updates per
toggle. Remove the explicit postMessage since the plugin hook handles
it. Also add dlog instrumentation for debugging.

* Add Cmd+Shift+G shortcut for React Grab (configurable)

- Add toggleReactGrab to KeyboardShortcutSettings with Cmd+Shift+G default
- Add View menu item with customizable shortcut
- Add command palette entry (searchable as "react grab" or "inspect element")
- Simplify button to use toggleOrInjectReactGrab, remove local state tracking

* Fix Codex review findings: pin version, verify hash, fix retry and state

1. Pin react-grab to exact version (0.1.29) with SHA-256 integrity
   check. Script is verified before evaluation to prevent supply-chain
   attacks via compromised CDN responses.
2. Clear prefetchTask on failure so subsequent attempts retry the
   download instead of reusing a permanently failed task.
3. Remove premature isReactGrabActive=true. State is now only set
   by the onStateChange message handler callback after confirmed
   initialization, or explicitly reset on evaluation error.

* Extract React Grab into own file, make version configurable

Move all react-grab logic (settings, script loader, message handler,
BrowserPanel extension) into Sources/Panels/ReactGrab.swift.

Add a "React Grab Version" text field in Settings > Browser that lets
the user pin which npm version is fetched. Only versions with a known
SHA-256 integrity hash in ReactGrabSettings.knownHashes are accepted.
The cache invalidates when the configured version changes.

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-30 18:00:45 -07:00

6460 lines
268 KiB
Swift

import Bonsplit
import SwiftUI
import WebKit
import AppKit
enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable {
case wrenchAndScrewdriver = "wrench.and.screwdriver"
case wrenchAndScrewdriverFill = "wrench.and.screwdriver.fill"
case curlyBracesSquare = "curlybraces.square"
case curlyBraces = "curlybraces"
case terminalFill = "terminal.fill"
case terminal = "terminal"
case hammer = "hammer"
case hammerCircle = "hammer.circle"
case ladybug = "ladybug"
case ladybugFill = "ladybug.fill"
case scope = "scope"
case codeChevrons = "chevron.left.slash.chevron.right"
case gearshape = "gearshape"
case gearshapeFill = "gearshape.fill"
case globe = "globe"
case globeAmericas = "globe.americas.fill"
var id: String { rawValue }
var title: String {
switch self {
case .wrenchAndScrewdriver: return "Wrench + Screwdriver"
case .wrenchAndScrewdriverFill: return "Wrench + Screwdriver (Fill)"
case .curlyBracesSquare: return "Curly Braces"
case .curlyBraces: return "Curly Braces (Plain)"
case .terminalFill: return "Terminal (Fill)"
case .terminal: return "Terminal"
case .hammer: return "Hammer"
case .hammerCircle: return "Hammer Circle"
case .ladybug: return "Bug"
case .ladybugFill: return "Bug (Fill)"
case .scope: return "Scope"
case .codeChevrons: return "Code Chevrons"
case .gearshape: return "Gear"
case .gearshapeFill: return "Gear (Fill)"
case .globe: return "Globe"
case .globeAmericas: return "Globe Americas (Fill)"
}
}
}
enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable {
case bonsplitInactive
case bonsplitActive
case accent
case tertiary
var id: String { rawValue }
var title: String {
switch self {
case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)"
case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)"
case .accent: return "Accent"
case .tertiary: return "Tertiary"
}
}
var color: Color {
switch self {
case .bonsplitInactive:
// Matches Bonsplit tab icon tint for inactive tabs.
return Color(nsColor: .secondaryLabelColor)
case .bonsplitActive:
// Matches Bonsplit tab icon tint for active tabs.
return Color(nsColor: .labelColor)
case .accent:
return cmuxAccentColor()
case .tertiary:
return Color(nsColor: .tertiaryLabelColor)
}
}
}
enum BrowserDevToolsButtonDebugSettings {
static let iconNameKey = "browserDevToolsIconName"
static let iconColorKey = "browserDevToolsIconColor"
static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver
static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive
static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption {
guard let raw = defaults.string(forKey: iconNameKey),
let option = BrowserDevToolsIconOption(rawValue: raw) else {
return defaultIcon
}
return option
}
static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption {
guard let raw = defaults.string(forKey: iconColorKey),
let option = BrowserDevToolsIconColorOption(rawValue: raw) else {
return defaultColor
}
return option
}
static func copyPayload(defaults: UserDefaults = .standard) -> String {
let icon = iconOption(defaults: defaults)
let color = colorOption(defaults: defaults)
return """
browserDevToolsIconName=\(icon.rawValue)
browserDevToolsIconColor=\(color.rawValue)
"""
}
}
enum BrowserToolbarAccessorySpacingDebugSettings {
static let key = "browserToolbarAccessorySpacing"
static let defaultSpacing = 2
static let supportedValues = [0, 2, 4, 6, 8]
static func resolved(_ rawValue: Int) -> Int {
supportedValues.contains(rawValue) ? rawValue : defaultSpacing
}
static func current(defaults: UserDefaults = .standard) -> Int {
resolved(defaults.object(forKey: key) as? Int ?? defaultSpacing)
}
}
enum BrowserProfilePopoverDebugSettings {
static let horizontalPaddingKey = "browserProfilePopoverHorizontalPadding"
static let verticalPaddingKey = "browserProfilePopoverVerticalPadding"
static let defaultHorizontalPadding = 12.0
static let defaultVerticalPadding = 10.0
static let horizontalPaddingRange = 8.0...20.0
static let verticalPaddingRange = 4.0...14.0
static func resolvedHorizontalPadding(_ rawValue: Double) -> Double {
horizontalPaddingRange.contains(rawValue) ? rawValue : defaultHorizontalPadding
}
static func resolvedVerticalPadding(_ rawValue: Double) -> Double {
verticalPaddingRange.contains(rawValue) ? rawValue : defaultVerticalPadding
}
static func currentHorizontalPadding(defaults: UserDefaults = .standard) -> Double {
resolvedHorizontalPadding((defaults.object(forKey: horizontalPaddingKey) as? NSNumber)?.doubleValue ?? defaultHorizontalPadding)
}
static func currentVerticalPadding(defaults: UserDefaults = .standard) -> Double {
resolvedVerticalPadding((defaults.object(forKey: verticalPaddingKey) as? NSNumber)?.doubleValue ?? defaultVerticalPadding)
}
}
struct OmnibarInlineCompletion: Equatable {
let typedText: String
let displayText: String
let acceptedText: String
var suffixRange: NSRange {
let typedCount = typedText.utf16.count
let fullCount = displayText.utf16.count
return NSRange(location: typedCount, length: max(0, fullCount - typedCount))
}
}
private struct OmnibarAddressButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
OmnibarAddressButtonStyleBody(configuration: configuration)
}
}
private struct OmnibarAddressButtonStyleBody: View {
let configuration: OmnibarAddressButtonStyle.Configuration
@Environment(\.isEnabled) private var isEnabled
@State private var isHovered = false
private var backgroundOpacity: Double {
guard isEnabled else { return 0.0 }
if configuration.isPressed { return 0.16 }
if isHovered { return 0.08 }
return 0.0
}
var body: some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.primary.opacity(backgroundOpacity))
)
.onHover { hovering in
isHovered = hovering
}
.animation(.easeOut(duration: 0.12), value: isHovered)
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
}
}
private extension View {
func cmuxFlatSymbolColorRendering() -> some View {
// `symbolColorRenderingMode(.flat)` is not available in the current SDK
// used by CI/local builds. Keep this modifier as a compatibility no-op.
self
}
}
func resolvedBrowserChromeBackgroundColor(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> NSColor {
switch colorScheme {
case .dark, .light:
return themeBackgroundColor
@unknown default:
return themeBackgroundColor
}
}
func resolvedBrowserChromeColorScheme(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> ColorScheme {
let backgroundColor = resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: themeBackgroundColor
)
return backgroundColor.isLightColor ? .light : .dark
}
func resolvedBrowserOmnibarPillBackgroundColor(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> NSColor {
let darkenMix: CGFloat
switch colorScheme {
case .light:
darkenMix = 0.04
case .dark:
darkenMix = 0.05
@unknown default:
darkenMix = 0.04
}
return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor
}
private struct BrowserChromeStyle {
let backgroundColor: NSColor
let colorScheme: ColorScheme
let omnibarPillBackgroundColor: NSColor
static func resolve(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> BrowserChromeStyle {
let backgroundColor = resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: themeBackgroundColor
)
let chromeColorScheme = resolvedBrowserChromeColorScheme(
for: colorScheme,
themeBackgroundColor: backgroundColor
)
let omnibarPillBackgroundColor = resolvedBrowserOmnibarPillBackgroundColor(
for: chromeColorScheme,
themeBackgroundColor: backgroundColor
)
return BrowserChromeStyle(
backgroundColor: backgroundColor,
colorScheme: chromeColorScheme,
omnibarPillBackgroundColor: omnibarPillBackgroundColor
)
}
}
/// View for rendering a browser panel with address bar
struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
@ObservedObject private var browserProfileStore = BrowserProfileStore.shared
let paneId: PaneID
let isFocused: Bool
let isVisibleInUI: Bool
let portalPriority: Int
let onRequestPanelFocus: () -> Void
@Environment(\.colorScheme) private var colorScheme
@Environment(\.paneDropZone) private var paneDropZone
@State private var omnibarState = OmnibarState()
@State private var addressBarFocused: Bool = false
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
@AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
@AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
private var browserProfilePopoverHorizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
@AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey)
private var browserProfilePopoverVerticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue
@AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue
@AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
@AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
private var toggleBrowserDeveloperToolsShortcutData = Data()
@State private var suggestionTask: Task<Void, Never>?
@State private var isLoadingRemoteSuggestions: Bool = false
@State private var latestRemoteSuggestionQuery: String = ""
@State private var latestRemoteSuggestions: [String] = []
@State private var emptyStateImportBrowsers: [InstalledBrowserCandidate] = []
@State private var emptyStateImportBrowserRefreshTask: Task<Void, Never>?
@State private var emptyStateImportBrowserRefreshGeneration: UInt64 = 0
@State private var inlineCompletion: OmnibarInlineCompletion?
@State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0)
@State private var omnibarHasMarkedText: Bool = false
@State private var suppressNextFocusLostRevert: Bool = false
@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 isBrowserImportHintPopoverPresented = false
@State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var pendingAddressBarFocusRetryRequestId: UUID?
@State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0
@State private var isBrowserProfileMenuPresented = false
@State private var isBrowserThemeMenuPresented = false
@State private var browserChromeStyle = BrowserChromeStyle.resolve(
for: .light,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
@State private var toggleBrowserDeveloperToolsShortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
// Keep this below half of the compact omnibar height so it reads as a squircle,
// not a capsule.
private let omnibarPillCornerRadius: CGFloat = 10
private let addressBarButtonSize: CGFloat = 22
private let addressBarButtonHitSize: CGFloat = 26
private let addressBarVerticalPadding: CGFloat = 4
private let devToolsButtonIconSize: CGFloat = 11
private var searchEngine: BrowserSearchEngine {
BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine
}
private var searchSuggestionsEnabled: Bool {
// Touch @AppStorage so SwiftUI invalidates this view when settings change.
_ = searchSuggestionsEnabledStorage
return BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: .standard)
}
private var remoteSuggestionsEnabled: Bool {
// Deterministic UI-test hook: force remote path on even if a persisted
// setting disabled suggestions in previous sessions.
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] != nil ||
UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON") != nil {
return true
}
// Keep UI tests deterministic by disabling network suggestions when requested.
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] == "1" {
return false
}
return searchSuggestionsEnabled
}
private var devToolsIconOption: BrowserDevToolsIconOption {
BrowserDevToolsIconOption(rawValue: devToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon
}
private var devToolsColorOption: BrowserDevToolsIconColorOption {
BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
}
private var browserThemeMode: BrowserThemeMode {
BrowserThemeSettings.mode(for: browserThemeModeRaw)
}
private var browserImportHintVariant: BrowserImportHintVariant {
BrowserImportHintSettings.variant(for: browserImportHintVariantRaw)
}
private var browserImportHintPresentation: BrowserImportHintPresentation {
BrowserImportHintPresentation(
variant: browserImportHintVariant,
showOnBlankTabs: showBrowserImportHintOnBlankTabs,
isDismissed: isBrowserImportHintDismissed
)
}
private var browserToolbarAccessorySpacing: CGFloat {
CGFloat(BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw))
}
private var browserProfilePopoverHorizontalPadding: CGFloat {
CGFloat(BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw))
}
private var browserProfilePopoverVerticalPadding: CGFloat {
CGFloat(BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw))
}
private var browserChromeBackground: Color {
Color(nsColor: browserChromeStyle.backgroundColor)
}
private var browserChromeBackgroundColor: NSColor {
browserChromeStyle.backgroundColor
}
private var browserChromeColorScheme: ColorScheme {
browserChromeStyle.colorScheme
}
private var browserContentAccessibilityIdentifier: String {
"BrowserPanelContent.\(panel.id.uuidString)"
}
private var omnibarPillBackgroundColor: NSColor {
browserChromeStyle.omnibarPillBackgroundColor
}
private var developerToolsButtonHelp: String {
let base = String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools")
return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))"
}
private var browserImportHintSummary: String {
InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers)
}
private var shouldShowToolbarImportHintChip: Bool {
shouldShowEmptyStateImportOverlay && browserImportHintPresentation.blankTabPlacement == .toolbarChip
}
private var owningWorkspace: Workspace? {
guard let app = AppDelegate.shared,
let manager = app.tabManagerFor(tabId: panel.workspaceId) else {
return nil
}
return manager.tabs.first(where: { $0.id == panel.workspaceId })
}
private var isCurrentPaneOwner: Bool {
guard let currentPaneId = owningWorkspace?.paneId(forPanelId: panel.id) else {
return false
}
return currentPaneId.id == paneId.id
}
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
.fixedSize(horizontal: false, vertical: true)
webView
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.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 WindowBrowserPortal to avoid layering/clipping issues.
if !panel.shouldRenderWebView, let searchState = panel.searchState {
BrowserSearchOverlay(
panelId: panel.id,
searchState: searchState,
focusRequestGeneration: panel.searchFocusRequestGeneration,
canApplyFocusRequest: { generation in
canApplyBrowserFindFieldFocusRequest(generation)
},
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
onClose: { panel.hideFind() },
onFieldDidFocus: { panel.noteFindFieldFocused() }
)
}
}
.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(
engineName: searchEngine.displayName,
items: omnibarState.suggestions,
selectedIndex: omnibarState.selectedSuggestionIndex,
isLoadingRemoteSuggestions: isLoadingRemoteSuggestions,
searchSuggestionsEnabled: remoteSuggestionsEnabled,
onCommit: { item in
commitSuggestion(item)
},
onHighlight: { idx in
let effects = omnibarReduce(state: &omnibarState, event: .highlightIndex(idx))
applyOmnibarEffects(effects)
}
)
.frame(width: omnibarPillFrame.width)
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3)
.zIndex(1000)
.environment(\.colorScheme, browserChromeColorScheme)
}
}
.coordinateSpace(name: "BrowserPanelViewSpace")
.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 }
return webView === panel?.webView
}) { _ in
#if DEBUG
dlog(
"browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
"isFocused=\(isFocused ? 1 : 0) " +
"addressFocused=\(addressBarFocused ? 1 : 0)"
)
#endif
if addressBarFocused {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.webViewClickBlur")
#endif
setAddressBarFocused(false, reason: "webView.clickIntent")
}
if !isFocused {
onRequestPanelFocus()
}
}
.onAppear {
UserDefaults.standard.register(defaults: [
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
BrowserToolbarAccessorySpacingDebugSettings.key: BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing,
BrowserProfilePopoverDebugSettings.horizontalPaddingKey: BrowserProfilePopoverDebugSettings.defaultHorizontalPadding,
BrowserProfilePopoverDebugSettings.verticalPaddingKey: BrowserProfilePopoverDebugSettings.defaultVerticalPadding,
BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue,
])
refreshBrowserChromeStyle()
refreshToggleBrowserDeveloperToolsShortcut()
let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard)
if browserThemeModeRaw != resolvedThemeMode.rawValue {
browserThemeModeRaw = resolvedThemeMode.rawValue
}
let resolvedHintVariant = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw)
if browserImportHintVariantRaw != resolvedHintVariant.rawValue {
browserImportHintVariantRaw = resolvedHintVariant.rawValue
}
let resolvedToolbarAccessorySpacing = BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw)
if browserToolbarAccessorySpacingRaw != resolvedToolbarAccessorySpacing {
browserToolbarAccessorySpacingRaw = resolvedToolbarAccessorySpacing
}
let resolvedProfilePopoverHorizontalPadding = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw)
if browserProfilePopoverHorizontalPaddingRaw != resolvedProfilePopoverHorizontalPadding {
browserProfilePopoverHorizontalPaddingRaw = resolvedProfilePopoverHorizontalPadding
}
let resolvedProfilePopoverVerticalPadding = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw)
if browserProfilePopoverVerticalPaddingRaw != resolvedProfilePopoverVerticalPadding {
browserProfilePopoverVerticalPaddingRaw = resolvedProfilePopoverVerticalPadding
}
panel.refreshAppearanceDrivenColors()
panel.setBrowserThemeMode(browserThemeMode)
applyPendingAddressBarFocusRequestIfNeeded()
syncURLFromPanel()
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
refreshEmptyStateImportBrowsers()
panel.historyStore.loadIfNeeded()
#if DEBUG
logBrowserFocusState(event: "view.onAppear")
#endif
}
.onChange(of: panel.focusFlashToken) { _ in
triggerFocusFlashAnimation()
}
.onChange(of: panel.currentURL) { _ in
let addressWasEmpty = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
syncURLFromPanel()
// If we auto-focused a blank omnibar but then a URL loads programmatically, move focus
// into WebKit unless the user had already started typing.
if addressBarFocused,
!panel.shouldSuppressWebViewFocus(),
addressWasEmpty,
!isWebViewBlank() {
setAddressBarFocused(false, reason: "panel.currentURL.loaded")
}
if isWebViewBlank() {
refreshEmptyStateImportBrowsers()
}
panel.resetReactGrabState()
}
.onChange(of: browserThemeModeRaw) { _ in
let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw)
if browserThemeModeRaw != normalizedMode.rawValue {
browserThemeModeRaw = normalizedMode.rawValue
}
panel.setBrowserThemeMode(normalizedMode)
}
.onChange(of: colorScheme) { _ in
refreshBrowserChromeStyle()
panel.refreshAppearanceDrivenColors()
}
.onChange(of: toggleBrowserDeveloperToolsShortcutData) { _ in
refreshToggleBrowserDeveloperToolsShortcut()
}
.onChange(of: panel.pendingAddressBarFocusRequestId) { _ in
applyPendingAddressBarFocusRequestIfNeeded()
}
.onChange(of: panel.profileID) { _ in
panel.historyStore.loadIfNeeded()
if addressBarFocused {
refreshSuggestions()
}
}
.onChange(of: isVisibleInUI) { visibleInUI in
if visibleInUI {
panel.cancelPendingDeveloperToolsVisibilityLossCheck()
return
}
// Pane/workspace churn can briefly mark the browser hidden before the
// final host settles. Only treat a stable hide as a signal to consume
// an attached-inspector X-close.
panel.scheduleDeveloperToolsVisibilityLossCheck()
}
.onChange(of: isFocused) { focused in
#if DEBUG
logBrowserFocusState(
event: "panelFocus.onChange",
detail: "next=\(focused ? 1 : 0)"
)
#endif
// Ensure this view doesn't retain focus while hidden (bonsplit keepAllAlive).
if focused {
applyPendingAddressBarFocusRequestIfNeeded()
autoFocusOmnibarIfBlank()
} else {
panel.invalidateAddressBarPageFocusRestoreAttempts()
hideSuggestions()
setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused")
// Surface switches in split layouts can keep the browser visible, so
// `isVisibleInUI` never flips to false. Check for an attached-inspector
// X-close when focus leaves as well so the persisted intent stays in sync.
DispatchQueue.main.async {
panel.scheduleDeveloperToolsVisibilityLossCheck()
}
}
syncWebViewResponderPolicyWithViewState(
reason: "panelFocusChanged",
isPanelFocusedOverride: focused
)
}
.onChange(of: addressBarFocused) { focused in
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.onChange",
detail: "next=\(focused ? 1 : 0)"
)
#endif
let urlString = panel.preferredURLStringForOmnibar() ?? ""
if focused {
panel.beginSuppressWebViewFocusForAddressBar()
NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: panel.id)
// Only request panel focus if this pane isn't currently focused. When already
// focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit.
if !isFocused {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.requestPanelFocus")
#endif
onRequestPanelFocus()
}
let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString))
applyOmnibarEffects(effects)
refreshInlineCompletion()
} else {
panel.endSuppressWebViewFocusForAddressBar()
NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panel.id)
if suppressNextFocusLostRevert {
suppressNextFocusLostRevert = false
let effects = omnibarReduce(state: &omnibarState, event: .focusLostPreserveBuffer(currentURLString: urlString))
applyOmnibarEffects(effects)
} else {
let effects = omnibarReduce(state: &omnibarState, event: .focusLostRevertBuffer(currentURLString: urlString))
applyOmnibarEffects(effects)
}
inlineCompletion = nil
}
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.onChange.applied")
#endif
}
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return }
guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return }
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.moveSelection", detail: "delta=\(delta)")
#endif
let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta))
applyOmnibarEffects(effects)
refreshInlineCompletion()
}
.onReceive(panel.historyStore.$entries) { _ in
guard addressBarFocused else { return }
refreshSuggestions()
}
.onReceive(NotificationCenter.default.publisher(for: .browserDidBlurAddressBar).filter { note in
guard let panelId = note.object as? UUID else { return false }
return panelId == panel.id
}) { _ in
if addressBarFocused {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.externalBlur")
#endif
setAddressBarFocused(false, reason: "notification.externalBlur")
}
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
refreshBrowserChromeStyle()
}
}
private var addressBar: some View {
HStack(spacing: 8) {
addressBarButtonBar
omnibarField
.accessibilityIdentifier("BrowserOmnibarPill")
.accessibilityLabel("Browser omnibar")
HStack(spacing: browserToolbarAccessorySpacing) {
if shouldShowToolbarImportHintChip {
browserImportHintToolbarChip
}
reactGrabButton
browserProfileButton
browserThemeModeButton
developerToolsButton
}
}
.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)
}
private var addressBarButtonBar: some View {
return HStack(spacing: 0) {
Button(action: {
#if DEBUG
dlog("browser.back panel=\(panel.id.uuidString.prefix(5))")
#endif
panel.goBack()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 12, weight: .medium))
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
.contentShape(Rectangle())
}
.buttonStyle(OmnibarAddressButtonStyle())
.disabled(!panel.canGoBack)
.opacity(panel.canGoBack ? 1.0 : 0.4)
.safeHelp(String(localized: "browser.goBack", defaultValue: "Go Back"))
Button(action: {
#if DEBUG
dlog("browser.forward panel=\(panel.id.uuidString.prefix(5))")
#endif
panel.goForward()
}) {
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
.contentShape(Rectangle())
}
.buttonStyle(OmnibarAddressButtonStyle())
.disabled(!panel.canGoForward)
.opacity(panel.canGoForward ? 1.0 : 0.4)
.safeHelp(String(localized: "browser.goForward", defaultValue: "Go Forward"))
Button(action: {
if panel.isLoading {
#if DEBUG
dlog("browser.stop panel=\(panel.id.uuidString.prefix(5))")
#endif
panel.stopLoading()
} else {
#if DEBUG
dlog("browser.reload panel=\(panel.id.uuidString.prefix(5))")
#endif
panel.reload()
}
}) {
Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise")
.font(.system(size: 12, weight: .medium))
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
.contentShape(Rectangle())
}
.buttonStyle(OmnibarAddressButtonStyle())
.safeHelp(panel.isLoading ? String(localized: "browser.stop", defaultValue: "Stop") : String(localized: "browser.reload", defaultValue: "Reload"))
if panel.isDownloading {
HStack(spacing: 4) {
ProgressView()
.controlSize(.small)
Text(String(localized: "browser.downloading", defaultValue: "Downloading..."))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.padding(.leading, 6)
.safeHelp(String(localized: "browser.downloadInProgress", defaultValue: "Download in progress"))
}
}
}
private var reactGrabButton: some View {
Button(action: {
Task { await panel.toggleOrInjectReactGrab() }
}) {
Image(systemName: "cursorarrow.click.2")
.symbolRenderingMode(.monochrome)
.cmuxFlatSymbolColorRendering()
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(panel.isReactGrabActive ? Color.accentColor : Color.secondary)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.safeHelp(String(localized: "browser.reactGrab", defaultValue: "Inject React Grab"))
.accessibilityIdentifier("BrowserReactGrabButton")
}
private var developerToolsButton: some View {
Button(action: {
openDevTools()
}) {
Image(systemName: devToolsIconOption.rawValue)
.symbolRenderingMode(.monochrome)
.cmuxFlatSymbolColorRendering()
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(devToolsColorOption.color)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.safeHelp(developerToolsButtonHelp)
.accessibilityIdentifier("BrowserToggleDevToolsButton")
}
private var browserProfileButton: some View {
Button(action: {
isBrowserProfileMenuPresented.toggle()
}) {
Image(systemName: "person.crop.circle")
.symbolRenderingMode(.monochrome)
.cmuxFlatSymbolColorRendering()
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(devToolsColorOption.color)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.popover(isPresented: $isBrowserProfileMenuPresented, arrowEdge: .bottom) {
browserProfilePopover
}
.safeHelp(
String(
format: String(
localized: "browser.profile.buttonHelp",
defaultValue: "Browser Profile: %@"
),
panel.profileDisplayName
)
)
.accessibilityIdentifier("BrowserProfileButton")
}
private var browserThemeModeButton: some View {
Button(action: {
isBrowserThemeMenuPresented.toggle()
}) {
Image(systemName: browserThemeMode.iconName)
.symbolRenderingMode(.monochrome)
.cmuxFlatSymbolColorRendering()
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(browserThemeModeIconColor)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) {
browserThemeModePopover
}
.safeHelp(
String(
format: String(
localized: "browser.theme.buttonHelp",
defaultValue: "Browser Theme: %@"
),
browserThemeMode.displayName
)
)
.accessibilityIdentifier("BrowserThemeModeButton")
}
private var browserImportHintToolbarChip: some View {
Button(action: {
isBrowserImportHintPopoverPresented.toggle()
}) {
HStack(spacing: 4) {
Image(systemName: "square.and.arrow.down.on.square")
.font(.system(size: 10, weight: .medium))
Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import"))
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
}
.foregroundStyle(devToolsColorOption.color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
.buttonStyle(OmnibarAddressButtonStyle())
.popover(isPresented: $isBrowserImportHintPopoverPresented, arrowEdge: .bottom) {
browserImportHintPopover
}
.safeHelp(String(localized: "browser.import.hint.toolbar.help", defaultValue: "Import browser data"))
.accessibilityIdentifier("BrowserImportHintToolbarChip")
}
private var browserProfilePopover: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
ForEach(browserProfileStore.profiles) { profile in
Button {
applyBrowserProfileSelection(profile.id)
} label: {
HStack(spacing: 8) {
Image(systemName: profile.id == panel.profileID ? "checkmark" : "circle")
.font(.system(size: 10, weight: .semibold))
.opacity(profile.id == panel.profileID ? 1.0 : 0.0)
.frame(width: 12, alignment: .center)
Text(profile.displayName)
.font(.system(size: 12))
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(height: 24)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(profile.id == panel.profileID ? Color.primary.opacity(0.12) : Color.clear)
)
}
.buttonStyle(.plain)
}
}
Divider()
Button {
isBrowserProfileMenuPresented = false
presentCreateBrowserProfilePrompt()
} label: {
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
.font(.system(size: 12))
}
.buttonStyle(.plain)
Button {
presentImportDialogFromProfileMenu()
} label: {
Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"))
.font(.system(size: 12))
}
.buttonStyle(.plain)
if browserProfileStore.canRenameProfile(id: panel.profileID) {
Button {
isBrowserProfileMenuPresented = false
presentRenameBrowserProfilePrompt()
} label: {
Text(String(localized: "browser.profile.rename", defaultValue: "Rename Current Profile..."))
.font(.system(size: 12))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, browserProfilePopoverHorizontalPadding)
.padding(.vertical, browserProfilePopoverVerticalPadding)
.frame(minWidth: 208)
}
private var browserThemeModePopover: some View {
VStack(alignment: .leading, spacing: 2) {
ForEach(BrowserThemeMode.allCases) { mode in
Button {
applyBrowserThemeModeSelection(mode)
isBrowserThemeMenuPresented = false
} label: {
HStack(spacing: 8) {
Image(systemName: mode == browserThemeMode ? "checkmark" : "circle")
.font(.system(size: 10, weight: .semibold))
.opacity(mode == browserThemeMode ? 1.0 : 0.0)
.frame(width: 12, alignment: .center)
Text(mode.displayName)
.font(.system(size: 12))
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(height: 24)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(mode == browserThemeMode ? Color.primary.opacity(0.12) : Color.clear)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("BrowserThemeModeOption\(mode.rawValue.capitalized)")
}
}
.padding(8)
.frame(minWidth: 128)
}
private var browserThemeModeIconColor: Color {
devToolsColorOption.color
}
private var omnibarField: some View {
let showSecureBadge = panel.currentURL?.scheme == "https"
return HStack(spacing: 4) {
if showSecureBadge {
Image(systemName: "lock.fill")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
OmnibarTextFieldRepresentable(
text: Binding(
get: { omnibarState.buffer },
set: { newValue in
let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(newValue))
applyOmnibarEffects(effects)
refreshInlineCompletion()
}
),
isFocused: $addressBarFocused,
inlineCompletion: inlineCompletion,
placeholder: String(localized: "browser.addressBar.placeholder", defaultValue: "Search or enter URL"),
onTap: {
handleOmnibarTap()
},
onSubmit: {
if addressBarFocused, !omnibarState.suggestions.isEmpty {
commitSelectedSuggestion()
} else {
panel.navigateSmart(omnibarState.buffer)
hideSuggestions()
suppressNextFocusLostRevert = true
setAddressBarFocused(false, reason: "omnibar.submit.navigate")
}
},
onEscape: {
handleOmnibarEscape()
},
onFieldLostFocus: {
setAddressBarFocused(false, reason: "omnibar.fieldLostFocus")
},
onMoveSelection: { delta in
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return }
let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta))
applyOmnibarEffects(effects)
refreshInlineCompletion()
},
onDeleteSelectedSuggestion: {
deleteSelectedSuggestionIfPossible()
},
onAcceptInlineCompletion: {
acceptInlineCompletion()
},
onDeleteBackwardWithInlineSelection: {
handleInlineBackspace()
},
onSelectionChanged: { selectionRange, hasMarkedText in
handleOmnibarSelectionChange(range: selectionRange, hasMarkedText: hasMarkedText)
},
shouldSuppressWebViewFocus: {
panel.shouldSuppressWebViewFocus()
}
)
.frame(height: 18)
.accessibilityIdentifier("BrowserOmnibarTextField")
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
.fill(Color(nsColor: omnibarPillBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
.stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1)
)
.accessibilityElement(children: .contain)
.background {
GeometryReader { geo in
Color.clear
.preference(
key: OmnibarPillFramePreferenceKey.self,
value: geo.frame(in: .named("BrowserPanelViewSpace"))
)
}
}
}
private var webView: some View {
let useLocalInlineDeveloperToolsHosting =
panel.shouldUseLocalInlineDeveloperToolsHosting() &&
isVisibleInUI &&
isCurrentPaneOwner
return Group {
if panel.shouldRenderWebView {
WebViewRepresentable(
panel: panel,
paneId: paneId,
shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting,
useLocalInlineHosting: useLocalInlineDeveloperToolsHosting,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused,
portalZPriority: portalPriority,
paneDropZone: paneDropZone,
searchOverlay: panel.searchState.map { searchState in
BrowserPortalSearchOverlayConfiguration(
panelId: panel.id,
searchState: searchState,
focusRequestGeneration: panel.searchFocusRequestGeneration,
canApplyFocusRequest: { generation in
canApplyBrowserFindFieldFocusRequest(generation)
},
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
onClose: { panel.hideFind() },
onFieldDidFocus: { panel.noteFindFieldFocused() }
)
},
paneTopChromeHeight: addressBarHeight
)
.accessibilityIdentifier("BrowserWebViewSurface")
// Keep the host stable for normal pane churn, but force a remount when
// BrowserPanel replaces its underlying WKWebView after process termination
// or when the browser moves to a different Bonsplit pane host.
.id("\(panel.webViewInstanceID.uuidString)-\(paneId.id.uuidString)")
.contentShape(Rectangle())
.accessibilityIdentifier(browserContentAccessibilityIdentifier)
.simultaneousGesture(TapGesture().onEnded {
// Chrome-like behavior: clicking web content while editing the
// omnibar should commit blur and revert transient edits.
if addressBarFocused {
#if DEBUG
logBrowserFocusState(event: "webContent.tapBlur")
#endif
setAddressBarFocused(false, reason: "webContent.tapBlur")
}
})
} else {
Color(nsColor: browserChromeBackgroundColor)
.contentShape(Rectangle())
.accessibilityIdentifier(browserContentAccessibilityIdentifier)
.onTapGesture {
onRequestPanelFocus()
if addressBarFocused {
setAddressBarFocused(false, reason: "placeholderContent.tapBlur")
}
}
.overlay(alignment: .topLeading) {
if shouldShowEmptyStateImportOverlay,
browserImportHintPresentation.blankTabPlacement == .inlineStrip {
emptyBrowserStateInlineStrip
}
}
.overlay {
if shouldShowEmptyStateImportOverlay,
browserImportHintPresentation.blankTabPlacement == .floatingCard {
emptyBrowserStateCardOverlay
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.layoutPriority(1)
.zIndex(0)
}
private func triggerFocusFlashAnimation() {
focusFlashAnimationGeneration &+= 1
let generation = focusFlashAnimationGeneration
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
for segment in FocusFlashPattern.segments {
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
guard focusFlashAnimationGeneration == generation else { return }
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
focusFlashOpacity = segment.targetOpacity
}
}
}
}
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
switch curve {
case .easeIn:
return .easeIn(duration: duration)
case .easeOut:
return .easeOut(duration: duration)
}
}
private func refreshBrowserChromeStyle() {
browserChromeStyle = BrowserChromeStyle.resolve(
for: colorScheme,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
}
private func refreshToggleBrowserDeveloperToolsShortcut() {
toggleBrowserDeveloperToolsShortcut = decodeShortcut(
from: toggleBrowserDeveloperToolsShortcutData,
fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
)
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
private func syncWebViewResponderPolicyWithViewState(
reason: String,
isPanelFocusedOverride: Bool? = nil
) {
guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
let isPanelFocused = isPanelFocusedOverride ?? isFocused
let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
if cmuxWebView.allowsFirstResponderAcquisition != next {
#if DEBUG
dlog(
"browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
"new=\(next ? 1 : 0) reason=\(reason) " +
"panelFocusedUsed=\(isPanelFocused ? 1 : 0)"
)
#endif
}
cmuxWebView.allowsFirstResponderAcquisition = next
}
private func setAddressBarFocused(_ focused: Bool, reason: String) {
#if DEBUG
if addressBarFocused == focused {
logBrowserFocusState(
event: "addressBarFocus.write.noop",
detail: "reason=\(reason) value=\(focused ? 1 : 0)"
)
} else {
logBrowserFocusState(
event: "addressBarFocus.write",
detail: "reason=\(reason) old=\(addressBarFocused ? 1 : 0) new=\(focused ? 1 : 0)"
)
}
#endif
addressBarFocused = focused
if focused {
panel.noteAddressBarFocused()
}
}
private func browserFocusResponderChainContains(
_ start: NSResponder?,
target: NSResponder
) -> Bool {
var current = start
var hops = 0
while let responder = current, hops < 64 {
if responder === target { return true }
current = responder.nextResponder
hops += 1
}
return false
}
private func isPanelFocusedInModel() -> Bool {
guard let app = AppDelegate.shared,
let manager = app.tabManagerFor(tabId: panel.workspaceId),
manager.selectedTabId == panel.workspaceId,
let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }) else {
return false
}
return workspace.focusedPanelId == panel.id
}
private func canApplyBrowserFindFieldFocusRequest(_ generation: UInt64) -> Bool {
isPanelFocusedInModel() && panel.canApplySearchFocusRequest(generation)
}
private func shouldApplyAddressBarExitFallback(in window: NSWindow) -> Bool {
// Navigation-triggered omnibar blur can still be unwinding when Cmd+F opens
// the browser find bar. Once find is visible, any delayed omnibar-exit
// handoff must not reclaim first responder for WebKit.
panel.webView.window === window &&
isPanelFocusedInModel() &&
panel.searchState == nil
}
#if DEBUG
private func browserFocusWindow() -> NSWindow? {
panel.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
}
private func browserFocusResponderDescription(_ responder: NSResponder?) -> String {
guard let responder else { return "nil" }
return String(describing: type(of: responder))
}
private func logBrowserFocusState(event: String, detail: String = "") {
let window = browserFocusWindow()
let firstResponder = window?.firstResponder
let firstResponderType = browserFocusResponderDescription(firstResponder)
let webResponder = browserFocusResponderChainContains(firstResponder, target: panel.webView) ? 1 : 0
var line =
"browser.focus.trace event=\(event) panel=\(panel.id.uuidString.prefix(5)) " +
"panelFocused=\(isFocused ? 1 : 0) addrFocused=\(addressBarFocused ? 1 : 0) " +
"suppressWeb=\(panel.shouldSuppressWebViewFocus() ? 1 : 0) " +
"suppressAuto=\(panel.shouldSuppressOmnibarAutofocus() ? 1 : 0) " +
"webResponder=\(webResponder) win=\(window?.windowNumber ?? -1) fr=\(firstResponderType)"
if let pending = panel.pendingAddressBarFocusRequestId {
line += " pending=\(pending.uuidString.prefix(8))"
}
if !detail.isEmpty {
line += " \(detail)"
}
dlog(line)
}
#endif
private func syncURLFromPanel() {
let urlString = panel.preferredURLStringForOmnibar() ?? ""
let effects = omnibarReduce(state: &omnibarState, event: .panelURLChanged(currentURLString: urlString))
applyOmnibarEffects(effects)
}
private func isCommandPaletteVisibleForPanelWindow() -> Bool {
guard let app = AppDelegate.shared else { return false }
if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) {
return true
}
if let manager = app.tabManagerFor(tabId: panel.workspaceId),
let windowId = app.windowId(for: manager),
let window = app.mainWindow(for: windowId),
app.isCommandPaletteVisible(for: window) {
return true
}
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
return true
}
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
return true
}
return false
}
private func clearPendingAddressBarFocusRetry() {
pendingAddressBarFocusRetryRequestId = nil
pendingAddressBarFocusRetryGeneration &+= 1
}
private func schedulePendingAddressBarFocusRetryIfNeeded(requestId: UUID) {
guard pendingAddressBarFocusRetryRequestId != requestId else { return }
pendingAddressBarFocusRetryRequestId = requestId
pendingAddressBarFocusRetryGeneration &+= 1
let generation = pendingAddressBarFocusRetryGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) {
guard pendingAddressBarFocusRetryGeneration == generation else { return }
pendingAddressBarFocusRetryRequestId = nil
guard panel.pendingAddressBarFocusRequestId == requestId else { return }
applyPendingAddressBarFocusRequestIfNeeded()
}
}
private func applyPendingAddressBarFocusRequestIfNeeded() {
guard let requestId = panel.pendingAddressBarFocusRequestId else {
clearPendingAddressBarFocusRetry()
return
}
guard !isCommandPaletteVisibleForPanelWindow() else {
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply.skip",
detail: "reason=command_palette_visible request=\(requestId.uuidString.prefix(8))"
)
#endif
schedulePendingAddressBarFocusRetryIfNeeded(requestId: requestId)
return
}
clearPendingAddressBarFocusRetry()
guard lastHandledAddressBarFocusRequestId != requestId else {
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply.skip",
detail: "reason=already_handled request=\(requestId.uuidString.prefix(8))"
)
#endif
return
}
lastHandledAddressBarFocusRequestId = requestId
panel.beginSuppressWebViewFocusForAddressBar()
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply",
detail: "request=\(requestId.uuidString.prefix(8))"
)
#endif
if addressBarFocused {
// Re-run focus behavior (select-all/refresh suggestions) when focus is
// explicitly requested again while already focused.
let urlString = panel.preferredURLStringForOmnibar() ?? ""
let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString))
applyOmnibarEffects(effects)
refreshInlineCompletion()
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply",
detail: "request=\(requestId.uuidString.prefix(8)) mode=refresh"
)
#endif
} else {
setAddressBarFocused(true, reason: "request.apply")
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.apply",
detail: "request=\(requestId.uuidString.prefix(8)) mode=set_focused"
)
#endif
}
panel.acknowledgeAddressBarFocusRequest(requestId)
#if DEBUG
logBrowserFocusState(
event: "addressBarFocus.request.ack",
detail: "request=\(requestId.uuidString.prefix(8))"
)
#endif
}
private var emptyBrowserStateCardOverlay: some View {
VStack {
Spacer(minLength: 22)
browserImportHintBody
.padding(12)
.frame(maxWidth: 360, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.9))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(
Color(nsColor: .separatorColor).opacity(0.45),
lineWidth: 1
)
)
.shadow(color: Color.black.opacity(0.08), radius: 8, y: 3)
Spacer()
}
.padding(.horizontal, 18)
}
private var emptyBrowserStateInlineStrip: some View {
VStack(alignment: .leading, spacing: 0) {
browserImportHintBody
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: 520, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.84))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(
Color(nsColor: .separatorColor).opacity(0.35),
lineWidth: 1
)
)
.shadow(color: Color.black.opacity(0.05), radius: 6, y: 2)
Spacer(minLength: 0)
}
.padding(.horizontal, 18)
.padding(.top, 14)
}
private var browserImportHintPopover: some View {
browserImportHintBody
.padding(12)
.frame(width: 300, alignment: .leading)
}
private var browserImportHintBody: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data"))
.font(.system(size: 12.5, weight: .semibold))
Text(browserImportHintSummary)
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser."))
.font(.system(size: 10.5))
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) {
browserImportHintPrimaryButton
browserImportHintSettingsButton
browserImportHintDismissButton
}
VStack(alignment: .leading, spacing: 8) {
browserImportHintPrimaryButton
HStack(spacing: 10) {
browserImportHintSettingsButton
browserImportHintDismissButton
}
}
}
}
.accessibilityElement(children: .contain)
}
private var browserImportHintPrimaryButton: some View {
Button(String(localized: "browser.import.hint.import", defaultValue: "Import…")) {
presentImportDialogFromHint()
}
.buttonStyle(.bordered)
.controlSize(.small)
.accessibilityIdentifier("BrowserImportHintImportButton")
}
private var browserImportHintSettingsButton: some View {
Button(String(localized: "browser.import.hint.settings", defaultValue: "Browser Settings")) {
openBrowserImportSettings()
}
.buttonStyle(.plain)
.controlSize(.small)
.accessibilityIdentifier("BrowserImportHintSettingsButton")
}
private var browserImportHintDismissButton: some View {
Button(String(localized: "browser.import.hint.dismiss", defaultValue: "Hide Hint")) {
dismissBrowserImportHint()
}
.buttonStyle(.plain)
.controlSize(.small)
.accessibilityIdentifier("BrowserImportHintDismissButton")
}
private var shouldShowEmptyStateImportOverlay: Bool {
!panel.shouldRenderWebView && isWebViewBlank()
}
private func presentImportDialogFromHint() {
isBrowserImportHintPopoverPresented = false
// Let the popover fully dismiss before entering the modal import flow.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
BrowserDataImportCoordinator.shared.presentImportDialog(
defaultDestinationProfileID: panel.profileID
)
}
}
private func presentImportDialogFromProfileMenu() {
isBrowserProfileMenuPresented = false
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog(
defaultDestinationProfileID: panel.profileID
)
}
}
private func openBrowserImportSettings() {
isBrowserImportHintPopoverPresented = false
AppDelegate.presentPreferencesWindow(navigationTarget: .browserImport)
}
private func dismissBrowserImportHint() {
showBrowserImportHintOnBlankTabs = false
isBrowserImportHintDismissed = true
isBrowserImportHintPopoverPresented = false
}
/// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes.
private func isWebViewBlank() -> Bool {
guard let url = panel.webView.url else { return true }
return url.absoluteString == "about:blank"
}
private func autoFocusOmnibarIfBlank() {
guard isFocused else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=panel_not_focused")
#endif
return
}
guard !addressBarFocused else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=already_focused")
#endif
return
}
guard !isCommandPaletteVisibleForPanelWindow() else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=command_palette_visible")
#endif
return
}
// If a test/automation explicitly focused WebKit, don't steal focus back.
guard !panel.shouldSuppressOmnibarAutofocus() else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=autofocus_suppressed")
#endif
return
}
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
guard !panel.webView.isLoading else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_loading")
#endif
return
}
guard isWebViewBlank() else {
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=webview_not_blank")
#endif
return
}
setAddressBarFocused(true, reason: "autoFocus.blank")
#if DEBUG
logBrowserFocusState(event: "addressBarFocus.autoFocus.apply")
#endif
}
private func refreshEmptyStateImportBrowsers() {
emptyStateImportBrowserRefreshTask?.cancel()
emptyStateImportBrowserRefreshGeneration &+= 1
let generation = emptyStateImportBrowserRefreshGeneration
guard shouldShowEmptyStateImportOverlay else {
emptyStateImportBrowsers = []
emptyStateImportBrowserRefreshTask = nil
return
}
emptyStateImportBrowserRefreshTask = Task {
let browsers = await Task.detached(priority: .utility) {
InstalledBrowserDetector.detectInstalledBrowsers()
}.value
guard !Task.isCancelled else { return }
await MainActor.run {
guard emptyStateImportBrowserRefreshGeneration == generation,
shouldShowEmptyStateImportOverlay else { return }
emptyStateImportBrowsers = browsers
emptyStateImportBrowserRefreshTask = nil
}
}
}
private func openDevTools() {
#if DEBUG
dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))")
#endif
if !panel.toggleDeveloperTools() {
NSSound.beep()
}
}
private func applyBrowserThemeModeSelection(_ mode: BrowserThemeMode) {
if browserThemeModeRaw != mode.rawValue {
browserThemeModeRaw = mode.rawValue
}
panel.setBrowserThemeMode(mode)
}
private func handleOmnibarTap() {
#if DEBUG
logBrowserFocusState(event: "addressBar.tap")
#endif
if !addressBarFocused {
// Mark focused before pane selection converges so WebKit focus is not
// briefly re-acquired during `focusPane`.
setAddressBarFocused(true, reason: "omnibar.tap")
}
onRequestPanelFocus()
}
private func hideSuggestions() {
suggestionTask?.cancel()
suggestionTask = nil
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated([]))
applyOmnibarEffects(effects)
isLoadingRemoteSuggestions = false
inlineCompletion = nil
}
private func commitSelectedSuggestion() {
let idx = omnibarState.selectedSuggestionIndex
guard idx >= 0, idx < omnibarState.suggestions.count else { return }
commitSuggestion(omnibarState.suggestions[idx])
}
private func commitSuggestion(_ suggestion: OmnibarSuggestion) {
// Treat this as a commit, not a user edit: don't refetch suggestions while we're navigating away.
omnibarState.buffer = suggestion.completion
omnibarState.isUserEditing = false
switch suggestion.kind {
case .switchToTab(let tabId, let panelId, _, _):
AppDelegate.shared?.tabManager?.focusTab(tabId, surfaceId: panelId)
default:
panel.navigateSmart(suggestion.completion)
}
hideSuggestions()
inlineCompletion = nil
suppressNextFocusLostRevert = true
setAddressBarFocused(false, reason: "suggestion.commit")
}
private func handleOmnibarEscape() {
guard addressBarFocused else { return }
// Chrome-like flow: clear inline completion first, then apply normal escape behavior.
if inlineCompletion != nil {
inlineCompletion = nil
return
}
let effects = omnibarReduce(state: &omnibarState, event: .escape)
applyOmnibarEffects(effects)
refreshInlineCompletion()
}
private func handleOmnibarSelectionChange(range: NSRange, hasMarkedText: Bool) {
omnibarSelectionRange = range
omnibarHasMarkedText = hasMarkedText
refreshInlineCompletion()
}
private func acceptInlineCompletion() {
guard let completion = inlineCompletion else { return }
let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(completion.displayText))
applyOmnibarEffects(effects)
inlineCompletion = nil
}
private func handleInlineBackspace() {
guard let completion = inlineCompletion else { return }
let prefix = completion.typedText
guard !prefix.isEmpty else { return }
let updated = String(prefix.dropLast())
let effects = omnibarReduce(state: &omnibarState, event: .bufferChanged(updated))
applyOmnibarEffects(effects)
omnibarSelectionRange = NSRange(location: updated.utf16.count, length: 0)
refreshInlineCompletion()
}
private func deleteSelectedSuggestionIfPossible() {
let idx = omnibarState.selectedSuggestionIndex
guard idx >= 0, idx < omnibarState.suggestions.count else { return }
let target = omnibarState.suggestions[idx]
guard case .history(let url, _) = target.kind else { return }
guard panel.historyStore.removeHistoryEntry(urlString: url) else { return }
refreshSuggestions()
}
private func applyBrowserProfileSelection(_ profileID: UUID) {
isBrowserProfileMenuPresented = false
let didApply = panel.profileID == profileID || panel.switchToProfile(profileID)
guard didApply else { return }
owningWorkspace?.setPreferredBrowserProfileID(profileID)
}
private func presentCreateBrowserProfilePrompt() {
let alert = NSAlert()
alert.messageText = String(localized: "browser.profile.new.title", defaultValue: "New Browser Profile")
alert.informativeText = String(localized: "browser.profile.new.message", defaultValue: "Create a separate browser profile for cookies, history, and local storage.")
let input = NSTextField(string: "")
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: String(localized: "common.create", defaultValue: "Create"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
guard alert.runModal() == .alertFirstButtonReturn,
let profile = browserProfileStore.createProfile(named: input.stringValue) else {
return
}
applyBrowserProfileSelection(profile.id)
}
private func presentRenameBrowserProfilePrompt() {
guard let profile = browserProfileStore.profileDefinition(id: panel.profileID),
browserProfileStore.canRenameProfile(id: profile.id) else {
return
}
let alert = NSAlert()
alert.messageText = String(localized: "browser.profile.rename.title", defaultValue: "Rename Browser Profile")
alert.informativeText = String(localized: "browser.profile.rename.message", defaultValue: "Choose a new name for this browser profile.")
let input = NSTextField(string: profile.displayName)
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
guard alert.runModal() == .alertFirstButtonReturn else { return }
_ = browserProfileStore.renameProfile(id: profile.id, to: input.stringValue)
}
private func refreshInlineCompletion() {
inlineCompletion = omnibarInlineCompletionForDisplay(
typedText: omnibarState.buffer,
suggestions: omnibarState.suggestions,
isFocused: addressBarFocused,
selectionRange: omnibarSelectionRange,
hasMarkedText: omnibarHasMarkedText
)
}
private func refreshSuggestions() {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
defer {
let trimmedQuery = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
CmuxTypingTiming.logDuration(
path: "browser.omnibar.refreshSuggestions",
startedAt: typingTimingStart,
extra: "focused=\(addressBarFocused ? 1 : 0) queryLen=\(trimmedQuery.utf8.count) suggestionCount=\(omnibarState.suggestions.count)"
)
}
#endif
suggestionTask?.cancel()
suggestionTask = nil
isLoadingRemoteSuggestions = false
guard addressBarFocused else {
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated([]))
applyOmnibarEffects(effects)
return
}
let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
let historyEntries: [BrowserHistoryStore.Entry] = {
if query.isEmpty {
return panel.historyStore.recentSuggestions(limit: 12)
}
return panel.historyStore.suggestions(for: query, limit: 12)
}()
let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12)
let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil
let staleRemote: [String]
if query.isEmpty || isSingleCharacterQuery {
staleRemote = []
} else {
staleRemote = staleRemoteSuggestionsForDisplay(query: query)
}
let resolvedURL = query.isEmpty ? nil : panel.resolveNavigableURL(from: query)
let items = buildOmnibarSuggestions(
query: query,
engineName: searchEngine.displayName,
historyEntries: historyEntries,
openTabMatches: openTabMatches,
remoteQueries: staleRemote,
resolvedURL: resolvedURL,
limit: 8
)
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(items))
applyOmnibarEffects(effects)
refreshInlineCompletion()
guard !query.isEmpty else { return }
if !isSingleCharacterQuery, let forcedRemote = forcedRemoteSuggestionsForUITest() {
latestRemoteSuggestionQuery = query
latestRemoteSuggestions = forcedRemote
let merged = buildOmnibarSuggestions(
query: query,
engineName: searchEngine.displayName,
historyEntries: historyEntries,
openTabMatches: openTabMatches,
remoteQueries: forcedRemote,
resolvedURL: resolvedURL,
limit: 8
)
let forcedEffects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged))
applyOmnibarEffects(forcedEffects)
refreshInlineCompletion()
return
}
guard remoteSuggestionsEnabled else { return }
guard !isSingleCharacterQuery else { return }
guard omnibarInputIntent(for: query) != .urlLike else { return }
// Keep current remote rows visible while fetching fresh predictions.
let engine = searchEngine
isLoadingRemoteSuggestions = true
suggestionTask = Task {
let remote = await BrowserSearchSuggestionService.shared.suggestions(engine: engine, query: query)
if Task.isCancelled { return }
await MainActor.run {
guard addressBarFocused else { return }
let current = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
guard current == query else { return }
latestRemoteSuggestionQuery = query
latestRemoteSuggestions = remote
let merged = buildOmnibarSuggestions(
query: query,
engineName: searchEngine.displayName,
historyEntries: panel.historyStore.suggestions(for: query, limit: 12),
openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12),
remoteQueries: remote,
resolvedURL: panel.resolveNavigableURL(from: query),
limit: 8
)
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged))
applyOmnibarEffects(effects)
refreshInlineCompletion()
isLoadingRemoteSuggestions = false
}
}
}
private func staleRemoteSuggestionsForDisplay(query: String) -> [String] {
staleOmnibarRemoteSuggestionsForDisplay(
query: query,
previousRemoteQuery: latestRemoteSuggestionQuery,
previousRemoteSuggestions: latestRemoteSuggestions
)
}
private func matchingOpenTabSuggestions(for query: String, limit: Int) -> [OmnibarOpenTabMatch] {
guard !query.isEmpty, limit > 0 else { return [] }
let loweredQuery = query.lowercased()
let singleCharacterQuery = omnibarSingleCharacterQuery(for: query)
let includeCurrentPanelForSingleCharacterQuery = singleCharacterQuery != nil
let tabManager = AppDelegate.shared?.tabManager
let currentPanelWorkspaceId = tabManager?.tabs.first(where: { tab in
tab.panels[panel.id] is BrowserPanel
})?.id
var matches: [OmnibarOpenTabMatch] = []
var seenKeys = Set<String>()
func preferredPanelURL(_ browserPanel: BrowserPanel) -> String? {
browserPanel.preferredURLStringForOmnibar()
}
func addMatch(
tabId: UUID,
panelId: UUID,
url: String,
title: String?,
isKnownOpenTab: Bool,
matches: inout [OmnibarOpenTabMatch],
seenKeys: inout Set<String>
) {
let key = "\(tabId.uuidString.lowercased())|\(panelId.uuidString.lowercased())|\(url.lowercased())"
guard !seenKeys.contains(key) else { return }
seenKeys.insert(key)
matches.append(
OmnibarOpenTabMatch(
tabId: tabId,
panelId: panelId,
url: url,
title: title,
isKnownOpenTab: isKnownOpenTab
)
)
}
if includeCurrentPanelForSingleCharacterQuery,
let query = singleCharacterQuery,
let currentURL = preferredPanelURL(panel),
!currentURL.isEmpty {
let rawTitle = panel.pageTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let title = rawTitle.isEmpty ? nil : rawTitle
if omnibarHasSingleCharacterPrefixMatch(query: query, url: currentURL, title: title) {
addMatch(
tabId: currentPanelWorkspaceId ?? panel.workspaceId,
panelId: panel.id,
url: currentURL,
title: title,
isKnownOpenTab: currentPanelWorkspaceId != nil,
matches: &matches,
seenKeys: &seenKeys
)
}
}
guard let tabManager else { return matches }
for tab in tabManager.tabs {
for (panelId, anyPanel) in tab.panels {
guard let browserPanel = anyPanel as? BrowserPanel else { continue }
guard let currentURL = preferredPanelURL(browserPanel),
!currentURL.isEmpty else { continue }
let isCurrentPanel = tab.id == panel.workspaceId && panelId == panel.id
if isCurrentPanel && !includeCurrentPanelForSingleCharacterQuery {
continue
}
let rawTitle = browserPanel.pageTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let title = rawTitle.isEmpty ? nil : rawTitle
let isMatch: Bool = {
if let singleCharacterQuery {
return omnibarHasSingleCharacterPrefixMatch(
query: singleCharacterQuery,
url: currentURL,
title: title
)
}
let haystacks = [
currentURL.lowercased(),
(title ?? "").lowercased(),
]
return haystacks.contains { $0.contains(loweredQuery) }
}()
guard isMatch else { continue }
addMatch(
tabId: tab.id,
panelId: panelId,
url: currentURL,
title: title,
isKnownOpenTab: true,
matches: &matches,
seenKeys: &seenKeys
)
}
}
if matches.count <= limit { return matches }
return Array(matches.prefix(limit))
}
private func forcedRemoteSuggestionsForUITest() -> [String]? {
let raw = ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"]
?? UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON")
guard let raw,
let data = raw.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
return nil
}
let values = parsed.compactMap { item -> String? in
guard let s = item as? String else { return nil }
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
return values.isEmpty ? nil : values
}
private func applyOmnibarEffects(_ effects: OmnibarEffects) {
if effects.shouldRefreshSuggestions {
refreshSuggestions()
}
if effects.shouldSelectAll {
// Apply immediately for fast Cmd+L typing, then retry once in case
// first responder wasn't fully settled on the same runloop.
DispatchQueue.main.async {
NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil)
}
}
if effects.shouldBlurToWebView {
hideSuggestions()
// This transition is stateful: drop omnibar focus suppression before
// attempting responder handoff so WKWebView can actually become first responder.
panel.endSuppressWebViewFocusForAddressBar()
syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.preHandoff")
setAddressBarFocused(false, reason: "effects.blurToWebView")
DispatchQueue.main.async {
guard let window = panel.webView.window,
!panel.webView.isHiddenOrHasHiddenAncestor else { return }
guard shouldApplyAddressBarExitFallback(in: window) else {
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
"result=skip_not_focused"
)
#endif
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
return
}
syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.handoff")
panel.clearWebViewFocusSuppression()
let focusedWebView = window.makeFirstResponder(panel.webView)
if focusedWebView {
panel.noteWebViewFocused()
}
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
"focusedWebView=\(focusedWebView ? 1 : 0)"
)
#endif
panel.restoreAddressBarPageFocusIfNeeded { restored in
guard shouldApplyAddressBarExitFallback(in: window) else {
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
"result=skip_stale_restore restored=\(restored ? 1 : 0)"
)
#endif
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
return
}
var hasWebViewResponder =
browserFocusResponderChainContains(window.firstResponder, target: panel.webView)
if !hasWebViewResponder {
let fallbackFocusedWebView = window.makeFirstResponder(panel.webView)
hasWebViewResponder = fallbackFocusedWebView
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
"fallbackFocusedWebView=\(fallbackFocusedWebView ? 1 : 0) " +
"restored=\(restored ? 1 : 0)"
)
#endif
}
if hasWebViewResponder {
panel.noteWebViewFocused()
}
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
}
}
}
}
}
enum OmnibarInputIntent: Equatable {
case urlLike
case queryLike
case ambiguous
}
struct OmnibarOpenTabMatch: Equatable {
let tabId: UUID
let panelId: UUID
let url: String
let title: String?
let isKnownOpenTab: Bool
init(tabId: UUID, panelId: UUID, url: String, title: String?, isKnownOpenTab: Bool = true) {
self.tabId = tabId
self.panelId = panelId
self.url = url
self.title = title
self.isKnownOpenTab = isKnownOpenTab
}
}
func omnibarInputIntent(for query: String) -> OmnibarInputIntent {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .ambiguous }
if resolveBrowserNavigableURL(trimmed) != nil {
return .urlLike
}
if trimmed.contains(" ") {
return .queryLike
}
if trimmed.contains(".") {
return .ambiguous
}
return .queryLike
}
func omnibarSuggestionCompletion(for suggestion: OmnibarSuggestion) -> String? {
switch suggestion.kind {
case .navigate(let url):
return url
case .history(let url, _):
return url
case .switchToTab(_, _, let url, _):
return url
default:
return nil
}
}
func omnibarSuggestionTitle(for suggestion: OmnibarSuggestion) -> String? {
switch suggestion.kind {
case .history(_, let title):
return title
case .switchToTab(_, _, _, let title):
return title
default:
return nil
}
}
func omnibarSuggestionMatchesTypedPrefix(
typedText: String,
suggestionCompletion: String,
suggestionTitle: String? = nil
) -> Bool {
let trimmedQuery = typedText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedQuery.isEmpty else { return false }
let query = trimmedQuery.lowercased()
let trimmedCompletion = suggestionCompletion.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedCompletion.isEmpty else { return false }
let loweredCompletion = trimmedCompletion.lowercased()
let schemeStripped = stripHTTPSchemePrefix(trimmedCompletion)
let schemeAndWWWStripped = stripHTTPSchemeAndWWWPrefix(trimmedCompletion)
let typedIncludesScheme = query.hasPrefix("https://") || query.hasPrefix("http://")
let typedIncludesWWWPrefix = query.hasPrefix("www.")
if typedIncludesScheme, loweredCompletion.hasPrefix(query) { return true }
if schemeStripped.hasPrefix(query) { return true }
if !typedIncludesWWWPrefix && schemeAndWWWStripped.hasPrefix(query) { return true }
let normalizedTitle = suggestionTitle?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? ""
if !normalizedTitle.isEmpty && normalizedTitle.hasPrefix(query) {
return true
}
return false
}
func omnibarSuggestionSupportsAutocompletion(query: String, suggestion: OmnibarSuggestion) -> Bool {
if case .search = suggestion.kind { return false }
if case .remote = suggestion.kind { return false }
guard let completion = omnibarSuggestionCompletion(for: suggestion) else { return false }
// Reject URLs whose host lacks a TLD (e.g. "https://news." host "news").
if let components = URLComponents(string: completion),
let host = components.host?.lowercased() {
let trimmedHost = host.hasSuffix(".") ? String(host.dropLast()) : host
if !trimmedHost.contains(".") { return false }
}
let title = omnibarSuggestionTitle(for: suggestion)
return omnibarSuggestionMatchesTypedPrefix(
typedText: query,
suggestionCompletion: completion,
suggestionTitle: title
)
}
func omnibarSingleCharacterQuery(for query: String) -> String? {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard trimmed.utf16.count == 1 else { return nil }
return trimmed
}
func omnibarStrippedURL(_ value: String) -> String {
return stripHTTPSchemeAndWWWPrefix(value)
}
func omnibarScoringCandidate(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
if let components = URLComponents(string: trimmed), let host = components.host?.lowercased() {
let hostWithoutWWW = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host
let normalizedScheme = components.scheme?.lowercased()
let isDefaultPort = (normalizedScheme == "http" && components.port == 80)
|| (normalizedScheme == "https" && components.port == 443)
let portSuffix = {
guard let port = components.port, !isDefaultPort else { return "" }
return ":\(port)"
}()
var normalized = "\(hostWithoutWWW)\(portSuffix)"
let path = components.percentEncodedPath
if !path.isEmpty && path != "/" {
normalized += path
} else if path == "/" {
normalized += "/"
}
if let query = components.percentEncodedQuery, !query.isEmpty {
normalized += "?\(query)"
}
if let fragment = components.percentEncodedFragment, !fragment.isEmpty {
normalized += "#\(fragment)"
}
return normalized
}
return stripHTTPSchemeAndWWWPrefix(trimmed)
}
func omnibarHasSingleCharacterPrefixMatch(query: String, url: String, title: String?) -> Bool {
guard let trimmedQuery = omnibarSingleCharacterQuery(for: query) else { return false }
let normalizedURL = omnibarStrippedURL(url).lowercased()
let normalizedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
return normalizedURL.hasPrefix(trimmedQuery) || normalizedTitle.hasPrefix(trimmedQuery)
}
func buildOmnibarSuggestions(
query: String,
engineName: String,
historyEntries: [BrowserHistoryStore.Entry],
openTabMatches: [OmnibarOpenTabMatch] = [],
remoteQueries: [String],
resolvedURL: URL?,
limit: Int = 8,
now: Date = Date()
) -> [OmnibarSuggestion] {
guard limit > 0 else { return [] }
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedQuery.isEmpty {
return Array(historyEntries.prefix(limit).map { .history($0) })
}
let singleCharacterQuery = omnibarSingleCharacterQuery(for: trimmedQuery)
let isSingleCharacterQuery = singleCharacterQuery != nil
let shouldIncludeRemoteSuggestions = !isSingleCharacterQuery
let filteredHistoryEntries: [BrowserHistoryStore.Entry]
let filteredOpenTabMatches: [OmnibarOpenTabMatch]
if let singleCharacterQuery {
filteredHistoryEntries = historyEntries.filter {
omnibarHasSingleCharacterPrefixMatch(query: singleCharacterQuery, url: $0.url, title: $0.title)
}
filteredOpenTabMatches = openTabMatches.filter {
omnibarHasSingleCharacterPrefixMatch(query: singleCharacterQuery, url: $0.url, title: $0.title)
}
} else {
filteredHistoryEntries = historyEntries
filteredOpenTabMatches = openTabMatches
}
let shouldSuppressSingleCharacterSearchResult = isSingleCharacterQuery
&& (!filteredHistoryEntries.isEmpty || !filteredOpenTabMatches.isEmpty)
struct RankedSuggestion {
let suggestion: OmnibarSuggestion
let score: Double
let order: Int
let isAutocompletableMatch: Bool
let kindPriority: Int
}
var bestByCompletion: [String: RankedSuggestion] = [:]
var order = 0
let intent = omnibarInputIntent(for: trimmedQuery)
let normalizedQuery = trimmedQuery.lowercased()
func suggestionPriority(for kind: OmnibarSuggestion.Kind) -> Int {
switch kind {
case .search:
return 300
case .remote:
return 350
default:
return 0
}
}
func completionScore(for candidate: String) -> Double {
let c = candidate.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let q = normalizedQuery
guard !c.isEmpty, !q.isEmpty else { return 0 }
let scoringCandidate = omnibarScoringCandidate(c)
if !scoringCandidate.isEmpty {
if scoringCandidate == q { return 260 }
if scoringCandidate.hasPrefix(q) { return 220 }
if scoringCandidate.contains(q) { return 150 }
}
if c == q { return 240 }
if c.hasPrefix(q) { return 170 }
if c.contains(q) { return 95 }
return 0
}
func insert(_ suggestion: OmnibarSuggestion, score: Double) {
let key = suggestion.completion.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !key.isEmpty else { return }
let isAutocompletableMatch = omnibarSuggestionSupportsAutocompletion(query: trimmedQuery, suggestion: suggestion)
let ranked = RankedSuggestion(
suggestion: suggestion,
score: score,
order: order,
isAutocompletableMatch: isAutocompletableMatch,
kindPriority: suggestionPriority(for: suggestion.kind)
)
order += 1
if let existing = bestByCompletion[key] {
let shouldReplaceExisting: Bool = {
// For identical completions, keep "go to URL" over "switch to tab" so
// pressing Enter performs navigation unless the user explicitly picks a tab row.
switch (existing.suggestion.kind, ranked.suggestion.kind) {
case (.navigate, .switchToTab):
return false
case (.switchToTab, .navigate):
return true
default:
return ranked.score > existing.score
}
}()
if shouldReplaceExisting {
bestByCompletion[key] = ranked
}
} else {
bestByCompletion[key] = ranked
}
}
if !(isSingleCharacterQuery && shouldSuppressSingleCharacterSearchResult) {
let searchBaseScore: Double
switch intent {
case .queryLike: searchBaseScore = 820
case .ambiguous: searchBaseScore = 540
case .urlLike: searchBaseScore = 140
}
insert(.search(engineName: engineName, query: trimmedQuery), score: searchBaseScore + completionScore(for: trimmedQuery))
}
if let resolvedURL {
let completion = resolvedURL.absoluteString
let navigateBaseScore: Double
switch intent {
case .urlLike: navigateBaseScore = 1_020
case .ambiguous: navigateBaseScore = 760
case .queryLike: navigateBaseScore = 470
}
insert(.navigate(url: completion), score: navigateBaseScore + completionScore(for: completion))
}
for (index, entry) in filteredHistoryEntries.prefix(max(limit * 2, limit)).enumerated() {
let intentBaseScore: Double
switch intent {
case .urlLike: intentBaseScore = 780
case .ambiguous: intentBaseScore = 690
case .queryLike: intentBaseScore = 600
}
let urlMatch = completionScore(for: entry.url)
let titleMatch = completionScore(for: entry.title ?? "") * 0.6
let ageHours = max(0, now.timeIntervalSince(entry.lastVisited) / 3600)
let recencyScore = max(0, 75 - (ageHours / 5))
let visitScore = min(95, log1p(Double(max(1, entry.visitCount))) * 32)
let typedScore = min(230, log1p(Double(max(0, entry.typedCount))) * 100)
let typedRecencyScore: Double
if let lastTypedAt = entry.lastTypedAt {
let typedAgeHours = max(0, now.timeIntervalSince(lastTypedAt) / 3600)
typedRecencyScore = max(0, 80 - (typedAgeHours / 5))
} else {
typedRecencyScore = 0
}
let positionScore = Double(max(0, 16 - index))
let total = intentBaseScore + urlMatch + titleMatch + recencyScore + visitScore + typedScore + typedRecencyScore + positionScore
insert(.history(entry), score: total)
}
for (index, match) in filteredOpenTabMatches.prefix(limit).enumerated() {
let intentBaseScore: Double
switch intent {
case .urlLike: intentBaseScore = 1_180
case .ambiguous: intentBaseScore = 980
case .queryLike: intentBaseScore = 820
}
let urlMatch = completionScore(for: match.url)
let titleMatch = completionScore(for: match.title ?? "") * 0.65
let positionScore = Double(max(0, 14 - index)) * 0.9
let resolvedURLBonus: Double
if let resolvedURL,
resolvedURL.absoluteString.caseInsensitiveCompare(match.url) == .orderedSame {
resolvedURLBonus = 120
} else {
resolvedURLBonus = 0
}
let total = intentBaseScore + urlMatch + titleMatch + positionScore + resolvedURLBonus
if match.isKnownOpenTab {
insert(
.switchToTab(tabId: match.tabId, panelId: match.panelId, url: match.url, title: match.title),
score: total
)
} else {
insert(
OmnibarSuggestion.history(url: match.url, title: match.title),
score: total
)
}
}
if shouldIncludeRemoteSuggestions {
for (index, remoteQuery) in remoteQueries.prefix(limit).enumerated() {
let trimmedRemote = remoteQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedRemote.isEmpty else { continue }
let remoteBaseScore: Double
switch intent {
case .queryLike: remoteBaseScore = 690
case .ambiguous: remoteBaseScore = 450
case .urlLike: remoteBaseScore = 110
}
let positionScore = Double(max(0, 14 - index)) * 0.9
let total = remoteBaseScore + completionScore(for: trimmedRemote) + positionScore
insert(.remoteSearchSuggestion(trimmedRemote), score: total)
}
}
let sorted = bestByCompletion.values.sorted { lhs, rhs in
if lhs.isAutocompletableMatch != rhs.isAutocompletableMatch {
return lhs.isAutocompletableMatch
}
if lhs.score != rhs.score { return lhs.score > rhs.score }
if lhs.kindPriority != rhs.kindPriority {
return lhs.kindPriority < rhs.kindPriority
}
if lhs.order != rhs.order { return lhs.order < rhs.order }
return lhs.suggestion.completion < rhs.suggestion.completion
}
let suggestions = Array(sorted.map(\.suggestion).prefix(limit))
return prioritizedAutocompletionSuggestions(suggestions: Array(suggestions), for: trimmedQuery)
}
private func prioritizedAutocompletionSuggestions(suggestions: [OmnibarSuggestion], for query: String) -> [OmnibarSuggestion] {
guard let preferred = omnibarPreferredAutocompletionSuggestionIndex(
suggestions: suggestions,
query: query
) else {
return suggestions
}
guard preferred != 0 else { return suggestions }
var reordered = suggestions
let suggestion = reordered.remove(at: preferred)
reordered.insert(suggestion, at: 0)
return reordered
}
private func omnibarPreferredAutocompletionSuggestionIndex(
suggestions: [OmnibarSuggestion],
query: String
) -> Int? {
guard !query.isEmpty else { return nil }
var candidates: [(idx: Int, suffixLength: Int)] = []
for (idx, suggestion) in suggestions.enumerated() {
guard omnibarSuggestionSupportsAutocompletion(query: query, suggestion: suggestion) else { continue }
guard let completion = omnibarSuggestionCompletion(for: suggestion) else { continue }
let displayCompletion = omnibarSuggestionMatchesTypedPrefix(
typedText: query,
suggestionCompletion: completion,
suggestionTitle: omnibarSuggestionTitle(for: suggestion)
) ? completion : ""
guard !displayCompletion.isEmpty else { continue }
let suffixLength = max(
0,
omnibarSuggestionDisplayText(forPrefixing: displayCompletion, query: query).utf16.count - query.utf16.count
)
candidates.append((idx: idx, suffixLength: suffixLength))
}
guard let preferred = candidates.min(by: {
if $0.suffixLength != $1.suffixLength {
return $0.suffixLength < $1.suffixLength
}
return $0.idx < $1.idx
})?.idx else {
return nil
}
return preferred
}
private func omnibarSuggestionDisplayText(forPrefixing completion: String, query: String) -> String {
let typedIncludesScheme = query.hasPrefix("https://") || query.hasPrefix("http://")
let typedIncludesWWWPrefix = query.hasPrefix("www.")
if typedIncludesScheme {
return completion
}
if typedIncludesWWWPrefix {
return stripHTTPSchemePrefix(completion)
}
return stripHTTPSchemeAndWWWPrefix(completion)
}
func staleOmnibarRemoteSuggestionsForDisplay(
query: String,
previousRemoteQuery: String,
previousRemoteSuggestions: [String],
limit: Int = 8
) -> [String] {
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedPreviousQuery = previousRemoteQuery.trimmingCharacters(in: .whitespacesAndNewlines)
let loweredQuery = trimmedQuery.lowercased()
let loweredPreviousQuery = trimmedPreviousQuery.lowercased()
guard !trimmedQuery.isEmpty, !trimmedPreviousQuery.isEmpty else { return [] }
guard loweredQuery == loweredPreviousQuery || loweredQuery.hasPrefix(loweredPreviousQuery) || loweredPreviousQuery.hasPrefix(loweredQuery) else {
return []
}
guard !previousRemoteSuggestions.isEmpty else { return [] }
let sanitized = previousRemoteSuggestions.compactMap { raw -> String? in
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return trimmed
}
if sanitized.isEmpty {
return []
}
return Array(sanitized.prefix(limit))
}
func omnibarInlineCompletionForDisplay(
typedText: String,
suggestions: [OmnibarSuggestion],
isFocused: Bool,
selectionRange: NSRange,
hasMarkedText: Bool
) -> OmnibarInlineCompletion? {
guard isFocused else { return nil }
guard !hasMarkedText else { return nil }
let query = typedText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return nil }
let loweredQuery = query.lowercased()
let typedIncludesScheme = loweredQuery.hasPrefix("https://") || loweredQuery.hasPrefix("http://")
let typedIncludesWWWPrefix = loweredQuery.hasPrefix("www.")
let queryCount = query.utf16.count
let urlCandidate = suggestions.first { suggestion in
guard let completion = omnibarSuggestionCompletion(for: suggestion) else { return false }
return omnibarSuggestionMatchesTypedPrefix(
typedText: query,
suggestionCompletion: completion,
suggestionTitle: omnibarSuggestionTitle(for: suggestion)
)
}
guard let candidate = urlCandidate else {
return nil
}
let acceptedText = candidate.completion
let displayText: String
if typedQueryHasExplicitPathOrQuery(query) {
if typedIncludesScheme {
displayText = acceptedText
} else if typedIncludesWWWPrefix {
displayText = stripHTTPSchemePrefix(acceptedText)
} else {
displayText = stripHTTPSchemeAndWWWPrefix(acceptedText)
}
} else if let hostOnlyDisplay = inlineCompletionHostDisplayText(
for: acceptedText,
typedIncludesScheme: typedIncludesScheme,
typedIncludesWWWPrefix: typedIncludesWWWPrefix
) {
displayText = hostOnlyDisplay
} else {
if typedIncludesScheme {
displayText = acceptedText
} else if typedIncludesWWWPrefix {
displayText = stripHTTPSchemePrefix(acceptedText)
} else {
displayText = stripHTTPSchemeAndWWWPrefix(acceptedText)
}
}
guard omnibarSuggestionSupportsAutocompletion(query: query, suggestion: candidate) else { return nil }
// The display text must start with the typed query so the inline completion
// visually extends what the user typed rather than replacing it (e.g. a
// history entry matched via title "localhost:3000" whose URL is google.com
// should not replace a typed "l" with "g").
guard displayText.lowercased().hasPrefix(loweredQuery) else { return nil }
guard displayText.utf16.count > queryCount else {
return nil
}
let displayCount = displayText.utf16.count
let resolvedSelectionRange: NSRange = {
if selectionRange.location == NSNotFound {
return NSRange(location: queryCount, length: 0)
}
let clampedLocation = min(selectionRange.location, displayCount)
let remaining = max(0, displayCount - clampedLocation)
let clampedLength = min(selectionRange.length, remaining)
return NSRange(location: clampedLocation, length: clampedLength)
}()
let suffixRange = NSRange(location: queryCount, length: max(0, displayCount - queryCount))
let isCaretAtTypedBoundary = (resolvedSelectionRange.length == 0 && resolvedSelectionRange.location == queryCount)
let isSuffixSelection = NSEqualRanges(resolvedSelectionRange, suffixRange)
let isSelectAllSelection = (resolvedSelectionRange.location == 0 && resolvedSelectionRange.length == displayCount)
// Command+A can briefly report just the typed prefix selection before the full
// select-all range lands. Keep inline completion alive through that transition.
let typedPrefixSelection = NSRange(location: 0, length: queryCount)
let isTypedPrefixSelection = NSEqualRanges(resolvedSelectionRange, typedPrefixSelection)
guard isCaretAtTypedBoundary || isSuffixSelection || isSelectAllSelection || isTypedPrefixSelection else {
return nil
}
return OmnibarInlineCompletion(typedText: query, displayText: displayText, acceptedText: acceptedText)
}
func omnibarDesiredSelectionRangeForInlineCompletion(
currentSelection: NSRange,
inlineCompletion: OmnibarInlineCompletion
) -> NSRange {
let typedCount = inlineCompletion.typedText.utf16.count
let typedPrefixSelection = NSRange(location: 0, length: typedCount)
let displayCount = inlineCompletion.displayText.utf16.count
let isSelectAll = currentSelection.location == 0 && currentSelection.length == displayCount
if isSelectAll ||
NSEqualRanges(currentSelection, inlineCompletion.suffixRange) ||
NSEqualRanges(currentSelection, typedPrefixSelection) {
return currentSelection
}
return inlineCompletion.suffixRange
}
func omnibarPublishedBufferTextForFieldChange(
fieldValue: String,
inlineCompletion: OmnibarInlineCompletion?,
selectionRange: NSRange?,
hasMarkedText: Bool
) -> String {
guard !hasMarkedText else { return fieldValue }
guard let inlineCompletion else { return fieldValue }
guard fieldValue == inlineCompletion.displayText else { return fieldValue }
guard let selectionRange else { return inlineCompletion.typedText }
let typedCount = inlineCompletion.typedText.utf16.count
let displayCount = inlineCompletion.displayText.utf16.count
let typedPrefixSelection = NSRange(location: 0, length: typedCount)
let isCaretAtTypedBoundary = selectionRange.location == typedCount && selectionRange.length == 0
let isSuffixSelection = NSEqualRanges(selectionRange, inlineCompletion.suffixRange)
let isSelectAllSelection = selectionRange.location == 0 && selectionRange.length == displayCount
let isTypedPrefixSelection = NSEqualRanges(selectionRange, typedPrefixSelection)
if isCaretAtTypedBoundary || isSuffixSelection || isSelectAllSelection || isTypedPrefixSelection {
return inlineCompletion.typedText
}
return fieldValue
}
func omnibarInlineCompletionIfBufferMatchesTypedPrefix(
bufferText: String,
inlineCompletion: OmnibarInlineCompletion?
) -> OmnibarInlineCompletion? {
guard let inlineCompletion else { return nil }
guard bufferText == inlineCompletion.typedText else { return nil }
return inlineCompletion
}
private func typedQueryHasExplicitPathOrQuery(_ typedQuery: String) -> Bool {
var normalized = typedQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.hasPrefix("https://") {
normalized.removeFirst("https://".count)
} else if normalized.hasPrefix("http://") {
normalized.removeFirst("http://".count)
}
return normalized.contains("/") || normalized.contains("?") || normalized.contains("#")
}
private func inlineCompletionHostDisplayText(
for acceptedText: String,
typedIncludesScheme: Bool,
typedIncludesWWWPrefix: Bool
) -> String? {
guard let components = URLComponents(string: acceptedText),
var host = components.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!host.isEmpty else {
return nil
}
if !typedIncludesWWWPrefix, host.hasPrefix("www.") {
host.removeFirst("www.".count)
}
let portSuffix: String
if let port = components.port {
let scheme = components.scheme?.lowercased()
let isDefaultPort =
(scheme == "https" && port == 443) ||
(scheme == "http" && port == 80)
portSuffix = isDefaultPort ? "" : ":\(port)"
} else {
portSuffix = ""
}
let hostWithPort = "\(host)\(portSuffix)"
if typedIncludesScheme {
let scheme = (components.scheme?.lowercased() == "http") ? "http" : "https"
return "\(scheme)://\(hostWithPort)"
}
return hostWithPort
}
private func stripHTTPSchemePrefix(_ raw: String) -> String {
var normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.hasPrefix("https://") {
normalized.removeFirst("https://".count)
} else if normalized.hasPrefix("http://") {
normalized.removeFirst("http://".count)
}
return normalized
}
private func stripHTTPSchemeAndWWWPrefix(_ raw: String) -> String {
var normalized = stripHTTPSchemePrefix(raw)
if normalized.hasPrefix("www.") {
normalized.removeFirst("www.".count)
}
return normalized
}
private struct OmnibarPillFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
let next = nextValue()
if next != .zero {
value = next
}
}
}
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 {
var isFocused: Bool = false
var currentURLString: String = ""
var buffer: String = ""
var suggestions: [OmnibarSuggestion] = []
var selectedSuggestionIndex: Int = 0
var selectedSuggestionID: String?
var isUserEditing: Bool = false
}
enum OmnibarEvent: Equatable {
case focusGained(currentURLString: String)
case focusLostRevertBuffer(currentURLString: String)
case focusLostPreserveBuffer(currentURLString: String)
case panelURLChanged(currentURLString: String)
case bufferChanged(String)
case suggestionsUpdated([OmnibarSuggestion])
case moveSelection(delta: Int)
case highlightIndex(Int)
case escape
}
struct OmnibarEffects: Equatable {
var shouldSelectAll: Bool = false
var shouldBlurToWebView: Bool = false
var shouldRefreshSuggestions: Bool = false
}
@discardableResult
func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEffects {
var effects = OmnibarEffects()
switch event {
case .focusGained(let url):
state.isFocused = true
state.currentURLString = url
state.buffer = url
state.isUserEditing = false
state.suggestions = []
state.selectedSuggestionIndex = 0
state.selectedSuggestionID = nil
effects.shouldSelectAll = true
case .focusLostRevertBuffer(let url):
state.isFocused = false
state.currentURLString = url
state.buffer = url
state.isUserEditing = false
state.suggestions = []
state.selectedSuggestionIndex = 0
state.selectedSuggestionID = nil
case .focusLostPreserveBuffer(let url):
state.isFocused = false
state.currentURLString = url
state.isUserEditing = false
state.suggestions = []
state.selectedSuggestionIndex = 0
state.selectedSuggestionID = nil
case .panelURLChanged(let url):
state.currentURLString = url
if !state.isUserEditing {
state.buffer = url
state.suggestions = []
state.selectedSuggestionIndex = 0
state.selectedSuggestionID = nil
}
case .bufferChanged(let newValue):
state.buffer = newValue
if state.isFocused {
state.isUserEditing = (newValue != state.currentURLString)
state.selectedSuggestionIndex = 0
state.selectedSuggestionID = nil
effects.shouldRefreshSuggestions = true
}
case .suggestionsUpdated(let items):
let previousItems = state.suggestions
let previousSelectedID = state.selectedSuggestionID
state.suggestions = items
if items.isEmpty {
state.selectedSuggestionIndex = 0
state.selectedSuggestionID = nil
} else if let previousSelectedID,
let existingIdx = items.firstIndex(where: { $0.id == previousSelectedID }) {
state.selectedSuggestionIndex = existingIdx
state.selectedSuggestionID = items[existingIdx].id
} else if let preferredSuggestionIndex = omnibarPreferredAutocompletionSuggestionIndex(
suggestions: items,
query: state.buffer
) {
state.selectedSuggestionIndex = preferredSuggestionIndex
state.selectedSuggestionID = items[preferredSuggestionIndex].id
} else if previousItems.isEmpty {
// Popup reopened: start keyboard focus from the first row.
state.selectedSuggestionIndex = 0
state.selectedSuggestionID = items[0].id
} else if let previousSelectedID,
let idx = items.firstIndex(where: { $0.id == previousSelectedID }) {
state.selectedSuggestionIndex = idx
state.selectedSuggestionID = items[idx].id
} else {
state.selectedSuggestionIndex = min(max(0, state.selectedSuggestionIndex), items.count - 1)
state.selectedSuggestionID = items[state.selectedSuggestionIndex].id
}
case .moveSelection(let delta):
guard !state.suggestions.isEmpty else { break }
state.selectedSuggestionIndex = min(
max(0, state.selectedSuggestionIndex + delta),
state.suggestions.count - 1
)
state.selectedSuggestionID = state.suggestions[state.selectedSuggestionIndex].id
case .highlightIndex(let idx):
guard !state.suggestions.isEmpty else { break }
state.selectedSuggestionIndex = min(max(0, idx), state.suggestions.count - 1)
state.selectedSuggestionID = state.suggestions[state.selectedSuggestionIndex].id
case .escape:
guard state.isFocused else { break }
// Chrome semantics:
// - If user input is in progress OR the popup is open: revert to the page URL and select-all.
// - Otherwise: exit omnibar focus.
if state.isUserEditing || !state.suggestions.isEmpty {
state.isUserEditing = false
state.buffer = state.currentURLString
state.suggestions = []
state.selectedSuggestionIndex = 0
state.selectedSuggestionID = nil
effects.shouldSelectAll = true
} else {
effects.shouldBlurToWebView = true
}
}
return effects
}
struct OmnibarSuggestion: Identifiable, Hashable {
enum Kind: Hashable {
case search(engineName: String, query: String)
case navigate(url: String)
case history(url: String, title: String?)
case switchToTab(tabId: UUID, panelId: UUID, url: String, title: String?)
case remote(query: String)
}
let kind: Kind
// Stable identity prevents row teardown/rebuild flicker while typing.
var id: String {
switch kind {
case .search(let engineName, let query):
return "search|\(engineName.lowercased())|\(query.lowercased())"
case .navigate(let url):
return "navigate|\(url.lowercased())"
case .history(let url, _):
return "history|\(url.lowercased())"
case .switchToTab(let tabId, let panelId, let url, _):
return "switch-tab|\(tabId.uuidString.lowercased())|\(panelId.uuidString.lowercased())|\(url.lowercased())"
case .remote(let query):
return "remote|\(query.lowercased())"
}
}
var completion: String {
switch kind {
case .search(_, let q): return q
case .navigate(let url): return url
case .history(let url, _): return url
case .switchToTab(_, _, let url, _): return url
case .remote(let q): return q
}
}
var primaryText: String {
switch kind {
case .search(let engineName, let q):
return "Search \(engineName) for \"\(q)\""
case .navigate(let url):
return Self.displayURLText(for: url)
case .history(let url, let title):
return (title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? Self.singleLineText(title) : Self.displayURLText(for: url)
case .switchToTab(_, _, let url, let title):
return (title?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? Self.singleLineText(title) : Self.displayURLText(for: url)
case .remote(let q):
return q
}
}
var listText: String {
switch kind {
case .history(let url, let title), .switchToTab(_, _, let url, let title):
let titleOneline = Self.singleLineText(title)
guard !titleOneline.isEmpty else { return Self.displayURLText(for: url) }
return "\(titleOneline)\(Self.displayURLText(for: url))"
default:
return primaryText
}
}
var secondaryText: String? {
switch kind {
case .history(let url, let title):
let titleOneline = Self.singleLineText(title)
return titleOneline.isEmpty ? nil : Self.displayURLText(for: url)
case .switchToTab(_, _, let url, let title):
let titleOneline = Self.singleLineText(title)
return titleOneline.isEmpty ? nil : Self.displayURLText(for: url)
default:
return nil
}
}
var trailingBadgeText: String? {
switch kind {
case .switchToTab:
return String(localized: "browser.switchToTab", defaultValue: "Switch to tab")
default:
return nil
}
}
var isHistoryRemovable: Bool {
if case .history = kind { return true }
return false
}
static func history(_ entry: BrowserHistoryStore.Entry) -> OmnibarSuggestion {
OmnibarSuggestion(kind: .history(url: entry.url, title: entry.title))
}
static func history(url: String, title: String?) -> OmnibarSuggestion {
OmnibarSuggestion(kind: .history(url: url, title: title))
}
static func search(engineName: String, query: String) -> OmnibarSuggestion {
OmnibarSuggestion(kind: .search(engineName: engineName, query: query))
}
static func navigate(url: String) -> OmnibarSuggestion {
OmnibarSuggestion(kind: .navigate(url: url))
}
static func switchToTab(tabId: UUID, panelId: UUID, url: String, title: String?) -> OmnibarSuggestion {
OmnibarSuggestion(kind: .switchToTab(tabId: tabId, panelId: panelId, url: url, title: title))
}
private static func singleLineText(_ value: String?) -> String {
var normalized = (value ?? "").replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\t", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
while normalized.contains(" ") {
let collapsed = normalized.replacingOccurrences(of: " ", with: " ")
if collapsed == normalized { break }
normalized = collapsed
}
return normalized
}
static func remoteSearchSuggestion(_ query: String) -> OmnibarSuggestion {
OmnibarSuggestion(kind: .remote(query: query))
}
private static func displayURLText(for rawURL: String) -> String {
guard let components = URLComponents(string: rawURL),
var host = components.host else {
return rawURL
}
if host.hasPrefix("www.") {
host.removeFirst(4)
}
host = host.lowercased()
var result = host
if let port = components.port {
result += ":\(port)"
}
let path = components.percentEncodedPath
if !path.isEmpty, path != "/" {
result += path
} else if path == "/" {
result += "/"
}
if let query = components.percentEncodedQuery, !query.isEmpty {
result += "?\(query)"
}
if result.isEmpty { return rawURL }
return result
}
}
func browserOmnibarShouldReacquireFocusAfterEndEditing(
desiredOmnibarFocus: Bool,
nextResponderIsOtherTextField: Bool
) -> Bool {
desiredOmnibarFocus && !nextResponderIsOtherTextField
}
private final class OmnibarNativeTextField: NSTextField {
var onPointerDown: (() -> Void)?
var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)?
/// Anchor index for Shift+click selection extension, reset on non-shift clicks.
private var shiftClickAnchor: Int?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
isBordered = false
isBezeled = false
drawsBackground = false
focusRingType = .none
lineBreakMode = .byTruncatingTail
usesSingleLineMode = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func mouseDown(with event: NSEvent) {
#if DEBUG
let frType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"browser.omnibarClick win=\(window?.windowNumber ?? -1) " +
"fr=\(frType) hasEditor=\(currentEditor() == nil ? 0 : 1)"
)
#endif
onPointerDown?()
if currentEditor() == nil {
// First click activate editing and select all (standard URL bar behavior).
// Avoids NSTextView's tracking loop which can spin forever if text layout
// enters an infinite invalidation cycle (e.g. under memory pressure).
let result = window?.makeFirstResponder(self) ?? false
#if DEBUG
let frAfter = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"browser.omnibarClick.makeFirstResponder result=\(result ? 1 : 0) " +
"win=\(window?.windowNumber ?? -1) fr=\(frAfter)"
)
#endif
currentEditor()?.selectAll(nil)
shiftClickAnchor = nil
} else {
// Already editing place the cursor at the click position without calling
// super.mouseDown, which enters NSTextView's mouse-tracking loop. That loop
// can spin forever when NSTextLayoutManager.enumerateTextLayoutFragments hits
// an infinite invalidation cycle (see #917). The previous mitigation posted a
// synthetic mouseUp via NSApp.postEvent after a timeout, but the tracking loop
// does not always dequeue events from the application event queue, so the hang
// persisted. By positioning the cursor ourselves we avoid the tracking loop
// entirely. Drag-to-select is not supported in this path, but for a single-line
// omnibar this is an acceptable trade-off (double-click to select word and
// Shift+click to extend selection still work via the field editor).
guard let editor = currentEditor() as? NSTextView else {
super.mouseDown(with: event)
return
}
// Double/triple-click: forward directly to the field editor (NSTextView)
// which handles word and line selection internally. This bypasses
// NSTextField's super.mouseDown (and its problematic tracking loop)
// while preserving multi-click semantics.
if event.clickCount > 1 {
editor.mouseDown(with: event)
shiftClickAnchor = nil
return
}
let localPoint = editor.convert(event.locationInWindow, from: nil)
let index = editor.characterIndexForInsertion(at: localPoint)
let textLength = (editor.string as NSString).length
let safeIndex = min(index, textLength)
if event.modifierFlags.contains(.shift) {
// Shift+click: extend the existing selection to the clicked position.
// Use stored anchor to handle bidirectional extension correctly;
// NSRange.location is always the lower index so it cannot serve as
// a directional anchor on its own.
let sel = editor.selectedRange()
let anchor = shiftClickAnchor ?? sel.location
shiftClickAnchor = anchor
let newRange: NSRange
if safeIndex >= anchor {
newRange = NSRange(location: anchor, length: safeIndex - anchor)
} else {
newRange = NSRange(location: safeIndex, length: anchor - safeIndex)
}
editor.setSelectedRange(newRange)
} else {
shiftClickAnchor = nil
editor.setSelectedRange(NSRange(location: safeIndex, length: 0))
}
}
}
override func keyDown(with event: NSEvent) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var route = "super"
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.keyDown",
startedAt: typingTimingStart,
event: event,
extra: "route=\(route)"
)
}
#endif
// Reset shift-click anchor on any keyboard input so that a subsequent
// Shift+click uses the post-keyboard selection as its anchor, not a
// stale value from a prior mouse interaction.
shiftClickAnchor = nil
if (currentEditor() as? NSTextView)?.hasMarkedText() == true {
super.keyDown(with: event)
return
}
if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true {
#if DEBUG
route = "custom"
#endif
return
}
super.keyDown(with: event)
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var handled = false
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.performKeyEquivalent",
startedAt: typingTimingStart,
event: event,
extra: "handled=\(handled ? 1 : 0)"
)
}
#endif
shiftClickAnchor = nil
if (currentEditor() as? NSTextView)?.hasMarkedText() == true {
let result = super.performKeyEquivalent(with: event)
#if DEBUG
handled = result
#endif
return result
}
if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true {
#if DEBUG
handled = true
#endif
return true
}
let result = super.performKeyEquivalent(with: event)
#if DEBUG
handled = result
#endif
return result
}
}
private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
let inlineCompletion: OmnibarInlineCompletion?
let placeholder: String
let onTap: () -> Void
let onSubmit: () -> Void
let onEscape: () -> Void
let onFieldLostFocus: () -> Void
let onMoveSelection: (Int) -> Void
let onDeleteSelectedSuggestion: () -> Void
let onAcceptInlineCompletion: () -> Void
let onDeleteBackwardWithInlineSelection: () -> Void
let onSelectionChanged: (NSRange, Bool) -> Void
let shouldSuppressWebViewFocus: () -> Bool
final class Coordinator: NSObject, NSTextFieldDelegate {
var parent: OmnibarTextFieldRepresentable
var isProgrammaticMutation: Bool = false
var selectionObserver: NSObjectProtocol?
weak var observedEditor: NSTextView?
var appliedInlineCompletion: OmnibarInlineCompletion?
var lastPublishedSelection: NSRange = NSRange(location: NSNotFound, length: 0)
var lastPublishedHasMarkedText: Bool = false
/// Guards against infinite focus loops: `true` = focus requested, `false` = blur requested, `nil` = idle.
var pendingFocusRequest: Bool?
init(parent: OmnibarTextFieldRepresentable) {
self.parent = parent
}
#if DEBUG
func logFocusEvent(_ event: String, detail: String = "") {
let window = parentField?.window
let responder = window?.firstResponder
let responderType = responder.map { String(describing: type(of: $0)) } ?? "nil"
let responderIsField: Int = {
guard let field = parentField else { return 0 }
if responder === field { return 1 }
if let editor = responder as? NSTextView,
(editor.delegate as? NSTextField) === field {
return 1
}
return 0
}()
let pendingValue: String = {
guard let pendingFocusRequest else { return "nil" }
return pendingFocusRequest ? "focus" : "blur"
}()
var line =
"browser.focus.field event=\(event) focused=\(parent.isFocused ? 1 : 0) " +
"pending=\(pendingValue) suppressWeb=\(parent.shouldSuppressWebViewFocus() ? 1 : 0) " +
"win=\(window?.windowNumber ?? -1) fr=\(responderType) frIsField=\(responderIsField)"
if !detail.isEmpty {
line += " \(detail)"
}
dlog(line)
}
#endif
deinit {
if let selectionObserver {
NotificationCenter.default.removeObserver(selectionObserver)
}
}
private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool {
guard let window, let field = parentField else { return false }
let responder = window.firstResponder
if let editor = responder as? NSTextView,
let delegateField = editor.delegate as? NSTextField {
return delegateField !== field
}
if let textField = responder as? NSTextField {
return textField !== field
}
return false
}
private func isPointerDownEvent(_ event: NSEvent) -> Bool {
switch event.type {
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
return true
default:
return false
}
}
private func topHitViewForCurrentPointerEvent(window: NSWindow) -> NSView? {
guard let event = NSApp.currentEvent, isPointerDownEvent(event) else {
return nil
}
if event.windowNumber != 0, event.windowNumber != window.windowNumber {
return nil
}
if let eventWindow = event.window, eventWindow !== window {
return nil
}
if let contentView = window.contentView,
let themeFrame = contentView.superview {
let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil)
if let hitInTheme = themeFrame.hitTest(pointInTheme) {
return hitInTheme
}
}
guard let contentView = window.contentView else {
return nil
}
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
return contentView.hitTest(pointInContent)
}
private func pointerDownBlurIntent(window: NSWindow?) -> Bool {
guard let window, let field = parentField else { return false }
guard let hitView = topHitViewForCurrentPointerEvent(window: window) else {
return false
}
if hitView === field || hitView.isDescendant(of: field) {
return false
}
if let textView = hitView as? NSTextView,
let delegateField = textView.delegate as? NSTextField,
delegateField === field {
return false
}
return true
}
private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool {
if pointerDownBlurIntent(window: window) {
return false
}
return browserOmnibarShouldReacquireFocusAfterEndEditing(
desiredOmnibarFocus: parent.isFocused,
nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
)
}
func controlTextDidBeginEditing(_ obj: Notification) {
#if DEBUG
logFocusEvent("controlTextDidBeginEditing")
#endif
if !parent.isFocused {
DispatchQueue.main.async {
#if DEBUG
self.logFocusEvent("controlTextDidBeginEditing.asyncSetFocused", detail: "old=0 new=1")
#endif
self.parent.isFocused = true
}
}
attachSelectionObserverIfNeeded()
publishSelectionState()
}
func controlTextDidEndEditing(_ obj: Notification) {
#if DEBUG
let nextOther = nextResponderIsOtherTextField(window: parentField?.window)
let pointerBlur = pointerDownBlurIntent(window: parentField?.window)
logFocusEvent(
"controlTextDidEndEditing",
detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(pointerBlur ? 1 : 0) shouldReacquire=\(shouldReacquireFocusAfterEndEditing(window: parentField?.window) ? 1 : 0)"
)
#endif
if parent.isFocused {
if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
#if DEBUG
logFocusEvent("controlTextDidEndEditing.reacquire.begin")
#endif
guard pendingFocusRequest != true else { return }
pendingFocusRequest = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.pendingFocusRequest = nil
#if DEBUG
self.logFocusEvent("controlTextDidEndEditing.reacquire.tick")
#endif
guard self.parent.isFocused else { return }
guard let field = self.parentField, let window = field.window else { return }
guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
#if DEBUG
self.logFocusEvent("controlTextDidEndEditing.reacquire.cancel")
#endif
self.parent.onFieldLostFocus()
return
}
// Check both the field itself AND its field editor (which becomes
// the actual first responder when the text field is being edited).
let fr = window.firstResponder
let isAlreadyFocused = fr === field ||
field.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === field
if !isAlreadyFocused {
#if DEBUG
self.logFocusEvent("controlTextDidEndEditing.reacquire.apply")
#endif
window.makeFirstResponder(field)
} else {
#if DEBUG
self.logFocusEvent("controlTextDidEndEditing.reacquire.skip", detail: "reason=already_focused")
#endif
}
}
return
}
#if DEBUG
logFocusEvent("controlTextDidEndEditing.blur")
#endif
parent.onFieldLostFocus()
}
detachSelectionObserver()
}
func controlTextDidChange(_ obj: Notification) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.controlTextDidChange",
startedAt: typingTimingStart,
event: NSApp.currentEvent,
extra: "programmatic=\(isProgrammaticMutation ? 1 : 0)"
)
}
#endif
guard !isProgrammaticMutation else { return }
guard let field = obj.object as? NSTextField else { return }
let editor = field.currentEditor() as? NSTextView
parent.text = omnibarPublishedBufferTextForFieldChange(
fieldValue: field.stringValue,
inlineCompletion: parent.inlineCompletion,
selectionRange: editor?.selectedRange(),
hasMarkedText: editor?.hasMarkedText() ?? false
)
publishSelectionState()
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var handled = false
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.doCommandBy",
startedAt: typingTimingStart,
event: NSApp.currentEvent,
extra: "handled=\(handled ? 1 : 0) selector=\(NSStringFromSelector(commandSelector))"
)
}
#endif
switch commandSelector {
case #selector(NSResponder.moveDown(_:)):
parent.onMoveSelection(+1)
#if DEBUG
handled = true
#endif
return true
case #selector(NSResponder.moveUp(_:)):
parent.onMoveSelection(-1)
#if DEBUG
handled = true
#endif
return true
case #selector(NSResponder.insertNewline(_:)):
let currentFlags = NSApp.currentEvent?.modifierFlags ?? []
guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false }
parent.onSubmit()
#if DEBUG
handled = true
#endif
return true
case #selector(NSResponder.cancelOperation(_:)):
parent.onEscape()
#if DEBUG
handled = true
#endif
return true
case #selector(NSResponder.moveRight(_:)), #selector(NSResponder.moveToEndOfLine(_:)):
if parent.inlineCompletion != nil {
parent.onAcceptInlineCompletion()
#if DEBUG
handled = true
#endif
return true
}
return false
case #selector(NSResponder.insertTab(_:)):
if parent.inlineCompletion != nil {
parent.onAcceptInlineCompletion()
#if DEBUG
handled = true
#endif
return true
}
return false
case #selector(NSResponder.deleteBackward(_:)):
if suffixSelectionMatchesInline(textView, inline: parent.inlineCompletion) {
parent.onDeleteBackwardWithInlineSelection()
#if DEBUG
handled = true
#endif
return true
}
return false
default:
return false
}
}
func attachSelectionObserverIfNeeded() {
guard selectionObserver == nil else { return }
guard let field = parentField else { return }
guard let editor = field.currentEditor() as? NSTextView else { return }
observedEditor = editor
selectionObserver = NotificationCenter.default.addObserver(
forName: NSTextView.didChangeSelectionNotification,
object: editor,
queue: .main
) { [weak self] _ in
self?.publishSelectionState()
}
}
func detachSelectionObserver() {
if let selectionObserver {
NotificationCenter.default.removeObserver(selectionObserver)
self.selectionObserver = nil
}
observedEditor = nil
}
weak var parentField: OmnibarNativeTextField?
func publishSelectionState() {
guard let field = parentField else { return }
if let editor = field.currentEditor() as? NSTextView {
let range = editor.selectedRange()
let hasMarkedText = editor.hasMarkedText()
guard !NSEqualRanges(range, lastPublishedSelection) || hasMarkedText != lastPublishedHasMarkedText else {
return
}
lastPublishedSelection = range
lastPublishedHasMarkedText = hasMarkedText
parent.onSelectionChanged(range, hasMarkedText)
} else {
let location = field.stringValue.utf16.count
let range = NSRange(location: location, length: 0)
guard !NSEqualRanges(range, lastPublishedSelection) || lastPublishedHasMarkedText else { return }
lastPublishedSelection = range
lastPublishedHasMarkedText = false
parent.onSelectionChanged(range, false)
}
}
private func suffixSelectionMatchesInline(_ editor: NSTextView?, inline: OmnibarInlineCompletion?) -> Bool {
guard let editor, let inline else { return false }
let selected = editor.selectedRange()
return NSEqualRanges(selected, inline.suffixRange)
}
private func selectionIsTypedPrefixBoundary(_ editor: NSTextView?, inline: OmnibarInlineCompletion?) -> Bool {
guard let editor, let inline else { return false }
let selected = editor.selectedRange()
let typedCount = inline.typedText.utf16.count
return selected.location == typedCount && selected.length == 0
}
func handleKeyEvent(_ event: NSEvent, editor: NSTextView?) -> Bool {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()
var handled = false
defer {
CmuxTypingTiming.logDuration(
path: "browser.omnibar.handleKeyEvent",
startedAt: typingTimingStart,
event: event,
extra: "handled=\(handled ? 1 : 0)"
)
}
#endif
let keyCode = event.keyCode
let modifiers = event.modifierFlags.intersection([.command, .control, .shift, .option, .function])
// When a non-Latin input source is active (Korean, Chinese, Japanese),
// charactersIgnoringModifiers returns non-ASCII characters. Normalize
// via KeyboardLayout so Cmd/Ctrl+N/P navigation works across input sources.
let lowered = KeyboardLayout.normalizedCharacters(for: event)
let hasCommandOrControl = modifiers.contains(.command) || modifiers.contains(.control)
// Cmd/Ctrl+N and Cmd/Ctrl+P should repeat while held.
if hasCommandOrControl, lowered == "n" {
parent.onMoveSelection(+1)
#if DEBUG
handled = true
#endif
return true
}
if hasCommandOrControl, lowered == "p" {
parent.onMoveSelection(-1)
#if DEBUG
handled = true
#endif
return true
}
// Shift+Delete removes the selected history suggestion when possible.
if modifiers.contains(.shift), (keyCode == 51 || keyCode == 117) {
parent.onDeleteSelectedSuggestion()
#if DEBUG
handled = true
#endif
return true
}
switch keyCode {
case 36, 76: // Return / keypad Enter
guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false }
parent.onSubmit()
#if DEBUG
handled = true
#endif
return true
case 53: // Escape
parent.onEscape()
#if DEBUG
handled = true
#endif
return true
case 125: // Down
parent.onMoveSelection(+1)
#if DEBUG
handled = true
#endif
return true
case 126: // Up
parent.onMoveSelection(-1)
#if DEBUG
handled = true
#endif
return true
case 124, 119: // Right arrow / End
if parent.inlineCompletion != nil {
parent.onAcceptInlineCompletion()
#if DEBUG
handled = true
#endif
return true
}
case 48: // Tab
if parent.inlineCompletion != nil {
parent.onAcceptInlineCompletion()
#if DEBUG
handled = true
#endif
return true
}
case 51: // Backspace
if let inline = parent.inlineCompletion,
(suffixSelectionMatchesInline(editor, inline: inline) || selectionIsTypedPrefixBoundary(editor, inline: inline)) {
parent.onDeleteBackwardWithInlineSelection()
#if DEBUG
handled = true
#endif
return true
}
default:
break
}
return false
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Context) -> OmnibarNativeTextField {
let field = OmnibarNativeTextField(frame: .zero)
field.font = .systemFont(ofSize: 12)
field.placeholderString = placeholder
field.delegate = context.coordinator
field.target = nil
field.action = nil
field.isEditable = true
field.isSelectable = true
field.isEnabled = true
field.stringValue = text
field.onPointerDown = {
onTap()
}
field.onHandleKeyEvent = { [weak coordinator = context.coordinator] event, editor in
coordinator?.handleKeyEvent(event, editor: editor) ?? false
}
context.coordinator.parentField = field
return field
}
func updateNSView(_ nsView: OmnibarNativeTextField, context: Context) {
context.coordinator.parent = self
context.coordinator.parentField = nsView
nsView.placeholderString = placeholder
let activeInlineCompletion = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
bufferText: text,
inlineCompletion: inlineCompletion
)
let desiredDisplayText = activeInlineCompletion?.displayText ?? text
if let editor = nsView.currentEditor() as? NSTextView {
if !editor.hasMarkedText(), editor.string != desiredDisplayText {
context.coordinator.isProgrammaticMutation = true
editor.string = desiredDisplayText
nsView.stringValue = desiredDisplayText
context.coordinator.isProgrammaticMutation = false
}
} else if nsView.stringValue != desiredDisplayText {
nsView.stringValue = desiredDisplayText
}
if let window = nsView.window {
let firstResponder = window.firstResponder
let isFirstResponder =
firstResponder === nsView ||
nsView.currentEditor() != nil ||
((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
#if DEBUG
context.coordinator.logFocusEvent(
"updateNSView.requestFocus.begin",
detail: "isFocused=1 isFirstResponder=0"
)
#endif
// Defer to avoid triggering input method XPC during layout pass,
// which can crash via re-entrant view hierarchy modification.
context.coordinator.pendingFocusRequest = true
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let nsView, let window = nsView.window else { return }
#if DEBUG
if coordinator?.parent.isFocused != true {
coordinator?.logFocusEvent("updateNSView.requestFocus.cancel", detail: "reason=stale_state")
return
}
#endif
guard coordinator?.parent.isFocused == true else { return }
#if DEBUG
coordinator?.logFocusEvent("updateNSView.requestFocus.tick")
#endif
let fr = window.firstResponder
let alreadyFocused = fr === nsView ||
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
guard !alreadyFocused else { return }
#if DEBUG
coordinator?.logFocusEvent("updateNSView.requestFocus.apply")
#endif
window.makeFirstResponder(nsView)
}
} else if !isFocused, isFirstResponder, context.coordinator.pendingFocusRequest != false {
#if DEBUG
context.coordinator.logFocusEvent(
"updateNSView.requestBlur.begin",
detail: "isFocused=0 isFirstResponder=1"
)
#endif
context.coordinator.pendingFocusRequest = false
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let nsView, let window = nsView.window else { return }
#if DEBUG
if coordinator?.parent.isFocused == true {
coordinator?.logFocusEvent("updateNSView.requestBlur.cancel", detail: "reason=stale_state")
return
}
#endif
guard coordinator?.parent.isFocused == false else { return }
#if DEBUG
coordinator?.logFocusEvent("updateNSView.requestBlur.tick")
#endif
let fr = window.firstResponder
let stillFirst = fr === nsView ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
guard stillFirst else { return }
#if DEBUG
coordinator?.logFocusEvent("updateNSView.requestBlur.apply")
#endif
window.makeFirstResponder(nil)
}
}
}
if let editor = nsView.currentEditor() as? NSTextView, !editor.hasMarkedText() {
if let activeInlineCompletion {
let currentSelection = editor.selectedRange()
let desiredSelection = omnibarDesiredSelectionRangeForInlineCompletion(
currentSelection: currentSelection,
inlineCompletion: activeInlineCompletion
)
if context.coordinator.appliedInlineCompletion != activeInlineCompletion ||
!NSEqualRanges(currentSelection, desiredSelection) {
context.coordinator.isProgrammaticMutation = true
editor.setSelectedRange(desiredSelection)
context.coordinator.isProgrammaticMutation = false
}
} else if context.coordinator.appliedInlineCompletion != nil {
let end = text.utf16.count
let current = editor.selectedRange()
if current.length != 0 || current.location != end {
context.coordinator.isProgrammaticMutation = true
editor.setSelectedRange(NSRange(location: end, length: 0))
context.coordinator.isProgrammaticMutation = false
}
}
}
context.coordinator.appliedInlineCompletion = activeInlineCompletion
context.coordinator.attachSelectionObserverIfNeeded()
context.coordinator.publishSelectionState()
}
static func dismantleNSView(_ nsView: OmnibarNativeTextField, coordinator: Coordinator) {
nsView.onPointerDown = nil
nsView.onHandleKeyEvent = nil
nsView.delegate = nil
coordinator.detachSelectionObserver()
coordinator.parentField = nil
}
}
private struct OmnibarSuggestionsView: View {
let engineName: String
let items: [OmnibarSuggestion]
let selectedIndex: Int
let isLoadingRemoteSuggestions: Bool
let searchSuggestionsEnabled: Bool
let onCommit: (OmnibarSuggestion) -> Void
let onHighlight: (Int) -> Void
@Environment(\.colorScheme) private var colorScheme
// Keep radii below half of the smallest rendered heights so this keeps a
// squircle silhouette instead of auto-clamping into a capsule.
private let popupCornerRadius: CGFloat = 12
private let rowHighlightCornerRadius: CGFloat = 9
private let singleLineRowHeight: CGFloat = 24
private let rowSpacing: CGFloat = 1
private let topInset: CGFloat = 3
private let bottomInset: CGFloat = 3
private var horizontalInset: CGFloat { topInset }
private let maxPopupHeight: CGFloat = 560
private var totalRowCount: Int {
max(1, items.count)
}
private func rowHeight(for item: OmnibarSuggestion) -> CGFloat {
return singleLineRowHeight
}
private var contentHeight: CGFloat {
let rowsHeight = items.isEmpty ? singleLineRowHeight : items.reduce(CGFloat(0)) { partial, item in
partial + rowHeight(for: item)
}
let gaps = CGFloat(max(0, totalRowCount - 1))
return rowsHeight + (gaps * rowSpacing) + topInset + bottomInset
}
private var minimumPopupHeight: CGFloat {
singleLineRowHeight + topInset + bottomInset
}
private func snapToDevicePixels(_ value: CGFloat) -> CGFloat {
let scale = NSScreen.main?.backingScaleFactor ?? 2
return (value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
private var popupHeight: CGFloat {
snapToDevicePixels(min(max(contentHeight, minimumPopupHeight), maxPopupHeight))
}
private var isPointerDrivenSelectionEvent: Bool {
guard let event = NSApp.currentEvent else { return false }
switch event.type {
case .mouseMoved, .leftMouseDown, .leftMouseDragged, .leftMouseUp,
.rightMouseDown, .rightMouseDragged, .rightMouseUp,
.otherMouseDown, .otherMouseDragged, .otherMouseUp, .scrollWheel:
return true
default:
return false
}
}
private var shouldScroll: Bool {
contentHeight > maxPopupHeight
}
private var listTextColor: Color {
switch colorScheme {
case .light:
return Color(nsColor: .labelColor)
case .dark:
return Color.white.opacity(0.9)
@unknown default:
return Color(nsColor: .labelColor)
}
}
private var badgeTextColor: Color {
switch colorScheme {
case .light:
return Color(nsColor: .secondaryLabelColor)
case .dark:
return Color.white.opacity(0.72)
@unknown default:
return Color(nsColor: .secondaryLabelColor)
}
}
private var badgeBackgroundColor: Color {
switch colorScheme {
case .light:
return Color.black.opacity(0.06)
case .dark:
return Color.white.opacity(0.08)
@unknown default:
return Color.black.opacity(0.06)
}
}
private var rowHighlightColor: Color {
switch colorScheme {
case .light:
return Color.black.opacity(0.07)
case .dark:
return Color.white.opacity(0.12)
@unknown default:
return Color.black.opacity(0.07)
}
}
private var popupOverlayGradientColors: [Color] {
switch colorScheme {
case .light:
return [
Color.white.opacity(0.55),
Color.white.opacity(0.2),
]
case .dark:
return [
Color.black.opacity(0.26),
Color.black.opacity(0.14),
]
@unknown default:
return [
Color.white.opacity(0.55),
Color.white.opacity(0.2),
]
}
}
private var popupBorderGradientColors: [Color] {
switch colorScheme {
case .light:
return [
Color.white.opacity(0.65),
Color.black.opacity(0.12),
]
case .dark:
return [
Color.white.opacity(0.22),
Color.white.opacity(0.06),
]
@unknown default:
return [
Color.white.opacity(0.65),
Color.black.opacity(0.12),
]
}
}
private var popupShadowColor: Color {
switch colorScheme {
case .light:
return Color.black.opacity(0.18)
case .dark:
return Color.black.opacity(0.45)
@unknown default:
return Color.black.opacity(0.18)
}
}
@ViewBuilder
private var rowsView: some View {
VStack(spacing: rowSpacing) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
Button {
#if DEBUG
dlog("browser.suggestionClick index=\(idx) text=\"\(item.listText)\"")
#endif
onCommit(item)
} label: {
HStack(spacing: 6) {
Text(item.listText)
.font(.system(size: 11))
.foregroundStyle(listTextColor)
.lineLimit(1)
.truncationMode(.tail)
if let badge = item.trailingBadgeText {
Text(badge)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(badgeTextColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(badgeBackgroundColor)
)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(
maxWidth: .infinity,
minHeight: rowHeight(for: item),
maxHeight: rowHeight(for: item),
alignment: .leading
)
.background(
RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous)
.fill(
idx == selectedIndex
? rowHighlightColor
: Color.clear
)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("BrowserOmnibarSuggestions.Row.\(idx)")
.accessibilityValue(
idx == selectedIndex
? "selected \(item.listText)"
: item.listText
)
.onHover { hovering in
if hovering, idx != selectedIndex, isPointerDrivenSelectionEvent {
onHighlight(idx)
}
}
.animation(.none, value: selectedIndex)
}
}
.padding(.horizontal, horizontalInset)
.padding(.top, topInset)
.padding(.bottom, bottomInset)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
var body: some View {
Group {
if shouldScroll {
ScrollView {
rowsView
}
} else {
rowsView
}
}
.frame(height: popupHeight, alignment: .top)
.overlay(alignment: .topTrailing) {
if searchSuggestionsEnabled, isLoadingRemoteSuggestions {
ProgressView()
.controlSize(.small)
.padding(.top, 7)
.padding(.trailing, 14)
.opacity(0.75)
.allowsHitTesting(false)
}
}
.background(
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: popupOverlayGradientColors,
startPoint: .top,
endPoint: .bottom
)
)
)
)
.overlay(
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
.stroke(
LinearGradient(
colors: popupBorderGradientColors,
startPoint: .top,
endPoint: .bottom
),
lineWidth: 1
)
)
.clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
.shadow(color: popupShadowColor, radius: 20, y: 10)
.contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
.accessibilityElement(children: .contain)
.accessibilityRespondsToUserInteraction(true)
.accessibilityIdentifier("BrowserOmnibarSuggestions")
.accessibilityLabel(String(localized: "browser.addressBarSuggestions", defaultValue: "Address bar suggestions"))
}
}
/// NSViewRepresentable wrapper for WKWebView
struct WebViewRepresentable: NSViewRepresentable {
let panel: BrowserPanel
let paneId: PaneID
let shouldAttachWebView: Bool
let useLocalInlineHosting: 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?
weak var webView: WKWebView?
var attachGeneration: Int = 0
var desiredPortalVisibleInUI: Bool = true
var desiredPortalZPriority: Int = 0
var lastPortalHostId: ObjectIdentifier?
var lastSynchronizedHostGeometryRevision: UInt64 = 0
}
final class HostContainerView: NSView {
private final class HostedInspectorSideDockContainerView: NSView {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.masksToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
override var isOpaque: Bool { false }
override func resizeSubviews(withOldSize oldSize: NSSize) {
// Managed side-docked DevTools use explicit frame updates from the host.
// Letting AppKit autoresize the WK siblings here makes them snap back to
// stale widths while the divider drag or pane resize is in flight.
}
}
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
private(set) var geometryRevision: UInt64 = 0
private var lastReportedGeometryState: GeometryState?
private var hasPendingGeometryNotification = false
private weak var hostedWebView: WKWebView?
private var hostedWebViewConstraints: [NSLayoutConstraint] = []
private weak var localInlineSlotView: WindowBrowserSlotView?
private var localInlineSlotConstraints: [NSLayoutConstraint] = []
private weak var hostedInspectorSideDockContainerView: HostedInspectorSideDockContainerView?
private var hostedInspectorSideDockConstraints: [NSLayoutConstraint] = []
private weak var hostedInspectorFrontendWebView: WKWebView?
private struct HostedInspectorDividerHit {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
let dockSide: HostedInspectorDockSide
}
private struct GeometryState: Equatable {
let frame: CGRect
let bounds: CGRect
let windowNumber: Int?
let superviewID: ObjectIdentifier?
}
private struct HostedInspectorDividerDragState {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
let dockSide: HostedInspectorDockSide
let initialWindowX: CGFloat
let initialPageFrame: NSRect
let initialInspectorFrame: NSRect
}
private enum DividerCursorKind: Equatable {
case vertical
var cursor: NSCursor { .resizeLeftRight }
}
private static let hostedInspectorDividerHitExpansion: CGFloat = 10
private static let minimumHostedInspectorWidth: CGFloat = 120
private static let minimumHostedInspectorPageWidthForSideDock: CGFloat = 240
private static let adaptiveBottomDockRequestCooldown: TimeInterval = 0.25
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
private var preferredHostedInspectorWidth: CGFloat?
private var preferredHostedInspectorWidthFraction: CGFloat?
var onPreferredHostedInspectorWidthChanged: ((CGFloat, CGFloat?) -> Void)?
private weak var hostedInspectorSideDockPageView: NSView?
private weak var hostedInspectorSideDockInspectorView: NSView?
private var hostedInspectorSideDockDockSide: HostedInspectorDockSide?
private var isHostedInspectorDividerDragActive = false
private var isApplyingHostedInspectorLayout = false
private var hostedInspectorReapplyWorkItem: DispatchWorkItem?
private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem?
private var adaptiveBottomDockRequestCooldownDeadline: Date?
private var recordedHostedInspectorSideDockWidth: CGFloat?
private var lastHostedInspectorManualSideDockAllowed: Bool?
private var lastHostedInspectorLayoutBoundsSize: NSSize?
#if DEBUG
private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)?
private var hasLoggedMissingHostedInspectorCandidate = false
#endif
deinit {
hostedInspectorReapplyWorkItem?.cancel()
hostedInspectorDockConfigurationSyncWorkItem?.cancel()
if let trackingArea {
removeTrackingArea(trackingArea)
}
clearActiveDividerCursor(restoreArrow: false)
}
private func recordPreferredHostedInspectorWidth(_ width: CGFloat, containerBounds: NSRect) {
preferredHostedInspectorWidth = width
guard containerBounds.width > 0 else {
preferredHostedInspectorWidthFraction = nil
onPreferredHostedInspectorWidthChanged?(width, nil)
return
}
preferredHostedInspectorWidthFraction = width / containerBounds.width
onPreferredHostedInspectorWidthChanged?(width, preferredHostedInspectorWidthFraction)
}
private func resolvedPreferredHostedInspectorWidth(in containerBounds: NSRect) -> CGFloat? {
if let preferredHostedInspectorWidthFraction, containerBounds.width > 0 {
return max(0, containerBounds.width * preferredHostedInspectorWidthFraction)
}
return preferredHostedInspectorWidth
}
func setPreferredHostedInspectorWidth(width: CGFloat?, widthFraction: CGFloat?) {
preferredHostedInspectorWidth = width
preferredHostedInspectorWidthFraction = widthFraction
}
private func recordHostedInspectorSideDockWidth(_ width: CGFloat) {
guard width > 1 else { return }
recordedHostedInspectorSideDockWidth = max(Self.minimumHostedInspectorWidth, width)
}
private func shouldAllowHostedInspectorManualSideDock() -> Bool {
let containerWidth = max(0, bounds.width)
guard containerWidth > 1 else { return true }
let baselineWidth = max(
Self.minimumHostedInspectorWidth,
recordedHostedInspectorSideDockWidth ?? Self.minimumHostedInspectorWidth
)
return containerWidth - baselineWidth >= Self.minimumHostedInspectorPageWidthForSideDock
}
private func updateHostedInspectorDockControlAvailabilityIfNeeded(reason: String) {
guard let hostedInspectorFrontendWebView else {
lastHostedInspectorManualSideDockAllowed = nil
return
}
let sideDockAllowed = shouldAllowHostedInspectorManualSideDock()
guard lastHostedInspectorManualSideDockAllowed != sideDockAllowed else { return }
lastHostedInspectorManualSideDockAllowed = sideDockAllowed
let sideDockAllowedLiteral = sideDockAllowed ? "true" : "false"
#if DEBUG
let recordedWidthDesc = recordedHostedInspectorSideDockWidth.map {
String(format: "%.1f", $0)
} ?? "nil"
dlog(
"browser.panel.hostedInspector stage=\(reason).dockControls " +
"host=\(Self.debugObjectID(self)) allowSideDock=\(sideDockAllowed ? 1 : 0) " +
"recordedWidth=\(recordedWidthDesc) bounds=\(Self.debugRect(bounds))"
)
#endif
hostedInspectorFrontendWebView.evaluateJavaScript(
"""
(() => {
if (typeof WI === "undefined")
return null;
const allowSideDock = \(sideDockAllowedLiteral);
if (!WI.__cmuxOriginalUpdateDockNavigationItems && typeof WI._updateDockNavigationItems === "function")
WI.__cmuxOriginalUpdateDockNavigationItems = WI._updateDockNavigationItems;
if (!WI.__cmuxOriginalDockLeft && typeof WI._dockLeft === "function")
WI.__cmuxOriginalDockLeft = WI._dockLeft;
if (!WI.__cmuxOriginalDockRight && typeof WI._dockRight === "function")
WI.__cmuxOriginalDockRight = WI._dockRight;
if (!WI.__cmuxOriginalTogglePreviousDockConfiguration && typeof WI._togglePreviousDockConfiguration === "function")
WI.__cmuxOriginalTogglePreviousDockConfiguration = WI._togglePreviousDockConfiguration;
function callOriginal(fn, event) {
return typeof fn === "function" ? fn.call(WI, event) : null;
}
function updateButton(button, hidden) {
if (!button)
return;
button.hidden = hidden;
if (button.element) {
button.element.style.display = hidden ? "none" : "";
button.element.style.pointerEvents = hidden ? "none" : "";
}
}
function enforceDockControls() {
const disallowSideDock = !WI.__cmuxAllowSideDock;
updateButton(WI._dockLeftTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Left);
updateButton(WI._dockRightTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Right);
}
WI.__cmuxAllowSideDock = allowSideDock;
WI._dockLeft = function(event) {
if (!WI.__cmuxAllowSideDock)
return callOriginal(WI._dockBottom, event);
return callOriginal(WI.__cmuxOriginalDockLeft, event);
};
WI._dockRight = function(event) {
if (!WI.__cmuxAllowSideDock)
return callOriginal(WI._dockBottom, event);
return callOriginal(WI.__cmuxOriginalDockRight, event);
};
WI._togglePreviousDockConfiguration = function(event) {
const previousSideDock = WI._previousDockConfiguration === WI.DockConfiguration.Left || WI._previousDockConfiguration === WI.DockConfiguration.Right;
if (!WI.__cmuxAllowSideDock && previousSideDock)
return callOriginal(WI._dockBottom, event);
return callOriginal(WI.__cmuxOriginalTogglePreviousDockConfiguration, event);
};
WI._updateDockNavigationItems = function(...args) {
if (typeof WI.__cmuxOriginalUpdateDockNavigationItems === "function")
WI.__cmuxOriginalUpdateDockNavigationItems.apply(WI, args);
enforceDockControls();
};
WI._updateDockNavigationItems();
return WI.__cmuxAllowSideDock;
})();
""",
completionHandler: nil
)
}
func containsManagedLocalInlineContent(_ view: NSView) -> Bool {
if let localInlineSlotView,
view === localInlineSlotView || view.isDescendant(of: localInlineSlotView) {
return true
}
if let hostedInspectorSideDockContainerView,
view === hostedInspectorSideDockContainerView || view.isDescendant(of: hostedInspectorSideDockContainerView) {
return true
}
return false
}
func currentHostedWebViewContainer(preferredSlotView: WindowBrowserSlotView) -> NSView {
if let hostedInspectorSideDockContainerView,
let hostedInspectorSideDockPageView,
hostedWebView?.isDescendant(of: hostedInspectorSideDockContainerView) == true,
hostedInspectorSideDockPageView.isDescendant(of: hostedInspectorSideDockContainerView) {
return hostedInspectorSideDockContainerView
}
return preferredSlotView
}
func setHostedInspectorFrontendWebView(_ webView: WKWebView?) {
hostedInspectorFrontendWebView = webView
lastHostedInspectorManualSideDockAllowed = nil
updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "setHostedInspectorFrontendWebView")
}
private var hasStoredHostedInspectorWidthPreference: Bool {
preferredHostedInspectorWidth != nil || preferredHostedInspectorWidthFraction != nil
}
#if DEBUG
private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool {
switch event?.type {
case .leftMouseDown, .leftMouseDragged, .leftMouseUp:
return true
default:
return false
}
}
private func debugLogHitTest(stage: String, point: NSPoint, passThrough: Bool, hitView: NSView?) {
let event = NSApp.currentEvent
guard Self.shouldLogPointerEvent(event) else { return }
let hitDesc: String = {
guard let hitView else { return "nil" }
let token = Unmanaged.passUnretained(hitView).toOpaque()
return "\(type(of: hitView))@\(token)"
}()
let hostRectInContent: NSRect = {
guard let window, let contentView = window.contentView else { return .zero }
return contentView.convert(bounds, from: self)
}()
dlog(
"browser.panel.host stage=\(stage) event=\(String(describing: event?.type)) " +
"point=\(String(format: "%.1f,%.1f", point.x, point.y)) pass=\(passThrough ? 1 : 0) " +
"hostFrameInContent=\(String(format: "%.1f,%.1f %.1fx%.1f", hostRectInContent.origin.x, hostRectInContent.origin.y, hostRectInContent.width, hostRectInContent.height)) " +
"hit=\(hitDesc)"
)
}
private static func debugObjectID(_ object: AnyObject?) -> String {
guard let object else { return "nil" }
return String(describing: Unmanaged.passUnretained(object).toOpaque())
}
private static func debugRect(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.width, rect.height)
}
private func debugLogHostedInspectorFrames(
stage: String,
point: NSPoint? = nil,
hit: HostedInspectorDividerHit
) {
let pointDesc = point.map { String(format: "%.1f,%.1f", $0.x, $0.y) } ?? "nil"
let preferredWidthDesc = preferredHostedInspectorWidth.map { String(format: "%.1f", $0) } ?? "nil"
dlog(
"browser.panel.hostedInspector stage=\(stage) point=\(pointDesc) " +
"host=\(Self.debugObjectID(self)) container=\(Self.debugObjectID(hit.containerView)) " +
"page=\(Self.debugObjectID(hit.pageView)) inspector=\(Self.debugObjectID(hit.inspectorView)) " +
"preferredWidth=\(preferredWidthDesc) " +
"hostFrame=\(Self.debugRect(frame)) hostBounds=\(Self.debugRect(bounds)) " +
"containerBounds=\(Self.debugRect(hit.containerView.bounds)) " +
"pageFrame=\(Self.debugRect(hit.pageView.frame)) " +
"inspectorFrame=\(Self.debugRect(hit.inspectorView.frame))"
)
}
private func debugLogHostedInspectorLayoutIfNeeded(reason: String) {
guard let hit = hostedInspectorDividerCandidate() else {
if !hasLoggedMissingHostedInspectorCandidate,
lastLoggedHostedInspectorFrames != nil || preferredHostedInspectorWidth != nil {
let preferredWidthDesc = preferredHostedInspectorWidth.map {
String(format: "%.1f", $0)
} ?? "nil"
lastLoggedHostedInspectorFrames = nil
hasLoggedMissingHostedInspectorCandidate = true
dlog(
"browser.panel.hostedInspector stage=\(reason).candidateMissing " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)"
)
}
return
}
hasLoggedMissingHostedInspectorCandidate = false
let nextFrames = (page: hit.pageView.frame, inspector: hit.inspectorView.frame)
if let lastLoggedHostedInspectorFrames,
Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.page, nextFrames.page),
Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.inspector, nextFrames.inspector) {
return
}
lastLoggedHostedInspectorFrames = nextFrames
debugLogHostedInspectorFrames(stage: "\(reason).layout", hit: hit)
}
#endif
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.width - rhs.width) <= epsilon &&
abs(lhs.height - rhs.height) <= epsilon
}
private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.width - rhs.width) <= epsilon &&
abs(lhs.height - rhs.height) <= epsilon
}
private func currentGeometryState() -> GeometryState {
GeometryState(
frame: frame,
bounds: bounds,
windowNumber: window?.windowNumber,
superviewID: superview.map(ObjectIdentifier.init)
)
}
/// Record that geometry changed without firing the callback immediately.
/// `setFrameOrigin`/`setFrameSize` can fire multiple times before `layout()`;
/// deferring avoids redundant portal-sync cascades during divider drag.
/// A dispatch fallback ensures the callback fires even if `layout()` is not called.
/// Note: `lastReportedGeometryState` and `geometryRevision` are only updated
/// when the callback actually fires, so `updateNSView` sees a revision that
/// is strictly tied to emitted callbacks (no premature increments).
private func markGeometryDirtyIfNeeded() {
let state = currentGeometryState()
guard state != lastReportedGeometryState else { return }
guard !hasPendingGeometryNotification else { return }
hasPendingGeometryNotification = true
DispatchQueue.main.async { [weak self] in
self?.notifyGeometryChangedIfNeeded()
}
}
/// Check for geometry changes and fire the callback. Also flushes any pending
/// dirty state from `markGeometryDirtyIfNeeded` so `layout()` supersedes the
/// async fallback. Only updates `lastReportedGeometryState` / `geometryRevision`
/// when the callback is emitted, keeping the revision in sync with actual
/// notifications.
private func notifyGeometryChangedIfNeeded() {
hasPendingGeometryNotification = false
let state = currentGeometryState()
guard state != lastReportedGeometryState else { return }
lastReportedGeometryState = state
geometryRevision &+= 1
onGeometryChanged?()
}
func ensureLocalInlineSlotView() -> WindowBrowserSlotView {
if let localInlineSlotView, localInlineSlotView.superview === self {
localInlineSlotView.isHidden = false
return localInlineSlotView
}
let slotView = WindowBrowserSlotView(frame: bounds)
slotView.translatesAutoresizingMaskIntoConstraints = false
addSubview(slotView, positioned: .above, relativeTo: nil)
localInlineSlotConstraints = [
slotView.topAnchor.constraint(equalTo: topAnchor),
slotView.bottomAnchor.constraint(equalTo: bottomAnchor),
slotView.leadingAnchor.constraint(equalTo: leadingAnchor),
slotView.trailingAnchor.constraint(equalTo: trailingAnchor),
]
NSLayoutConstraint.activate(localInlineSlotConstraints)
localInlineSlotView = slotView
return slotView
}
func setLocalInlineSlotHidden(_ hidden: Bool) {
localInlineSlotView?.isHidden = hidden
}
func clearLocalInlineCallbacks() {
onPreferredHostedInspectorWidthChanged = nil
localInlineSlotView?.onHostedInspectorLayout = nil
}
func prepareForWindowPortalHosting() {
hostedInspectorDockConfigurationSyncWorkItem?.cancel()
hostedInspectorDockConfigurationSyncWorkItem = nil
deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView)
hostedInspectorFrontendWebView = nil
}
func releaseHostedWebViewConstraints() {
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
hostedWebViewConstraints = []
hostedWebView = nil
}
func pinHostedWebView(_ webView: WKWebView, in container: NSView) {
guard webView.superview === container || webView.isDescendant(of: container) else { return }
let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview(
in: container,
primaryWebView: webView
)
let needsPlainWebViewFrameReset =
webView.superview === container &&
!hasCompanionWKSubviews &&
Self.frameDiffersFromBounds(webView.frame, bounds: container.bounds)
let needsFrameHosting =
hostedWebView !== webView ||
!hostedWebViewConstraints.isEmpty ||
needsPlainWebViewFrameReset ||
!webView.translatesAutoresizingMaskIntoConstraints ||
webView.autoresizingMask != [.width, .height]
guard needsFrameHosting else {
needsLayout = true
layoutSubtreeIfNeeded()
return
}
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
hostedWebViewConstraints = []
hostedWebView = webView
// WebKit's attached inspector does not reliably dock into a constraint-managed
// WKWebView hierarchy on macOS. Host the moved webview with autoresizing and
// preserve WebKit-managed split frames when docked DevTools siblings exist.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
if webView.superview === container && !hasCompanionWKSubviews {
webView.frame = container.bounds
}
needsLayout = true
layoutSubtreeIfNeeded()
}
private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
abs(frame.minX - bounds.minX) > epsilon ||
abs(frame.minY - bounds.minY) > epsilon ||
abs(frame.width - bounds.width) > epsilon ||
abs(frame.height - bounds.height) > epsilon
}
private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool {
var stack = host.subviews.filter { $0 !== primaryWebView }
while let current = stack.popLast() {
if current.isDescendant(of: primaryWebView) {
continue
}
if String(describing: type(of: current)).contains("WK") {
return true
}
stack.append(contentsOf: current.subviews)
}
return false
}
private func ensureHostedInspectorSideDockContainerView() -> HostedInspectorSideDockContainerView {
if let hostedInspectorSideDockContainerView,
hostedInspectorSideDockContainerView.superview === self {
hostedInspectorSideDockContainerView.isHidden = false
return hostedInspectorSideDockContainerView
}
let containerView = HostedInspectorSideDockContainerView(frame: bounds)
containerView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerView, positioned: .above, relativeTo: localInlineSlotView)
hostedInspectorSideDockConstraints = [
containerView.topAnchor.constraint(equalTo: topAnchor),
containerView.bottomAnchor.constraint(equalTo: bottomAnchor),
containerView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: trailingAnchor),
]
NSLayoutConstraint.activate(hostedInspectorSideDockConstraints)
hostedInspectorSideDockContainerView = containerView
return containerView
}
private func moveHostedInspectorSubviewIfNeeded(_ view: NSView, to container: NSView) {
guard view.superview !== container else { return }
let frameInWindow = view.superview?.convert(view.frame, to: nil) ?? convert(view.frame, to: nil)
view.removeFromSuperview()
container.addSubview(view, positioned: .above, relativeTo: nil)
view.frame = container.convert(frameInWindow, from: nil)
}
private func isHostedInspectorSideDockActive() -> Bool {
guard let hostedInspectorSideDockContainerView,
let hostedInspectorSideDockPageView,
let hostedInspectorSideDockInspectorView else {
return false
}
return hostedInspectorSideDockPageView.superview === hostedInspectorSideDockContainerView &&
hostedInspectorSideDockInspectorView.superview === hostedInspectorSideDockContainerView
}
private func isHostedInspectorSideDockHit(_ hit: HostedInspectorDividerHit) -> Bool {
guard let hostedInspectorSideDockContainerView else { return false }
return hit.containerView === hostedInspectorSideDockContainerView
}
private func activateHostedInspectorSideDockIfNeeded(using hit: HostedInspectorDividerHit) {
let containerView = ensureHostedInspectorSideDockContainerView()
moveHostedInspectorSubviewIfNeeded(hit.pageView, to: containerView)
moveHostedInspectorSubviewIfNeeded(hit.inspectorView, to: containerView)
hostedInspectorSideDockPageView = hit.pageView
hostedInspectorSideDockInspectorView = hit.inspectorView
hostedInspectorSideDockDockSide = hit.dockSide
layoutHostedInspectorSideDockIfNeeded(reason: "sideDock.activate")
}
@discardableResult
func promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() -> Bool {
guard !isHostedInspectorSideDockActive(),
let slotView = localInlineSlotView,
let hit = hostedInspectorDividerCandidateUsingKnownWebViews(in: slotView) else {
return false
}
// The inspector frontend sometimes reports its dock configuration a tick
// late after local-inline reattach. Promote the visible left/right split
// immediately so drag routing stays symmetric on both dock sides.
activateHostedInspectorSideDockIfNeeded(using: hit)
return isHostedInspectorSideDockActive()
}
private func deactivateHostedInspectorSideDockIfNeeded(reparentTo slotView: WindowBrowserSlotView?) {
guard let slotView,
let pageView = hostedInspectorSideDockPageView,
let inspectorView = hostedInspectorSideDockInspectorView else {
hostedInspectorSideDockPageView = nil
hostedInspectorSideDockInspectorView = nil
hostedInspectorSideDockDockSide = nil
hostedInspectorSideDockContainerView?.isHidden = true
return
}
moveHostedInspectorSubviewIfNeeded(pageView, to: slotView)
moveHostedInspectorSubviewIfNeeded(inspectorView, to: slotView)
hostedInspectorSideDockPageView = nil
hostedInspectorSideDockInspectorView = nil
hostedInspectorSideDockDockSide = nil
hostedInspectorSideDockContainerView?.isHidden = true
}
private func layoutHostedInspectorSideDockIfNeeded(reason: String) {
guard let containerView = hostedInspectorSideDockContainerView,
let pageView = hostedInspectorSideDockPageView,
let inspectorView = hostedInspectorSideDockInspectorView,
let dockSide = hostedInspectorSideDockDockSide else {
return
}
let preferredWidth = resolvedPreferredHostedInspectorWidth(in: containerView.bounds) ?? max(0, inspectorView.frame.width)
_ = applyHostedInspectorDividerWidth(
preferredWidth,
to: HostedInspectorDividerHit(
containerView: containerView,
pageView: pageView,
inspectorView: inspectorView,
dockSide: dockSide
),
minimumInspectorWidth: Self.minimumHostedInspectorWidth,
reason: reason
)
}
func normalizeHostedInspectorLayoutIfNeeded(reason: String) {
if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).adaptive") {
return
}
_ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
if isHostedInspectorSideDockActive() {
layoutHostedInspectorSideDockIfNeeded(reason: reason)
} else if !hasStoredHostedInspectorWidthPreference {
captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason)
}
}
private func shouldForceHostedInspectorBottomDock(using hit: HostedInspectorDividerHit) -> Bool {
let containerWidth = max(0, hit.containerView.bounds.width)
guard containerWidth > 1 else { return false }
let currentInspectorWidth = max(0, hit.inspectorView.frame.width)
let currentPageWidth = max(0, hit.pageView.frame.width)
let remainingPageWidth = max(0, containerWidth - max(Self.minimumHostedInspectorWidth, currentInspectorWidth))
let effectivePageWidth = min(currentPageWidth, remainingPageWidth)
return effectivePageWidth < Self.minimumHostedInspectorPageWidthForSideDock
}
@discardableResult
private func requestAdaptiveHostedInspectorBottomDock(reason: String) -> Bool {
let now = Date()
if let adaptiveBottomDockRequestCooldownDeadline, adaptiveBottomDockRequestCooldownDeadline > now {
return true
}
guard let hostedInspectorFrontendWebView else { return false }
adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown)
updateHostedInspectorDockControlAvailabilityIfNeeded(reason: reason)
#if DEBUG
dlog(
"browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " +
"host=\(Self.debugObjectID(self)) bounds=\(Self.debugRect(bounds))"
)
#endif
hostedInspectorFrontendWebView.evaluateJavaScript(
"typeof WI !== 'undefined' ? WI._dockBottom() : null"
) { [weak self] _, _ in
self?.scheduleHostedInspectorDockConfigurationSync(
reason: "\(reason).adaptiveBottomDock"
)
}
return true
}
@discardableResult
private func enforceAdaptiveBottomDockIfNeeded(reason: String) -> Bool {
guard let hit = hostedInspectorDividerCandidate(),
shouldForceHostedInspectorBottomDock(using: hit) else {
return false
}
recordHostedInspectorSideDockWidth(hit.inspectorView.frame.width)
return requestAdaptiveHostedInspectorBottomDock(reason: reason)
}
fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) {
hostedInspectorDockConfigurationSyncWorkItem?.cancel()
guard hostedInspectorFrontendWebView != nil else { return }
let workItem = DispatchWorkItem { [weak self] in
self?.syncHostedInspectorDockConfiguration(reason: reason)
}
hostedInspectorDockConfigurationSyncWorkItem = workItem
DispatchQueue.main.async(execute: workItem)
}
private func syncHostedInspectorDockConfiguration(reason: String) {
hostedInspectorDockConfigurationSyncWorkItem = nil
guard let hostedInspectorFrontendWebView else { return }
hostedInspectorFrontendWebView.evaluateJavaScript(
"typeof WI === 'undefined' ? null : WI.dockConfiguration"
) { [weak self] result, _ in
self?.applyHostedInspectorDockConfiguration(result as? String, reason: reason)
}
}
private func applyHostedInspectorDockConfiguration(_ dockConfiguration: String?, reason: String) {
switch dockConfiguration {
case "left":
hostedInspectorSideDockDockSide = .leading
if isHostedInspectorSideDockActive() {
if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockLeft") {
return
}
layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockLeft")
} else if let slotView = localInlineSlotView,
let hit = hostedInspectorDividerCandidate(in: slotView),
hit.dockSide == .leading {
if shouldForceHostedInspectorBottomDock(using: hit) {
_ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockLeft")
return
}
activateHostedInspectorSideDockIfNeeded(using: hit)
}
case "right":
hostedInspectorSideDockDockSide = .trailing
if isHostedInspectorSideDockActive() {
if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockRight") {
return
}
layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockRight")
} else if let slotView = localInlineSlotView,
let hit = hostedInspectorDividerCandidate(in: slotView),
hit.dockSide == .trailing {
if shouldForceHostedInspectorBottomDock(using: hit) {
_ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockRight")
return
}
activateHostedInspectorSideDockIfNeeded(using: hit)
}
default:
adaptiveBottomDockRequestCooldownDeadline = nil
if isHostedInspectorSideDockActive() {
deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView)
if dockConfiguration == "bottom" {
hostedInspectorFrontendWebView?.evaluateJavaScript(
"typeof WI !== 'undefined' ? WI._dockBottom() : null",
completionHandler: nil
)
}
}
}
updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "\(reason).dockConfiguration")
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
clearActiveDividerCursor(restoreArrow: false)
} else {
scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToWindow")
scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToWindow")
}
window?.invalidateCursorRects(for: self)
onDidMoveToWindow?()
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow")
#endif
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToSuperview")
scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToSuperview")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview")
#endif
}
override func layout() {
super.layout()
_ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") {
updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
#endif
return
}
if let previousSize = lastHostedInspectorLayoutBoundsSize,
Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
// Origin-only frame churn is common while the surrounding split layout
// settles. Reapplying the side-docked inspector at the same size fights
// WebKit's own dock layout and shows up as visible flicker.
if !isHostedInspectorDividerDragActive {
if hasStoredHostedInspectorWidthPreference {
reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "host.layout.sameSize")
} else if !isHostedInspectorSideDockActive() {
captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize")
}
}
updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout.sameSize")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
#endif
return
}
lastHostedInspectorLayoutBoundsSize = bounds.size
if isHostedInspectorSideDockActive() {
layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock")
} else if hasStoredHostedInspectorWidthPreference {
reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "host.layout")
} else {
captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout")
}
updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout")
scheduleHostedInspectorDockConfigurationSync(reason: "layout")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
#endif
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
window?.invalidateCursorRects(for: self)
// Mark dirty; the callback fires from layout() with the settled geometry.
markGeometryDirtyIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin")
#endif
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
window?.invalidateCursorRects(for: self)
// Mark dirty; the callback fires from layout() with the settled geometry.
markGeometryDirtyIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize")
#endif
}
override func resetCursorRects() {
super.resetCursorRects()
guard let hostedInspectorHit = hostedInspectorDividerCandidate() else { return }
let clipped = hostedInspectorDividerHitRect(for: hostedInspectorHit).intersection(bounds)
guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { return }
addCursorRect(clipped, cursor: NSCursor.resizeLeftRight)
}
override func updateTrackingAreas() {
if let trackingArea {
removeTrackingArea(trackingArea)
}
let options: NSTrackingArea.Options = [
.inVisibleRect,
.activeAlways,
.cursorUpdate,
.mouseMoved,
.mouseEnteredAndExited,
.enabledDuringMouseDrag,
]
let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
addTrackingArea(next)
trackingArea = next
super.updateTrackingAreas()
}
override func cursorUpdate(with event: NSEvent) {
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
}
override func mouseMoved(with event: NSEvent) {
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
}
override func mouseExited(with event: NSEvent) {
clearActiveDividerCursor(restoreArrow: true)
}
override func hitTest(_ point: NSPoint) -> NSView? {
let hostedInspectorHit = hostedInspectorDividerHit(at: point)
updateDividerCursor(at: point, hostedInspectorHit: hostedInspectorHit)
let passThrough = shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: hostedInspectorHit)
if passThrough {
#if DEBUG
debugLogHitTest(stage: "hitTest.pass", point: point, passThrough: true, hitView: nil)
#endif
return nil
}
if let hostedInspectorHit {
if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) {
#if DEBUG
debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit)
#endif
if nativeHit !== hostedInspectorHit.inspectorView &&
!hostedInspectorHit.inspectorView.isDescendant(of: nativeHit) {
return nativeHit
}
}
#if DEBUG
debugLogHitTest(
stage: "hitTest.hostedInspectorManual",
point: point,
passThrough: false,
hitView: self
)
#endif
return self
}
let hit = super.hitTest(point)
#if DEBUG
debugLogHitTest(stage: "hitTest.result", point: point, passThrough: false, hitView: hit)
#endif
return hit
}
override func mouseDown(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else {
super.mouseDown(with: event)
return
}
hostedInspectorReapplyWorkItem?.cancel()
isHostedInspectorDividerDragActive = true
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
containerView: hostedInspectorHit.containerView,
pageView: hostedInspectorHit.pageView,
inspectorView: hostedInspectorHit.inspectorView,
dockSide: hostedInspectorHit.dockSide,
initialWindowX: event.locationInWindow.x,
initialPageFrame: hostedInspectorHit.pageView.frame,
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
)
#if DEBUG
debugLogHostedInspectorFrames(stage: "drag.start", point: point, hit: hostedInspectorHit)
#endif
}
override func mouseDragged(with event: NSEvent) {
guard let dragState = hostedInspectorDividerDrag else {
super.mouseDragged(with: event)
return
}
let containerBounds = dragState.containerView.bounds
let minimumInspectorWidth = Self.minimumHostedInspectorWidth
let initialDividerX = dragState.dockSide.dividerX(
pageFrame: dragState.initialPageFrame,
inspectorFrame: dragState.initialInspectorFrame
)
let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX)
let clampedDividerX = dragState.dockSide.clampedDividerX(
proposedDividerX,
containerBounds: containerBounds,
pageFrame: dragState.initialPageFrame,
minimumInspectorWidth: minimumInspectorWidth
)
let inspectorWidth = dragState.dockSide.inspectorWidth(
forDividerX: clampedDividerX,
in: containerBounds
)
recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds)
_ = applyHostedInspectorDividerWidth(
inspectorWidth,
to: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
),
minimumInspectorWidth: Self.minimumHostedInspectorWidth,
reason: "drag"
)
#if DEBUG
debugLogHostedInspectorFrames(
stage: "drag.update",
point: convert(event.locationInWindow, from: nil),
hit: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
)
)
#endif
updateDividerCursor(
at: convert(event.locationInWindow, from: nil),
hostedInspectorHit: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
)
)
}
override func mouseUp(with event: NSEvent) {
let finalDragState = hostedInspectorDividerDrag
hostedInspectorDividerDrag = nil
isHostedInspectorDividerDragActive = false
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
if let finalDragState {
#if DEBUG
debugLogHostedInspectorFrames(
stage: "drag.end",
point: convert(event.locationInWindow, from: nil),
hit: HostedInspectorDividerHit(
containerView: finalDragState.containerView,
pageView: finalDragState.pageView,
inspectorView: finalDragState.inspectorView,
dockSide: finalDragState.dockSide
)
)
#endif
reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: "drag.end")
}
super.mouseUp(with: event)
}
private func shouldPassThroughToSidebarResizer(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit? = nil
) -> Bool {
if hostedInspectorHit != nil {
return false
}
// Pass through a narrow leading-edge band so the shared sidebar divider
// handle can receive hover/click even when WKWebView is attached here.
// Keeping this deterministic avoids flicker from dynamic left-edge scans.
guard point.x >= 0, point.x <= SidebarResizeInteraction.contentSideHitWidth else {
return false
}
guard let window, let contentView = window.contentView else {
return false
}
let hostRectInContent = contentView.convert(bounds, from: self)
return hostRectInContent.minX > 1
}
private func updateDividerCursor(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit? = nil
) {
let resolvedHostedInspectorHit = hostedInspectorHit ?? hostedInspectorDividerHit(at: point)
if shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: resolvedHostedInspectorHit) {
clearActiveDividerCursor(restoreArrow: false)
return
}
guard resolvedHostedInspectorHit != nil else {
clearActiveDividerCursor(restoreArrow: true)
return
}
activeDividerCursorKind = .vertical
NSCursor.resizeLeftRight.set()
}
private func clearActiveDividerCursor(restoreArrow: Bool) {
guard activeDividerCursorKind != nil else { return }
window?.invalidateCursorRects(for: self)
activeDividerCursorKind = nil
if restoreArrow {
NSCursor.arrow.set()
}
}
private func nativeHostedInspectorHit(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit
) -> NSView? {
guard let nativeHit = super.hitTest(point), nativeHit !== self else { return nil }
if nativeHit === hostedInspectorHit.pageView ||
nativeHit.isDescendant(of: hostedInspectorHit.pageView) {
return nil
}
if nativeHit === hostedInspectorHit.inspectorView ||
nativeHit.isDescendant(of: hostedInspectorHit.inspectorView) {
return nativeHit
}
if hostedInspectorHit.inspectorView.isDescendant(of: nativeHit),
!(hostedInspectorHit.pageView === nativeHit || hostedInspectorHit.pageView.isDescendant(of: nativeHit)) {
return nativeHit
}
return nil
}
private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? {
guard let hit = hostedInspectorDividerCandidate(),
hostedInspectorDividerHitRect(for: hit).contains(point) else {
return nil
}
return hit
}
private func hostedInspectorDividerCandidate() -> HostedInspectorDividerHit? {
hostedInspectorDividerCandidate(in: self)
}
private func hostedInspectorDividerCandidate(in root: NSView) -> HostedInspectorDividerHit? {
if let preferredHit = hostedInspectorDividerCandidateUsingKnownWebViews(in: root) {
return preferredHit
}
let inspectorCandidates = Self.visibleDescendants(in: root)
.filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) }
.sorted { lhs, rhs in
let lhsFrame = root.convert(lhs.bounds, from: lhs)
let rhsFrame = root.convert(rhs.bounds, from: rhs)
return lhsFrame.minX < rhsFrame.minX
}
var bestHit: HostedInspectorDividerHit?
var bestScore = -CGFloat.greatestFiniteMagnitude
for inspectorCandidate in inspectorCandidates {
guard let candidate = hostedInspectorDividerCandidate(in: root, startingAt: inspectorCandidate) else {
continue
}
let score = hostedInspectorDividerCandidateScore(candidate)
if score > bestScore {
bestScore = score
bestHit = candidate
}
}
return bestHit
}
private func hostedInspectorDividerCandidateUsingKnownWebViews(in root: NSView) -> HostedInspectorDividerHit? {
guard let pageLeaf = hostedWebView,
let inspectorLeaf = hostedInspectorFrontendWebView,
pageLeaf.isDescendant(of: root),
inspectorLeaf.isDescendant(of: root),
Self.isVisibleHostedInspectorCandidate(inspectorLeaf) else {
return nil
}
return hostedInspectorDividerCandidate(
in: root,
pageLeaf: pageLeaf,
inspectorLeaf: inspectorLeaf
)
}
private func hostedInspectorDividerCandidate(
in root: NSView,
pageLeaf: NSView,
inspectorLeaf: NSView
) -> HostedInspectorDividerHit? {
var currentInspector: NSView? = inspectorLeaf
while let inspectorView = currentInspector, inspectorView !== root {
guard let containerView = inspectorView.superview else { break }
guard containerView === root || containerView.isDescendant(of: root) else {
currentInspector = containerView
continue
}
guard let pageView = Self.directChild(of: containerView, containing: pageLeaf) else {
currentInspector = containerView
continue
}
guard pageView !== inspectorView,
Self.isVisibleHostedInspectorSiblingCandidate(pageView),
Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame) > 8,
let dockSide = HostedInspectorDockSide.resolve(
pageFrame: pageView.frame,
inspectorFrame: inspectorView.frame
) else {
currentInspector = containerView
continue
}
return HostedInspectorDividerHit(
containerView: containerView,
pageView: pageView,
inspectorView: inspectorView,
dockSide: dockSide
)
}
return nil
}
private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect {
let pageFrame = convert(hit.pageView.bounds, from: hit.pageView)
let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView)
return hit.dockSide.dividerHitRect(
in: bounds,
pageFrame: pageFrame,
inspectorFrame: inspectorFrame,
expansion: Self.hostedInspectorDividerHitExpansion
)
}
private func hostedInspectorDividerCandidate(in root: NSView, startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? {
var current: NSView? = inspectorLeaf
var bestHit: HostedInspectorDividerHit?
while let inspectorView = current, inspectorView !== root {
guard let containerView = inspectorView.superview else { break }
let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in
guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil }
guard candidate !== inspectorView else { return nil }
guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else {
return nil
}
guard let dockSide = HostedInspectorDockSide.resolve(
pageFrame: candidate.frame,
inspectorFrame: inspectorView.frame
) else {
return nil
}
return (view: candidate, dockSide: dockSide)
}
if let pageCandidate = pageCandidates.max(by: {
hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView)
< hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView)
}) {
bestHit = HostedInspectorDividerHit(
containerView: containerView,
pageView: pageCandidate.view,
inspectorView: inspectorView,
dockSide: pageCandidate.dockSide
)
}
current = containerView
}
return bestHit
}
private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat {
let pageFrame = convert(hit.pageView.bounds, from: hit.pageView)
let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView)
let overlap = Self.verticalOverlap(between: pageFrame, and: inspectorFrame)
let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX)
return (overlap * 1_000) + coverageWidth + pageFrame.width
}
private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat {
let overlap = Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame)
let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX)
return (overlap * 1_000) + coverageWidth + pageView.frame.width
}
fileprivate func scheduleHostedInspectorDividerReapply(reason: String) {
hostedInspectorReapplyWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.hostedInspectorReapplyWorkItem = nil
_ = self.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
if self.hasStoredHostedInspectorWidthPreference {
self.reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: reason)
} else {
self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason)
}
}
hostedInspectorReapplyWorkItem = workItem
DispatchQueue.main.async(execute: workItem)
}
private func captureHostedInspectorPreferredWidthFromCurrentLayout(reason: String) {
guard !isApplyingHostedInspectorLayout else { return }
guard !isHostedInspectorDividerDragActive else { return }
guard let hit = hostedInspectorDividerCandidate() else {
#if DEBUG
if !hasLoggedMissingHostedInspectorCandidate {
hasLoggedMissingHostedInspectorCandidate = true
let preferredWidthDesc = preferredHostedInspectorWidth.map {
String(format: "%.1f", $0)
} ?? "nil"
dlog(
"browser.panel.hostedInspector stage=\(reason).captureMissingCandidate " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)"
)
}
#endif
return
}
let inspectorWidth = max(0, hit.inspectorView.frame.width)
guard inspectorWidth > 1 else { return }
recordHostedInspectorSideDockWidth(inspectorWidth)
let currentFraction: CGFloat? = {
guard hit.containerView.bounds.width > 0 else { return nil }
return inspectorWidth / hit.containerView.bounds.width
}()
let widthMatches = preferredHostedInspectorWidth.map {
abs($0 - inspectorWidth) <= 0.5
} ?? false
let fractionMatches: Bool = {
switch (preferredHostedInspectorWidthFraction, currentFraction) {
case (nil, nil):
return true
case let (lhs?, rhs?):
return abs(lhs - rhs) <= 0.001
default:
return false
}
}()
guard !(widthMatches && fractionMatches) else { return }
#if DEBUG
hasLoggedMissingHostedInspectorCandidate = false
#endif
recordPreferredHostedInspectorWidth(
inspectorWidth,
containerBounds: hit.containerView.bounds
)
}
private func reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: String) {
guard !isApplyingHostedInspectorLayout else { return }
guard let hit = hostedInspectorDividerCandidate() else { return }
guard let preferredWidth = resolvedPreferredHostedInspectorWidth(in: hit.containerView.bounds) else {
return
}
let currentInspectorWidth = max(0, hit.inspectorView.frame.width)
guard abs(currentInspectorWidth - preferredWidth) > 0.5 else { return }
_ = applyHostedInspectorDividerWidth(
preferredWidth,
to: hit,
minimumInspectorWidth: Self.minimumHostedInspectorWidth,
reason: reason
)
}
@discardableResult
private func applyHostedInspectorDividerWidth(
_ preferredWidth: CGFloat,
to hit: HostedInspectorDividerHit,
minimumInspectorWidth: CGFloat,
reason: String
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
let containerBounds = hit.containerView.bounds
let nextFrames = hit.dockSide.resizedFrames(
preferredWidth: preferredWidth,
in: containerBounds,
pageFrame: hit.pageView.frame,
inspectorFrame: hit.inspectorView.frame,
minimumInspectorWidth: minimumInspectorWidth
)
let pageFrame = nextFrames.pageFrame
let inspectorFrame = nextFrames.inspectorFrame
let oldPageFrame = hit.pageView.frame
let oldInspectorFrame = hit.inspectorView.frame
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5)
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5)
guard pageChanged || inspectorChanged else {
return (pageFrame, inspectorFrame)
}
recordHostedInspectorSideDockWidth(inspectorFrame.width)
isApplyingHostedInspectorLayout = true
CATransaction.begin()
CATransaction.setDisableActions(true)
hit.pageView.frame = pageFrame
hit.inspectorView.frame = inspectorFrame
CATransaction.commit()
isApplyingHostedInspectorLayout = false
hit.pageView.needsDisplay = true
hit.pageView.setNeedsDisplay(hit.pageView.bounds)
hit.inspectorView.needsDisplay = true
hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds)
hit.containerView.needsDisplay = true
hit.containerView.setNeedsDisplay(hit.containerView.bounds)
if let localInlineSlotView {
localInlineSlotView.needsDisplay = true
localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds)
}
needsDisplay = true
setNeedsDisplay(bounds)
let isLiveDrag = reason == "drag"
#if DEBUG
dlog(
"browser.panel.hostedInspector stage=\(reason).reapply " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " +
"liveDrag=\(isLiveDrag ? 1 : 0) " +
"pageChanged=\(pageChanged ? 1 : 0) inspectorChanged=\(inspectorChanged ? 1 : 0) " +
"oldPage=\(Self.debugRect(oldPageFrame)) oldInspector=\(Self.debugRect(oldInspectorFrame)) " +
"container=\(Self.debugObjectID(hit.containerView)) " +
"pageFrame=\(Self.debugRect(pageFrame)) inspectorFrame=\(Self.debugRect(inspectorFrame))"
)
#endif
return (pageFrame, inspectorFrame)
}
private static func visibleDescendants(in root: NSView) -> [NSView] {
var descendants: [NSView] = []
var stack = Array(root.subviews.reversed())
while let view = stack.popLast() {
descendants.append(view)
stack.append(contentsOf: view.subviews.reversed())
}
return descendants
}
private static func directChild(of container: NSView, containing descendant: NSView) -> NSView? {
var current: NSView? = descendant
var directChild: NSView?
while let view = current, view !== container {
directChild = view
current = view.superview
}
guard current === container else { return nil }
return directChild
}
fileprivate static func isInspectorView(_ view: NSView) -> Bool {
String(describing: type(of: view)).contains("WKInspector")
}
fileprivate static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.width > 1 &&
view.frame.height > 1
}
private static func isVisibleHostedInspectorSiblingCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.height > 1
}
private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY))
}
}
#if DEBUG
private static func logDevToolsState(
_ panel: BrowserPanel,
event: String,
generation: Int,
retryCount: Int,
details: String? = nil
) {
var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())"
if let details, !details.isEmpty {
line += " \(details)"
}
dlog(line)
}
private static func objectID(_ object: AnyObject?) -> String {
guard let object else { return "nil" }
return String(describing: Unmanaged.passUnretained(object).toOpaque())
}
private static func responderDescription(_ responder: NSResponder?) -> String {
guard let responder else { return "nil" }
return "\(type(of: responder))@\(objectID(responder))"
}
private static func rectDescription(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
}
private static func attachContext(webView: WKWebView, host: NSView) -> String {
let hostWindow = host.window?.windowNumber ?? -1
let webWindow = webView.window?.windowNumber ?? -1
let firstResponder = (webView.window ?? host.window)?.firstResponder
return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))"
}
#endif
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
var r = start
var hops = 0
while let cur = r, hops < 64 {
if cur === target { return true }
r = cur.nextResponder
hops += 1
}
return false
}
private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool {
guard let responder else { return false }
let responderType = String(describing: type(of: responder))
if responderType.contains("WKInspector") {
return true
}
guard let view = responder as? NSView else { return false }
var node: NSView? = view
var hops = 0
while let current = node, hops < 64 {
if String(describing: type(of: current)).contains("WKInspector") {
return true
}
node = current.superview
hops += 1
}
return false
}
private static func firstResponderResignState(
_ responder: NSResponder?,
webView: WKWebView
) -> (needsResign: Bool, flags: String) {
let inWebViewChain = responderChainContains(responder, target: webView)
let inspectorResponder = isLikelyInspectorResponder(responder)
let needsResign = inWebViewChain || inspectorResponder
return (
needsResign: needsResign,
flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)"
)
}
func makeCoordinator() -> Coordinator {
let coordinator = Coordinator()
coordinator.panel = panel
return coordinator
}
func makeNSView(context: Context) -> NSView {
let container = HostContainerView()
container.wantsLayer = true
return container
}
private static func clearPortalCallbacks(for host: NSView) {
guard let host = host as? HostContainerView else { return }
host.onDidMoveToWindow = nil
host.onGeometryChanged = nil
host.clearLocalInlineCallbacks()
}
private static func shouldPreserveExternalFullscreenHost(
for webView: WKWebView,
relativeTo expectedWindow: NSWindow?
) -> Bool {
webView.cmuxIsManagedByExternalFullscreenWindow(relativeTo: expectedWindow)
}
private static func localInlineTransferRoot(for webView: WKWebView) -> NSView? {
var current = webView.superview
var last: NSView?
while let view = current {
if view is WindowBrowserSlotView {
return view
}
if view is HostContainerView {
break
}
last = view
current = view.superview
}
return last ?? webView.superview
}
private static func moveWebKitRelatedSubviewsIntoHostIfNeeded(
from sourceSuperview: NSView,
to container: WindowBrowserSlotView,
primaryWebView: WKWebView,
reason: String
) {
guard sourceSuperview !== container else { return }
let relatedSubviews = sourceSuperview.subviews.filter { view in
if view === primaryWebView { return true }
let className = String(describing: type(of: view))
guard className.contains("WK") else { return false }
if className.contains("WKInspector") {
return !view.isHidden && view.alphaValue > 0 && view.frame.width > 1 && view.frame.height > 1
}
return true
}
guard !relatedSubviews.isEmpty else { return }
let preserveSlotLocalFrames = sourceSuperview is WindowBrowserSlotView
let sourceSlotBoundsSize = sourceSuperview.bounds.size
#if DEBUG
dlog(
"browser.localHost.reparent.batch reason=\(reason) source=\(Self.objectID(sourceSuperview)) " +
"container=\(Self.objectID(container)) count=\(relatedSubviews.count) " +
"sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: container)))"
)
#endif
for view in relatedSubviews {
let className = String(describing: type(of: view))
let targetFrame: NSRect
if preserveSlotLocalFrames {
targetFrame = view.frame
} else {
let frameInWindow = sourceSuperview.convert(view.frame, to: nil)
targetFrame = container.convert(frameInWindow, from: nil)
}
view.removeFromSuperview()
container.addSubview(view, positioned: .above, relativeTo: nil)
view.frame = targetFrame
#if DEBUG
dlog(
"browser.localHost.reparent.batch.item reason=\(reason) class=\(className) " +
"view=\(Self.objectID(view))"
)
#endif
}
if preserveSlotLocalFrames, sourceSlotBoundsSize != container.bounds.size {
container.resizeSubviews(withOldSize: sourceSlotBoundsSize)
container.needsLayout = true
container.layoutSubtreeIfNeeded()
}
}
private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) {
// SwiftUI can keep transient replacement hosts alive off-window during split
// reparenting. Never let those hosts steal the shared portal anchor, or the
// portal will bind against an anchor with no real window and WKWebView will
// fall into a hidden/unrendered state.
guard host.window != nil else { return }
if anchorView.superview !== host {
anchorView.removeFromSuperview()
anchorView.translatesAutoresizingMaskIntoConstraints = false
host.addSubview(anchorView)
NSLayoutConstraint.activate([
anchorView.topAnchor.constraint(equalTo: host.topAnchor),
anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
])
} else if anchorView.translatesAutoresizingMaskIntoConstraints {
anchorView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
anchorView.topAnchor.constraint(equalTo: host.topAnchor),
anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
])
}
host.layoutSubtreeIfNeeded()
}
private func updateUsingLocalInlineHosting(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
guard let host = nsView as? HostContainerView else { return false }
let slotView = host.ensureLocalInlineSlotView()
let isAlreadyInLocalHost = host.containsManagedLocalInlineContent(webView)
let shouldPreserveExternalFullscreenHost = Self.shouldPreserveExternalFullscreenHost(
for: webView,
relativeTo: host.window
)
let didAttachWebViewToLocalHost =
!isAlreadyInLocalHost && !shouldPreserveExternalFullscreenHost
let coordinator = context.coordinator
coordinator.desiredPortalVisibleInUI = false
coordinator.desiredPortalZPriority = 0
coordinator.attachGeneration += 1
if panel.releasePortalHostIfOwned(
hostId: ObjectIdentifier(host),
reason: "localInlineHosting"
) {
BrowserWindowPortalRegistry.hide(
webView: webView,
source: "viewStateChanged.localInlineHosting"
)
}
let shouldPreserveExistingExternalLocalHost =
host.window == nil &&
webView.superview != nil &&
!host.containsManagedLocalInlineContent(webView)
if shouldPreserveExistingExternalLocalHost {
// Split zoom can instantiate a replacement local host before it joins a window.
// Never let that off-window host steal the live page + inspector hierarchy away
// from the currently visible local host.
host.setLocalInlineSlotHidden(true)
coordinator.lastPortalHostId = nil
coordinator.lastSynchronizedHostGeometryRevision = 0
#if DEBUG
dlog(
"browser.localHost.reparent.skip web=\(Self.objectID(webView)) " +
"reason=offWindowReplacementHost super=\(Self.objectID(webView.superview)) " +
"host=\(Self.objectID(host)) slot=\(Self.objectID(slotView))"
)
Self.logDevToolsState(
panel,
event: "localHost.skip",
generation: coordinator.attachGeneration,
retryCount: 0,
details: Self.attachContext(webView: webView, host: host)
)
#endif
return false
}
#if DEBUG
if shouldPreserveExternalFullscreenHost {
dlog(
"browser.localHost.reparent.skip web=\(Self.objectID(webView)) " +
"reason=fullscreenExternalHost host=\(Self.objectID(host)) " +
"slot=\(Self.objectID(slotView)) state=\(String(describing: webView.fullscreenState))"
)
}
#endif
let preferredAttachedWidthState = panel.preferredAttachedDeveloperToolsWidthState()
host.setPreferredHostedInspectorWidth(
width: preferredAttachedWidthState.width,
widthFraction: preferredAttachedWidthState.widthFraction
)
host.setHostedInspectorFrontendWebView(webView.cmuxInspectorFrontendWebView())
host.onPreferredHostedInspectorWidthChanged = { [weak browserPanel = panel] width, _ in
guard let browserPanel else { return }
browserPanel.recordPreferredAttachedDeveloperToolsWidth(
width,
containerBounds: slotView.bounds
)
}
slotView.onHostedInspectorLayout = { [weak host] _ in
host?.scheduleHostedInspectorDividerReapply(reason: "slot.layout")
host?.scheduleHostedInspectorDockConfigurationSync(reason: "slot.layout")
}
if didAttachWebViewToLocalHost {
if let sourceSuperview = Self.localInlineTransferRoot(for: webView) {
Self.moveWebKitRelatedSubviewsIntoHostIfNeeded(
from: sourceSuperview,
to: slotView,
primaryWebView: webView,
reason: "attachLocalHost"
)
} else {
slotView.addSubview(webView, positioned: .above, relativeTo: nil)
}
}
slotView.isHidden = false
host.pinHostedWebView(
webView,
in: host.currentHostedWebViewContainer(preferredSlotView: slotView)
)
coordinator.lastPortalHostId = nil
coordinator.lastSynchronizedHostGeometryRevision = 0
if didAttachWebViewToLocalHost {
panel.noteDeveloperToolsHostAttached()
panel.restoreDeveloperToolsAfterAttachIfNeeded()
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
slotView.layoutSubtreeIfNeeded()
host.layoutSubtreeIfNeeded()
host.normalizeHostedInspectorLayoutIfNeeded(reason: "localInline.update.immediate")
host.scheduleHostedInspectorDividerReapply(reason: "localInline.update.sync")
DispatchQueue.main.async { [weak host, weak webView] in
guard let host, let webView else { return }
host.setHostedInspectorFrontendWebView(webView.cmuxInspectorFrontendWebView())
host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update.async")
}
} else if !shouldPreserveExternalFullscreenHost {
panel.consumeAttachedDeveloperToolsManualCloseIfNeeded()
host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update")
}
#if DEBUG
Self.logDevToolsState(
panel,
event: "localHost.update",
generation: coordinator.attachGeneration,
retryCount: 0,
details: Self.attachContext(webView: webView, host: host)
)
#endif
return !shouldPreserveExternalFullscreenHost
}
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
guard let host = nsView as? HostContainerView else { return false }
host.prepareForWindowPortalHosting()
host.setLocalInlineSlotHidden(true)
host.releaseHostedWebViewConstraints()
let shouldPreserveExternalFullscreenHost = Self.shouldPreserveExternalFullscreenHost(
for: webView,
relativeTo: host.window
)
let coordinator = context.coordinator
let paneDropContext = currentPaneDropContext()
let isCurrentPaneOwner = paneDropContext?.paneId.id == paneId.id
let hostId = ObjectIdentifier(host)
let previousVisible = coordinator.desiredPortalVisibleInUI
let previousZPriority = coordinator.desiredPortalZPriority
coordinator.desiredPortalVisibleInUI = shouldAttachWebView && isCurrentPaneOwner
coordinator.desiredPortalZPriority = portalZPriority
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
let activePaneDropContext = coordinator.desiredPortalVisibleInUI ? paneDropContext : nil
let activeSearchOverlay = coordinator.desiredPortalVisibleInUI ? searchOverlay : nil
let portalAnchorView = panel.portalAnchorView
let portalHideReason = !isCurrentPaneOwner ? "lostPaneOwnership" : "hidden"
let didReleasePortalHost: Bool
if !shouldAttachWebView || !isCurrentPaneOwner {
didReleasePortalHost = panel.releasePortalHostIfOwned(
hostId: hostId,
reason: portalHideReason
)
// Only the host that currently owns the portal is allowed to hide it.
// Older keep-alive hosts can still receive updates after a new owner binds.
if didReleasePortalHost {
BrowserWindowPortalRegistry.hide(
webView: webView,
source: "viewStateChanged.\(portalHideReason)"
)
}
} else {
didReleasePortalHost = false
}
let portalHostAccepted =
shouldAttachWebView &&
isCurrentPaneOwner &&
panel.claimPortalHost(
hostId: hostId,
paneId: paneId,
inWindow: host.window != nil,
bounds: host.bounds,
reason: "update"
)
#if DEBUG
if !isCurrentPaneOwner && (shouldAttachWebView || host.window != nil) {
dlog(
"browser.portal.owner.skip panel=\(panel.id.uuidString.prefix(5)) " +
"viewPane=\(paneId.id.uuidString.prefix(5)) " +
"currentPane=\(paneDropContext?.paneId.id.uuidString.prefix(5) ?? "nil") " +
"host=\(Self.objectID(host)) hostInWin=\(host.window != nil ? 1 : 0) " +
"released=\(didReleasePortalHost ? 1 : 0)"
)
}
#endif
if host.window != nil, portalHostAccepted {
Self.installPortalAnchorView(portalAnchorView, in: host)
}
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in
guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return }
guard coordinator.attachGeneration == generation else { return }
guard currentPaneDropContext()?.paneId.id == paneId.id else { return }
guard browserPanel.claimPortalHost(
hostId: ObjectIdentifier(host),
paneId: paneId,
inWindow: host.window != nil,
bounds: host.bounds,
reason: "didMoveToWindow"
) else { return }
guard host.window != nil else { return }
Self.installPortalAnchorView(portalAnchorView, in: host)
BrowserWindowPortalRegistry.bind(
webView: webView,
to: portalAnchorView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
BrowserWindowPortalRegistry.refresh(
webView: webView,
reason: "portalHostBind.didMoveToWindow"
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
coordinator.lastPortalHostId = ObjectIdentifier(host)
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
}
host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in
guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return }
guard coordinator.attachGeneration == generation else { return }
guard currentPaneDropContext()?.paneId.id == paneId.id else { return }
guard browserPanel.claimPortalHost(
hostId: ObjectIdentifier(host),
paneId: paneId,
inWindow: host.window != nil,
bounds: host.bounds,
reason: "geometryChanged"
) else { return }
guard host.window != nil else { return }
let hostId = ObjectIdentifier(host)
Self.installPortalAnchorView(portalAnchorView, in: host)
if coordinator.lastPortalHostId != hostId ||
!BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) {
BrowserWindowPortalRegistry.bind(
webView: webView,
to: portalAnchorView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
BrowserWindowPortalRegistry.refresh(
webView: webView,
reason: "portalHostBind.geometryChanged"
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
coordinator.lastPortalHostId = hostId
}
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
}
if !shouldAttachWebView {
// In portal mode we no longer detach/re-attach to preserve DevTools state.
// Sync the inspector preference directly so manual closes are respected.
panel.syncDeveloperToolsPreferenceFromInspector(
preserveVisibleIntent: panel.shouldPreserveDeveloperToolsIntentWhileDetached()
)
}
if host.window != nil, portalHostAccepted {
let geometryRevision = host.geometryRevision
let portalEntryMissing = !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView)
let shouldBindNow =
coordinator.lastPortalHostId != hostId ||
webView.superview == nil ||
portalEntryMissing ||
previousVisible != shouldAttachWebView ||
previousZPriority != portalZPriority
if shouldBindNow {
Self.installPortalAnchorView(portalAnchorView, in: host)
BrowserWindowPortalRegistry.bind(
webView: webView,
to: portalAnchorView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
// Force a rendering-state reattach after portal host replacement
// (e.g. after a pane split). Without this, WKWebView can freeze
// because _exitInWindow/_enterInWindow are never cycled when the
// web view is reparented to a new container during bind.
BrowserWindowPortalRegistry.refresh(
webView: webView,
reason: "portalHostBind"
)
coordinator.lastPortalHostId = hostId
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
}
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
if !shouldBindNow,
coordinator.lastSynchronizedHostGeometryRevision != geometryRevision {
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
}
} else if portalHostAccepted {
// Bind is deferred until host moves into a window. Keep the current
// portal entry's desired state in sync so stale callbacks cannot keep
// the previous anchor visible while this host is temporarily off-window.
BrowserWindowPortalRegistry.updateEntryVisibility(
for: webView,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
}
if portalHostAccepted {
BrowserWindowPortalRegistry.updateDropZoneOverlay(
for: webView,
zone: coordinator.desiredPortalVisibleInUI ? paneDropZone : nil
)
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
for: webView,
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
)
BrowserWindowPortalRegistry.updatePaneDropContext(
for: webView,
context: activePaneDropContext
)
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
}
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
Self.logDevToolsState(
panel,
event: "portal.update",
generation: coordinator.attachGeneration,
retryCount: 0,
details: Self.attachContext(webView: webView, host: host)
)
#endif
return portalHostAccepted && !shouldPreserveExternalFullscreenHost
}
func updateNSView(_ nsView: NSView, context: Context) {
let webView = panel.webView
let coordinator = context.coordinator
let isCurrentPaneOwner = currentPaneDropContext()?.paneId.id == paneId.id
if let previousWebView = coordinator.webView, previousWebView !== webView {
BrowserWindowPortalRegistry.detach(webView: previousWebView)
coordinator.lastPortalHostId = nil
coordinator.lastSynchronizedHostGeometryRevision = 0
}
coordinator.panel = panel
coordinator.webView = webView
Self.clearPortalCallbacks(for: nsView)
let hostOwnsPortal = useLocalInlineHosting
? updateUsingLocalInlineHosting(nsView, context: context, webView: webView)
: updateUsingWindowPortal(nsView, context: context, webView: webView)
Self.applyWebViewFirstResponderPolicy(
panel: panel,
webView: webView,
isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal
)
Self.applyFocus(
panel: panel,
webView: webView,
nsView: nsView,
shouldFocusWebView: shouldFocusWebView && isCurrentPaneOwner && hostOwnsPortal,
isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal
)
}
private static func applyFocus(
panel: BrowserPanel,
webView: WKWebView,
nsView: NSView,
shouldFocusWebView: Bool,
isPanelFocused: Bool
) {
// Focus handling. Avoid fighting the address bar when it is focused.
guard let window = nsView.window else {
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=skip reason=no_window shouldFocus=\(shouldFocusWebView ? 1 : 0) " +
"panelFocused=\(isPanelFocused ? 1 : 0)"
)
#endif
return
}
if isPanelFocused && responderChainContains(window.firstResponder, target: webView) {
panel.noteWebViewFocused()
}
if shouldFocusWebView {
if panel.shouldSuppressWebViewFocus() {
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=skip reason=suppressed panelFocused=\(isPanelFocused ? 1 : 0)"
)
#endif
return
}
if responderChainContains(window.firstResponder, target: webView) {
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=skip reason=already_first_responder_chain"
)
#endif
return
}
let result = window.makeFirstResponder(webView)
if result {
panel.noteWebViewFocused()
}
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=focus result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))"
)
#endif
} else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) {
// Only force-resign WebView focus when this panel itself is not focused.
// If the panel is focused but the omnibar-focus state is briefly stale, aggressively
// clearing first responder here can undo programmatic webview focus (socket tests).
let result = window.makeFirstResponder(nil)
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
"action=resign result=\(result ? 1 : 0) fr=\(responderDescription(window.firstResponder))"
)
#endif
}
}
private static func applyWebViewFirstResponderPolicy(
panel: BrowserPanel,
webView: WKWebView,
isPanelFocused: Bool
) {
guard let cmuxWebView = webView as? CmuxWebView else { return }
let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
if cmuxWebView.allowsFirstResponderAcquisition != next {
#if DEBUG
dlog(
"browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " +
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
"new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " +
"suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)"
)
#endif
}
cmuxWebView.allowsFirstResponderAcquisition = next
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachGeneration += 1
clearPortalCallbacks(for: nsView)
if let panel = coordinator.panel, let host = nsView as? HostContainerView {
panel.releasePortalHostIfOwned(
hostId: ObjectIdentifier(host),
reason: "dismantle"
)
}
guard let webView = coordinator.webView else { return }
let panel = coordinator.panel
// If we're being torn down while the WKWebView (or one of its subviews) is first responder,
// resign it before detaching.
let window = webView.window ?? nsView.window
if let window {
let state = firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
#if DEBUG
if let panel {
logDevToolsState(
panel,
event: "dismantle.resignFirstResponder",
generation: coordinator.attachGeneration,
retryCount: 0,
details: attachContext(webView: webView, host: nsView) + " " + state.flags
)
}
#endif
window.makeFirstResponder(nil)
}
}
// SwiftUI can transiently dismantle/rebuild the browser host view during split
// rearrangement. Do not detach the portal-hosted WKWebView or clear its pane-drop
// context here; explicit teardown still happens on real web view replacement and
// panel teardown, and preserving this state lets internal tab drags re-enter the
// browser pane while SwiftUI churns underneath.
BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil)
coordinator.lastPortalHostId = nil
coordinator.lastSynchronizedHostGeometryRevision = 0
}
private func currentPaneDropContext() -> BrowserPaneDropContext? {
guard let app = AppDelegate.shared,
let manager = app.tabManagerFor(tabId: panel.workspaceId),
let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }),
let paneId = workspace.paneId(forPanelId: panel.id) else {
return nil
}
return BrowserPaneDropContext(
workspaceId: panel.workspaceId,
panelId: panel.id,
paneId: paneId
)
}
}