Add browser find focus debug logs (#1162)

* Add browser find focus debug logs

* Allow browser find bar focus in portal host

* Add split and terminal find debug logs

* Avoid stealing search focus across splits

* Generalize panel focus restore intent

* Unify split focus intent activation

* Address focus restore review feedback

* Yield inactive panel focus before restore

* Gate browser find focus retries by generation

* Avoid repeated browser focus invalidation

* Keep browser find ownership while find bar is open
This commit is contained in:
Lawrence Chen 2026-03-10 19:31:52 -07:00 committed by GitHub
parent 527dfa6292
commit ec10dfdaec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1019 additions and 116 deletions

View file

@ -10327,7 +10327,14 @@ private extension NSWindow {
}
if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"),
let portalWebView = cmuxUniqueBrowserWebView(in: candidate) {
return portalWebView
// Portal-hosted browser chrome (for example the Cmd+F overlay) is a
// sibling of the hosted WKWebView inside WindowBrowserSlotView, not a
// descendant of it. Treating every view in that slot as "web-owned"
// blocks legitimate first-responder changes to overlay text fields.
if view === portalWebView || view.isDescendant(of: portalWebView) {
return portalWebView
}
return nil
}
current = candidate.superview
}

View file

@ -6,6 +6,7 @@ import WebKit
private var cmuxWindowBrowserPortalKey: UInt8 = 0
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0
#if DEBUG
private func browserPortalDebugToken(_ view: NSView?) -> String {
@ -31,6 +32,17 @@ private extension NSObject {
}
}
private extension NSResponder {
var browserPortalOwningView: NSView? {
if let editor = self as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
return editedView
}
return self as? NSView
}
}
private extension WKWebView {
func browserPortalNotifyHidden(reason: String) {
let firedSelectors = ["viewDidHide", "_exitInWindow"].filter {
@ -978,9 +990,12 @@ private final class BrowserDropZoneOverlayView: NSView {
struct BrowserPortalSearchOverlayConfiguration {
let panelId: UUID
let searchState: BrowserSearchState
let focusRequestGeneration: UInt64
let canApplyFocusRequest: (UInt64) -> Bool
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
let onFieldDidFocus: () -> Void
}
struct BrowserPaneDropContext: Equatable {
@ -1420,23 +1435,63 @@ final class WindowBrowserSlotView: NSView {
applyResolvedDropZoneOverlay()
}
private func logSearchOverlayEvent(_ action: String, panelId: UUID?) {
#if DEBUG
let firstResponderSummary: String = {
guard let firstResponder = window?.firstResponder else { return "nil" }
if let editor = firstResponder as? NSTextView, editor.isFieldEditor {
let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil"
return "fieldEditor(delegate=\(delegateSummary))"
}
return String(describing: type(of: firstResponder))
}()
dlog(
"browser.findbar.portal action=\(action) " +
"panel=\(panelId?.uuidString.prefix(5) ?? "nil") " +
"window=\(window?.windowNumber ?? -1) " +
"firstResponder=\(firstResponderSummary) " +
"hasOverlay=\(searchOverlayHostingView != nil ? 1 : 0)"
)
#endif
}
func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) {
guard let configuration else {
logSearchOverlayEvent("remove", panelId: nil)
if let overlay = searchOverlayHostingView {
objc_setAssociatedObject(
overlay,
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
nil,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
searchOverlayHostingView?.removeFromSuperview()
searchOverlayHostingView = nil
return
}
logSearchOverlayEvent("set", panelId: configuration.panelId)
let rootView = BrowserSearchOverlay(
panelId: configuration.panelId,
searchState: configuration.searchState,
focusRequestGeneration: configuration.focusRequestGeneration,
canApplyFocusRequest: configuration.canApplyFocusRequest,
onNext: configuration.onNext,
onPrevious: configuration.onPrevious,
onClose: configuration.onClose
onClose: configuration.onClose,
onFieldDidFocus: configuration.onFieldDidFocus
)
if let overlay = searchOverlayHostingView {
logSearchOverlayEvent("updateExisting", panelId: configuration.panelId)
overlay.rootView = rootView
objc_setAssociatedObject(
overlay,
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
configuration.panelId,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
if overlay.superview !== self {
overlay.removeFromSuperview()
addSubview(overlay)
@ -1452,6 +1507,12 @@ final class WindowBrowserSlotView: NSView {
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
objc_setAssociatedObject(
overlay,
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
configuration.panelId,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
@ -1460,6 +1521,25 @@ final class WindowBrowserSlotView: NSView {
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
searchOverlayHostingView = overlay
logSearchOverlayEvent("create", panelId: configuration.panelId)
}
func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
guard let overlay = searchOverlayHostingView,
let view = responder.browserPortalOwningView,
view.isDescendant(of: overlay) else {
return nil
}
return objc_getAssociatedObject(overlay, &cmuxBrowserSearchOverlayPanelIdAssociationKey) as? UUID
}
@discardableResult
func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
guard let firstResponder = window.firstResponder,
searchOverlayPanelId(for: firstResponder) == panelId else {
return false
}
return window.makeFirstResponder(nil)
}
func pinHostedWebView(_ webView: WKWebView) {
@ -1872,7 +1952,9 @@ final class WindowBrowserPortal: NSObject {
case (nil, nil):
return true
case let (lhs?, rhs?):
return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState
return lhs.panelId == rhs.panelId &&
lhs.searchState === rhs.searchState &&
lhs.focusRequestGeneration == rhs.focusRequestGeneration
default:
return false
}
@ -2144,6 +2226,26 @@ final class WindowBrowserPortal: NSObject {
entry.containerView?.setSearchOverlay(configuration)
}
func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
for entry in entriesByWebViewId.values {
if let panelId = entry.containerView?.searchOverlayPanelId(for: responder) {
return panelId
}
}
return nil
}
@discardableResult
func yieldSearchOverlayFocusIfOwned(by panelId: UUID) -> Bool {
guard let window else { return false }
for entry in entriesByWebViewId.values {
if entry.containerView?.yieldSearchOverlayFocusIfOwned(by: panelId, in: window) == true {
return true
}
}
return false
}
func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) {
guard var entry = entriesByWebViewId[webViewId] else { return }
let resolvedHeight = max(0, height)
@ -3031,6 +3133,19 @@ enum BrowserWindowPortalRegistry {
portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration)
}
static func searchOverlayPanelId(for responder: NSResponder, in window: NSWindow) -> UUID? {
let windowId = ObjectIdentifier(window)
guard let portal = portalsByWindowId[windowId] else { return nil }
return portal.searchOverlayPanelId(for: responder)
}
@discardableResult
static func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
let windowId = ObjectIdentifier(window)
guard let portal = portalsByWindowId[windowId] else { return false }
return portal.yieldSearchOverlayFocusIfOwned(by: panelId)
}
static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],

View file

@ -1398,15 +1398,10 @@ struct ContentView: View {
}
}
private enum CommandPaletteRestoreFocusIntent {
case panel
case browserAddressBar
}
private struct CommandPaletteRestoreFocusTarget {
let workspaceId: UUID
let panelId: UUID
let intent: CommandPaletteRestoreFocusIntent
let intent: PanelFocusIntent
}
private enum CommandPaletteInputFocusTarget {
@ -5337,7 +5332,7 @@ struct ContentView: View {
static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: Bool,
focusedBrowserAddressBarPanelId: UUID?,
focusedPanelId: UUID
focusedPanelId: UUID?
) -> Bool {
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId
}
@ -5383,15 +5378,10 @@ struct ContentView: View {
private func presentCommandPalette(initialQuery: String) {
if let panelContext = focusedPanelContext {
let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: panelContext.panel.panelType == .browser,
focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(),
focusedPanelId: panelContext.panelId
)
commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget(
workspaceId: panelContext.workspace.id,
panelId: panelContext.panelId,
intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel
intent: panelContext.panel.captureFocusIntent(in: observedWindow)
)
} else {
commandPaletteRestoreFocusTarget = nil
@ -5468,7 +5458,7 @@ struct ContentView: View {
if let clickedFocusTarget {
dlog(
"palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " +
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(clickedFocusTarget.intent == .browserAddressBar ? "addressBar" : "panel")"
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(debugCommandPaletteFocusIntent(clickedFocusTarget.intent))"
)
} else {
dlog("palette.dismiss.backdrop focusTarget=nil")
@ -5507,10 +5497,11 @@ struct ContentView: View {
let workspaceId = terminalView.tabId,
let panelId = terminalView.terminalSurface?.id,
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
return CommandPaletteRestoreFocusTarget(
return commandPaletteRestoreFocusTarget(
workspaceId: workspaceId,
panelId: panelId,
intent: .panel
fallbackIntent: .terminal(.surface),
in: window
)
}
@ -5522,10 +5513,11 @@ struct ContentView: View {
let workspaceId = terminalView.tabId,
let panelId = terminalView.terminalSurface?.id,
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
return CommandPaletteRestoreFocusTarget(
return commandPaletteRestoreFocusTarget(
workspaceId: workspaceId,
panelId: panelId,
intent: .panel
fallbackIntent: .terminal(.surface),
in: observedWindow
)
}
@ -5563,16 +5555,35 @@ struct ContentView: View {
continue
}
return CommandPaletteRestoreFocusTarget(
return commandPaletteRestoreFocusTarget(
workspaceId: workspace.id,
panelId: panelId,
intent: .panel
fallbackIntent: .browser(.webView),
in: observedWindow
)
}
return nil
}
private func commandPaletteRestoreFocusTarget(
workspaceId: UUID,
panelId: UUID,
fallbackIntent: PanelFocusIntent,
in window: NSWindow?
) -> CommandPaletteRestoreFocusTarget {
let intent = tabManager.tabs
.first(where: { $0.id == workspaceId })?
.panels[panelId]?
.captureFocusIntent(in: window) ?? fallbackIntent
return CommandPaletteRestoreFocusTarget(
workspaceId: workspaceId,
panelId: panelId,
intent: intent
)
}
private func restoreCommandPaletteFocus(
target: CommandPaletteRestoreFocusTarget,
attemptsRemaining: Int
@ -5588,8 +5599,9 @@ struct ContentView: View {
if let context = focusedPanelContext,
context.workspace.id == target.workspaceId,
context.panelId == target.panelId {
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
return
if context.panel.restoreFocusIntent(target.intent) {
return
}
}
guard attemptsRemaining > 0 else { return }
@ -5598,33 +5610,32 @@ struct ContentView: View {
if let context = focusedPanelContext,
context.workspace.id == target.workspaceId,
context.panelId == target.panelId {
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
return
if context.panel.restoreFocusIntent(target.intent) {
return
}
}
restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1)
}
}
private func restoreCommandPaletteInputFocusIfNeeded(
target: CommandPaletteRestoreFocusTarget,
attemptsRemaining: Int
) {
guard !isCommandPalettePresented else { return }
guard target.intent == .browserAddressBar else { return }
guard attemptsRemaining > 0 else { return }
guard let appDelegate = AppDelegate.shared else { return }
if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
restoreCommandPaletteInputFocusIfNeeded(
target: target,
attemptsRemaining: attemptsRemaining - 1
)
#if DEBUG
private func debugCommandPaletteFocusIntent(_ intent: PanelFocusIntent) -> String {
switch intent {
case .panel:
return "panel"
case .terminal(.surface):
return "terminal.surface"
case .terminal(.findField):
return "terminal.findField"
case .browser(.webView):
return "browser.webView"
case .browser(.addressBar):
return "browser.addressBar"
case .browser(.findField):
return "browser.findField"
}
}
#endif
private func resetCommandPaletteSearchFocus() {
applyCommandPaletteInputFocusPolicy(.search)

View file

@ -1,12 +1,16 @@
import AppKit
import Bonsplit
import SwiftUI
struct BrowserSearchOverlay: View {
let panelId: UUID
@ObservedObject var searchState: BrowserSearchState
let focusRequestGeneration: UInt64
let canApplyFocusRequest: (UInt64) -> Bool
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
let onFieldDidFocus: () -> Void
@State private var corner: Corner = .topRight
@State private var dragOffset: CGSize = .zero
@State private var barSize: CGSize = .zero
@ -14,12 +18,58 @@ struct BrowserSearchOverlay: View {
private let padding: CGFloat = 8
private func requestSearchFieldFocus(maxAttempts: Int = 3) {
#if DEBUG
private func debugFirstResponderSummary() -> String {
guard let window = NSApp.keyWindow else { return "nil" }
guard let firstResponder = window.firstResponder else { return "nil" }
if let editor = firstResponder as? NSTextView, editor.isFieldEditor {
let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil"
return "fieldEditor(delegate=\(delegateSummary))"
}
return String(describing: type(of: firstResponder))
}
#endif
private func logFocusState(_ event: String) {
#if DEBUG
let keyWindow = NSApp.keyWindow
dlog(
"browser.findbar.focus panel=\(panelId.uuidString.prefix(5)) " +
"event=\(event) keyWindow=\(keyWindow?.windowNumber ?? -1) " +
"firstResponder=\(debugFirstResponderSummary()) " +
"focused=\(isSearchFieldFocused ? 1 : 0)"
)
#endif
}
private func requestSearchFieldFocus(maxAttempts: Int = 3, origin: String) {
guard maxAttempts > 0 else { return }
guard canApplyFocusRequest(focusRequestGeneration) else {
#if DEBUG
logFocusState("request.skip origin=\(origin) generation=\(focusRequestGeneration)")
#endif
return
}
logFocusState("request.begin origin=\(origin) remaining=\(maxAttempts)")
isSearchFieldFocused = true
#if DEBUG
DispatchQueue.main.async {
guard canApplyFocusRequest(focusRequestGeneration) else {
logFocusState("request.skipAsync origin=\(origin) generation=\(focusRequestGeneration)")
return
}
logFocusState("request.afterAsync origin=\(origin) remaining=\(maxAttempts)")
}
#endif
guard maxAttempts > 1 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
requestSearchFieldFocus(maxAttempts: maxAttempts - 1)
guard canApplyFocusRequest(focusRequestGeneration) else {
#if DEBUG
logFocusState("request.skipRetry origin=\(origin) generation=\(focusRequestGeneration)")
#endif
return
}
requestSearchFieldFocus(maxAttempts: maxAttempts - 1, origin: origin)
}
}
@ -102,16 +152,24 @@ struct BrowserSearchOverlay: View {
.clipShape(clipShape)
.shadow(radius: 4)
.onAppear {
#if DEBUG
#if DEBUG
dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))")
#endif
requestSearchFieldFocus()
#endif
logFocusState("appear")
requestSearchFieldFocus(origin: "appear")
}
.onChange(of: isSearchFieldFocused) { _, focused in
logFocusState("focusState.change next=\(focused ? 1 : 0)")
if focused {
onFieldDidFocus()
}
}
.onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in
guard let notifiedPanelId = notification.object as? UUID,
notifiedPanelId == panelId else { return }
logFocusState("notification.received")
DispatchQueue.main.async {
requestSearchFieldFocus()
requestSearchFieldFocus(origin: "notification")
}
}
.background(

View file

@ -328,10 +328,19 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable {
field.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === field
#if DEBUG
dlog("find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) alreadyFocused=\(alreadyFocused)")
dlog(
"find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " +
"alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: fr))"
)
#endif
guard !alreadyFocused else { return }
window.makeFirstResponder(field)
let result = window.makeFirstResponder(field)
#if DEBUG
dlog(
"find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
}
return field

View file

@ -6406,10 +6406,8 @@ final class GhosttySurfaceScrollView: NSView {
#endif
if active {
applyFirstResponderIfNeeded()
} else if let window,
let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
window.makeFirstResponder(nil)
} else {
resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
}
}
@ -6459,15 +6457,29 @@ final class GhosttySurfaceScrollView: NSView {
#if DEBUG
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
dlog("find.moveFocus to=\(surfaceShort) searchState=\(searchActive ? "active" : "nil")")
dlog(
"find.moveFocus to=\(surfaceShort) " +
"from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"searchState=\(searchActive ? "active" : "nil") " +
"delayMs=\(Int((delay ?? 0) * 1000))"
)
#endif
let work = { [weak self] in
guard let self else { return }
guard let window = self.window else { return }
#if DEBUG
let before = String(describing: window.firstResponder)
#endif
if let previous, previous !== self {
_ = previous.surfaceView.resignFirstResponder()
}
window.makeFirstResponder(self.surfaceView)
let result = window.makeFirstResponder(self.surfaceView)
#if DEBUG
dlog(
"find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))"
)
#endif
}
if let delay, delay > 0 {
@ -6611,6 +6623,12 @@ final class GhosttySurfaceScrollView: NSView {
guard isActive else { return }
guard let window else { return }
guard surfaceView.isVisibleInUI else {
#if DEBUG
dlog(
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"reason=not_visible attempts=\(attemptsRemaining)"
)
#endif
retry()
return
}
@ -6650,6 +6668,13 @@ final class GhosttySurfaceScrollView: NSView {
// Search focus restoration only after confirming this is the active tab/pane.
if surfaceView.terminalSurface?.searchState != nil {
#if DEBUG
dlog(
"focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
"attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
restoreSearchFocus(window: window)
return
}
@ -6663,7 +6688,15 @@ final class GhosttySurfaceScrollView: NSView {
if !window.isKeyWindow {
window.makeKeyAndOrderFront(nil)
}
_ = window.makeFirstResponder(surfaceView)
let result = window.makeFirstResponder(surfaceView)
#if DEBUG
dlog(
"focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " +
"attempts=\(attemptsRemaining)"
)
#endif
if !isSurfaceViewFirstResponder() {
retry()
@ -6793,6 +6826,18 @@ final class GhosttySurfaceScrollView: NSView {
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
switch searchFocusTarget {
case .searchField:
if let firstResponder = window.firstResponder,
isSearchOverlayOrDescendant(firstResponder),
!isCurrentSurfaceSearchResponder(firstResponder) {
surfaceView.terminalSurface?.setFocus(false)
#if DEBUG
dlog(
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
"reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))"
)
#endif
return
}
// Explicitly unfocus the terminal so cursor stops blinking immediately.
// The notification observer also does this, but it runs async when posted from main.
surfaceView.terminalSurface?.setFocus(false)
@ -6802,16 +6847,152 @@ final class GhosttySurfaceScrollView: NSView {
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
}
#if DEBUG
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=searchField via=notification")
dlog(
"find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
"via=notification firstResponder=\(String(describing: window.firstResponder))"
)
#endif
case .terminal:
window.makeFirstResponder(surfaceView)
let result = window.makeFirstResponder(surfaceView)
#if DEBUG
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal")
dlog(
"find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
}
}
func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
if surfaceView.terminalSurface?.searchState != nil {
if let firstResponder = window?.firstResponder as? NSView,
(firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) {
return .surface
}
if let firstResponder = window?.firstResponder,
isCurrentSurfaceSearchResponder(firstResponder) {
return .findField
}
if searchFocusTarget == .searchField {
return .findField
}
}
return .surface
}
func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent {
if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField {
return .findField
}
return .surface
}
func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) {
switch intent {
case .surface:
searchFocusTarget = .terminal
case .findField:
guard surfaceView.terminalSurface?.searchState != nil else { return }
searchFocusTarget = .searchField
}
#if DEBUG
dlog(
"find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"target=\(intent == .findField ? "searchField" : "terminal")"
)
#endif
}
@discardableResult
func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool {
switch intent {
case .surface:
searchFocusTarget = .terminal
setActive(true)
applyFirstResponderIfNeeded()
return true
case .findField:
guard let terminalSurface = surfaceView.terminalSurface,
terminalSurface.searchState != nil else {
return false
}
searchFocusTarget = .searchField
setActive(true)
if let window {
restoreSearchFocus(window: window)
} else {
terminalSurface.setFocus(false)
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
}
#if DEBUG
dlog(
"find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"target=searchField firstResponder=\(String(describing: window?.firstResponder))"
)
#endif
return true
}
}
func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? {
if isCurrentSurfaceSearchResponder(responder) {
return .findField
}
let resolvedResponder: NSResponder
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
resolvedResponder = editedView
} else {
resolvedResponder = responder
}
guard let view = resolvedResponder as? NSView else { return nil }
if view === surfaceView || view.isDescendant(of: surfaceView) {
return .surface
}
return nil
}
@discardableResult
func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool {
guard let firstResponder = window.firstResponder,
ownedPanelFocusIntent(for: firstResponder) == intent else {
return false
}
surfaceView.terminalSurface?.setFocus(false)
resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent")
#if DEBUG
dlog(
"focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"target=\(intent == .findField ? "searchField" : "terminal")"
)
#endif
return true
}
private func resignOwnedFirstResponderIfNeeded(reason: String) {
guard let window,
let firstResponder = window.firstResponder else { return }
let ownsSurfaceResponder: Bool = {
guard let view = firstResponder as? NSView else { return false }
return view === surfaceView || view.isDescendant(of: surfaceView)
}()
guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }
#if DEBUG
dlog(
"focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"reason=\(reason) firstResponder=\(String(describing: firstResponder))"
)
#endif
window.makeFirstResponder(nil)
}
/// Check if a responder is inside a search overlay hosting view.
/// Handles the AppKit field-editor case: when an NSTextField is being edited,
/// window.firstResponder is the shared NSTextView field editor, not the text field.
@ -6827,11 +7008,27 @@ final class GhosttySurfaceScrollView: NSView {
var current: NSView? = view
while let v = current {
if v is NSHostingView<SurfaceSearchOverlay> { return true }
let typeName = String(describing: type(of: v))
if typeName.contains("BrowserSearchOverlay") { return true }
current = v.superview
}
return false
}
private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool {
let resolvedResponder: NSResponder
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
resolvedResponder = editedView
} else {
resolvedResponder = responder
}
guard let view = resolvedResponder as? NSView else { return false }
return view.isDescendant(of: self)
}
#if DEBUG
struct DebugRenderStats {
let drawCount: Int

View file

@ -1684,10 +1684,17 @@ final class BrowserPanel: Panel, ObservableObject {
/// cleared only after BrowserPanelView acknowledges handling it.
@Published private(set) var pendingAddressBarFocusRequestId: UUID?
/// Semantic in-panel focus target used by split switching and transient overlays.
private(set) var preferredFocusIntent: BrowserPanelFocusIntent = .webView
/// Incremented whenever async browser find focus ownership changes.
@Published private(set) var searchFocusRequestGeneration: UInt64 = 0
/// Find-in-page state. Non-nil when the find bar is visible.
@Published var searchState: BrowserSearchState? = nil {
didSet {
if let searchState {
preferredFocusIntent = .findField
NSLog("Find: browser search state created panel=%@", id.uuidString)
searchNeedleCancellable = searchState.$needle
.removeDuplicates()
@ -1707,6 +1714,10 @@ final class BrowserPanel: Panel, ObservableObject {
}
} else if oldValue != nil {
searchNeedleCancellable = nil
if preferredFocusIntent == .findField {
preferredFocusIntent = .webView
}
invalidateSearchFocusRequests(reason: "searchStateCleared")
NSLog("Find: browser search state cleared panel=%@", id.uuidString)
executeFindClear()
}
@ -2298,12 +2309,16 @@ final class BrowserPanel: Panel, ObservableObject {
}
if Self.responderChainContains(window.firstResponder, target: webView) {
noteWebViewFocused()
return
}
window.makeFirstResponder(webView)
if window.makeFirstResponder(webView) {
noteWebViewFocused()
}
}
func unfocus() {
invalidateSearchFocusRequests(reason: "panelUnfocus")
guard let window = webView.window else { return }
if Self.responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
@ -3043,21 +3058,52 @@ extension BrowserPanel {
// MARK: - Find in Page
func startFind() {
if searchState == nil {
preferredFocusIntent = .findField
let created = searchState == nil
if created {
searchState = BrowserSearchState()
}
postBrowserSearchFocusNotification()
let generation = beginSearchFocusRequest(reason: "startFind")
#if DEBUG
let window = webView.window
dlog(
"browser.find.start panel=\(id.uuidString.prefix(5)) " +
"created=\(created ? 1 : 0) render=\(shouldRenderWebView ? 1 : 0) " +
"generation=\(generation) " +
"window=\(window?.windowNumber ?? -1) key=\(NSApp.keyWindow === window ? 1 : 0) " +
"firstResponder=\(String(describing: window?.firstResponder))"
)
#endif
postBrowserSearchFocusNotification(reason: "immediate", generation: generation)
// Focus notification can race with portal overlay mount. Re-post on the
// next runloop and shortly after so the find field can claim first responder.
DispatchQueue.main.async { [weak self] in
self?.postBrowserSearchFocusNotification()
self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.postBrowserSearchFocusNotification()
self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation)
}
}
private func postBrowserSearchFocusNotification() {
private func postBrowserSearchFocusNotification(reason: String, generation: UInt64) {
guard canApplySearchFocusRequest(generation) else {
#if DEBUG
dlog(
"browser.find.focusNotification.skip panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) generation=\(generation)"
)
#endif
return
}
#if DEBUG
let window = webView.window
dlog(
"browser.find.focusNotification panel=\(id.uuidString.prefix(5)) " +
"generation=\(generation) " +
"reason=\(reason) window=\(window?.windowNumber ?? -1) " +
"firstResponder=\(String(describing: window?.firstResponder))"
)
#endif
NotificationCenter.default.post(name: .browserSearchFocus, object: id)
}
@ -3078,6 +3124,7 @@ extension BrowserPanel {
}
func hideFind() {
invalidateSearchFocusRequests(reason: "hideFind")
searchState = nil
}
@ -3088,7 +3135,10 @@ extension BrowserPanel {
if replaySearch, !state.needle.isEmpty {
executeFindSearch(state.needle)
}
postBrowserSearchFocusNotification()
postBrowserSearchFocusNotification(
reason: "restoreAfterNavigation",
generation: searchFocusRequestGeneration
)
}
private func executeFindSearch(_ needle: String) {
@ -3215,6 +3265,8 @@ extension BrowserPanel {
@discardableResult
func requestAddressBarFocus() -> UUID {
preferredFocusIntent = .addressBar
invalidateSearchFocusRequests(reason: "requestAddressBarFocus")
beginSuppressWebViewFocusForAddressBar()
if let pendingAddressBarFocusRequestId {
#if DEBUG
@ -3236,6 +3288,173 @@ extension BrowserPanel {
return requestId
}
func noteWebViewFocused() {
guard searchState == nil else { return }
guard preferredFocusIntent != .webView else { return }
preferredFocusIntent = .webView
invalidateSearchFocusRequests(reason: "webViewFocused")
}
func noteAddressBarFocused() {
guard preferredFocusIntent != .addressBar else { return }
preferredFocusIntent = .addressBar
invalidateSearchFocusRequests(reason: "addressBarFocused")
}
func noteFindFieldFocused() {
guard preferredFocusIntent != .findField else { return }
preferredFocusIntent = .findField
}
func canApplySearchFocusRequest(_ generation: UInt64) -> Bool {
generation != 0 &&
generation == searchFocusRequestGeneration &&
searchState != nil &&
preferredFocusIntent == .findField
}
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
if pendingAddressBarFocusRequestId != nil || AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id {
return .browser(.addressBar)
}
if searchState != nil && preferredFocusIntent == .findField {
return .browser(.findField)
}
if let window,
Self.responderChainContains(window.firstResponder, target: webView) {
return .browser(.webView)
}
return .browser(preferredFocusIntent)
}
func preferredFocusIntentForActivation() -> PanelFocusIntent {
if pendingAddressBarFocusRequestId != nil {
return .browser(.addressBar)
}
if searchState != nil && preferredFocusIntent == .findField {
return .browser(.findField)
}
return .browser(preferredFocusIntent)
}
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
guard case .browser(let target) = intent else { return }
switch target {
case .webView:
preferredFocusIntent = .webView
invalidateSearchFocusRequests(reason: "prepareWebView")
endSuppressWebViewFocusForAddressBar()
case .addressBar:
preferredFocusIntent = .addressBar
invalidateSearchFocusRequests(reason: "prepareAddressBar")
beginSuppressWebViewFocusForAddressBar()
case .findField:
preferredFocusIntent = .findField
}
#if DEBUG
dlog(
"browser.focus.prepare panel=\(id.uuidString.prefix(5)) " +
"target=\(String(describing: target)) suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0)"
)
#endif
}
@discardableResult
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
guard case .browser(let target) = intent else { return false }
switch target {
case .webView:
noteWebViewFocused()
focus()
return true
case .addressBar:
let requestId = requestAddressBarFocus()
NotificationCenter.default.post(name: .browserFocusAddressBar, object: id)
#if DEBUG
dlog(
"browser.focus.restore panel=\(id.uuidString.prefix(5)) " +
"target=addressBar request=\(requestId.uuidString.prefix(8))"
)
#endif
return true
case .findField:
startFind()
return true
}
}
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
if AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id {
return .browser(.addressBar)
}
if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) == id {
return .browser(.findField)
}
if Self.responderChainContains(responder, target: webView) {
return .browser(.webView)
}
return nil
}
@discardableResult
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
guard case .browser(let target) = intent else { return false }
switch target {
case .findField:
invalidateSearchFocusRequests(reason: "yieldFindField")
let yielded = BrowserWindowPortalRegistry.yieldSearchOverlayFocusIfOwned(by: id, in: window)
#if DEBUG
if yielded {
dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind")
}
#endif
return yielded
case .addressBar:
guard AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id else { return false }
let yielded = window.makeFirstResponder(nil)
#if DEBUG
if yielded {
dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=addressBar")
}
#endif
return yielded
case .webView:
guard Self.responderChainContains(window.firstResponder, target: webView) else { return false }
return window.makeFirstResponder(nil)
}
}
@discardableResult
private func beginSearchFocusRequest(reason: String) -> UInt64 {
searchFocusRequestGeneration &+= 1
#if DEBUG
dlog(
"browser.find.focusLease.begin panel=\(id.uuidString.prefix(5)) " +
"generation=\(searchFocusRequestGeneration) reason=\(reason)"
)
#endif
return searchFocusRequestGeneration
}
private func invalidateSearchFocusRequests(reason: String) {
searchFocusRequestGeneration &+= 1
#if DEBUG
dlog(
"browser.find.focusLease.invalidate panel=\(id.uuidString.prefix(5)) " +
"generation=\(searchFocusRequestGeneration) reason=\(reason)"
)
#endif
}
func acknowledgeAddressBarFocusRequest(_ requestId: UUID) {
guard pendingAddressBarFocusRequestId == requestId else {
#if DEBUG

View file

@ -338,9 +338,14 @@ struct BrowserPanelView: View {
BrowserSearchOverlay(
panelId: panel.id,
searchState: searchState,
focusRequestGeneration: panel.searchFocusRequestGeneration,
canApplyFocusRequest: { generation in
panel.canApplySearchFocusRequest(generation)
},
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
onClose: { panel.hideFind() }
onClose: { panel.hideFind() },
onFieldDidFocus: { panel.noteFindFieldFocused() }
)
}
}
@ -816,9 +821,14 @@ struct BrowserPanelView: View {
BrowserPortalSearchOverlayConfiguration(
panelId: panel.id,
searchState: searchState,
focusRequestGeneration: panel.searchFocusRequestGeneration,
canApplyFocusRequest: { generation in
panel.canApplySearchFocusRequest(generation)
},
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
onClose: { panel.hideFind() }
onClose: { panel.hideFind() },
onFieldDidFocus: { panel.noteFindFieldFocused() }
)
},
paneTopChromeHeight: addressBarHeight
@ -911,6 +921,9 @@ struct BrowserPanelView: View {
}
#endif
addressBarFocused = focused
if focused {
panel.noteAddressBarFocused()
}
}
private func browserFocusResponderChainContains(
@ -1514,6 +1527,9 @@ struct BrowserPanelView: View {
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)) " +
@ -1531,10 +1547,11 @@ struct BrowserPanelView: View {
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
return
}
let hasWebViewResponder =
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)) " +
@ -1543,6 +1560,9 @@ struct BrowserPanelView: View {
)
#endif
}
if hasWebViewResponder {
panel.noteWebViewFocused()
}
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
}
}
@ -4552,6 +4572,9 @@ struct WebViewRepresentable: NSViewRepresentable {
#endif
return
}
if isPanelFocused && responderChainContains(window.firstResponder, target: webView) {
panel.noteWebViewFocused()
}
if shouldFocusWebView {
if panel.shouldSuppressWebViewFocus() {
#if DEBUG
@ -4572,6 +4595,9 @@ struct WebViewRepresentable: NSViewRepresentable {
return
}
let result = window.makeFirstResponder(webView)
if result {
panel.noteWebViewFocused()
}
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +

View file

@ -1,5 +1,6 @@
import Foundation
import Combine
import AppKit
/// Type of panel content
public enum PanelType: String, Codable, Sendable {
@ -8,6 +9,23 @@ public enum PanelType: String, Codable, Sendable {
case markdown
}
public enum TerminalPanelFocusIntent: Equatable {
case surface
case findField
}
public enum BrowserPanelFocusIntent: Equatable {
case webView
case addressBar
case findField
}
public enum PanelFocusIntent: Equatable {
case panel
case terminal(TerminalPanelFocusIntent)
case browser(BrowserPanelFocusIntent)
}
enum FocusFlashCurve: Equatable {
case easeIn
case easeOut
@ -72,10 +90,63 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
/// Trigger a focus flash animation for this panel.
func triggerFlash()
/// Capture the panel-local focus target that should be restored later.
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent
/// Return the best focus target to restore when this panel becomes active again.
func preferredFocusIntentForActivation() -> PanelFocusIntent
/// Prime panel-local focus state before activation side effects run.
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent)
/// Restore a previously captured focus target.
@discardableResult
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool
/// Return the semantic focus target currently owned by this panel, if any.
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent?
/// Explicitly yield a previously owned focus target before another panel restores focus.
@discardableResult
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool
}
/// Extension providing default implementations
extension Panel {
public var displayIcon: String? { nil }
public var isDirty: Bool { false }
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
_ = window
return preferredFocusIntentForActivation()
}
func preferredFocusIntentForActivation() -> PanelFocusIntent {
.panel
}
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
_ = intent
}
@discardableResult
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
guard intent == .panel else { return false }
focus()
return true
}
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
_ = responder
_ = window
return nil
}
@discardableResult
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
_ = intent
_ = window
return false
}
}

View file

@ -197,4 +197,42 @@ final class TerminalPanel: Panel, ObservableObject {
func applyWindowBackgroundIfActive() {
surface.applyWindowBackgroundIfActive()
}
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
.terminal(hostedView.capturePanelFocusIntent(in: window))
}
func preferredFocusIntentForActivation() -> PanelFocusIntent {
.terminal(hostedView.preferredPanelFocusIntentForActivation())
}
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
guard case .terminal(let target) = intent else { return }
hostedView.preparePanelFocusIntentForActivation(target)
}
@discardableResult
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
switch intent {
case .panel:
focus()
return true
case .terminal(let target):
return hostedView.restorePanelFocusIntent(target)
default:
return false
}
}
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
_ = window
guard let intent = hostedView.ownedPanelFocusIntent(for: responder) else { return nil }
return .terminal(intent)
}
@discardableResult
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
guard case .terminal(let target) = intent else { return false }
return hostedView.yieldPanelFocusIntent(target, in: window)
}
}

View file

@ -1230,7 +1230,14 @@ final class Workspace: Identifiable, ObservableObject {
private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:]
private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:]
private var isApplyingTabSelection = false
private var pendingTabSelection: (tabId: TabID, pane: PaneID)?
private struct PendingTabSelectionRequest {
let tabId: TabID
let pane: PaneID
let reassertAppKitFocus: Bool
let focusIntent: PanelFocusIntent?
let previousTerminalHostedView: GhosttySurfaceScrollView?
}
private var pendingTabSelection: PendingTabSelectionRequest?
private var isReconcilingFocusState = false
private var focusReconcileScheduled = false
#if DEBUG
@ -3061,6 +3068,19 @@ final class Workspace: Identifiable, ObservableObject {
}()
let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged
#if DEBUG
let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
let selectedTabShort = bonsplitController.focusedPaneId
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"focus.panel.begin workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " +
"targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " +
"converged=\(selectionAlreadyConverged ? 1 : 0) " +
"currentPanel=\(currentPanelShort)"
)
if shouldSuppressReentrantRefocus {
dlog(
"focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " +
@ -3070,46 +3090,36 @@ final class Workspace: Identifiable, ObservableObject {
#endif
if let targetPaneId, !selectionAlreadyConverged {
#if DEBUG
dlog(
"focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))"
)
#endif
bonsplitController.focusPane(targetPaneId)
}
if !selectionAlreadyConverged {
#if DEBUG
dlog(
"focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))"
)
#endif
bonsplitController.selectTab(tabId)
}
// Also focus the underlying panel
if let panel = panels[panelId] {
if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus {
panel.focus()
}
if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel {
// Avoid re-entrant focus loops when focus was initiated by AppKit first-responder
// (becomeFirstResponder -> onFocus -> focusPanel).
if !terminalPanel.hostedView.isSurfaceViewFirstResponder() {
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
}
}
}
if let targetPaneId {
let activationIntent = panels[panelId]?.preferredFocusIntentForActivation()
applyTabSelection(
tabId: tabId,
inPane: targetPaneId,
reassertAppKitFocus: !shouldSuppressReentrantRefocus
reassertAppKitFocus: !shouldSuppressReentrantRefocus,
focusIntent: activationIntent,
previousTerminalHostedView: previousTerminalHostedView
)
}
if let browserPanel = panels[panelId] as? BrowserPanel {
// Keep browser find focus behavior aligned with terminal find behavior.
// When switching back to a pane with an already-open find bar, reassert
// focus to that field instead of leaving first responder stale.
if browserPanel.searchState != nil {
browserPanel.startFind()
} else {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
}
}
if trigger == .terminalFirstResponder,
panels[panelId] is TerminalPanel {
scheduleTerminalFirstResponderReassert(panelId: panelId)
@ -4044,9 +4054,17 @@ extension Workspace: BonsplitDelegate {
private func applyTabSelection(
tabId: TabID,
inPane pane: PaneID,
reassertAppKitFocus: Bool = true
reassertAppKitFocus: Bool = true,
focusIntent: PanelFocusIntent? = nil,
previousTerminalHostedView: GhosttySurfaceScrollView? = nil
) {
pendingTabSelection = (tabId: tabId, pane: pane)
pendingTabSelection = PendingTabSelectionRequest(
tabId: tabId,
pane: pane,
reassertAppKitFocus: reassertAppKitFocus,
focusIntent: focusIntent,
previousTerminalHostedView: previousTerminalHostedView
)
guard !isApplyingTabSelection else { return }
isApplyingTabSelection = true
defer {
@ -4062,7 +4080,9 @@ extension Workspace: BonsplitDelegate {
applyTabSelectionNow(
tabId: request.tabId,
inPane: request.pane,
reassertAppKitFocus: reassertAppKitFocus
reassertAppKitFocus: request.reassertAppKitFocus,
focusIntent: request.focusIntent,
previousTerminalHostedView: request.previousTerminalHostedView
)
}
}
@ -4070,9 +4090,23 @@ extension Workspace: BonsplitDelegate {
private func applyTabSelectionNow(
tabId: TabID,
inPane pane: PaneID,
reassertAppKitFocus: Bool
reassertAppKitFocus: Bool,
focusIntent: PanelFocusIntent?,
previousTerminalHostedView: GhosttySurfaceScrollView?
) {
let previousFocusedPanelId = focusedPanelId
#if DEBUG
let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
let selectedTabBefore = bonsplitController.focusedPaneId
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
dlog(
"focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " +
"pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " +
"focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " +
"reassert=\(reassertAppKitFocus ? 1 : 0)"
)
#endif
if bonsplitController.allPaneIds.contains(pane) {
if bonsplitController.focusedPaneId != pane {
bonsplitController.focusPane(pane)
@ -4107,6 +4141,8 @@ extension Workspace: BonsplitDelegate {
if shouldTreatCurrentEventAsExplicitFocusIntent() {
markExplicitFocusIntent(on: panelId)
}
let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation()
panel.prepareFocusIntentForActivation(activationIntent)
syncPinnedStateForTab(selectedTabId, panelId: panelId)
syncUnreadBadgeStateForPanel(panelId)
@ -4116,11 +4152,24 @@ extension Workspace: BonsplitDelegate {
p.unfocus()
}
activatePanel(panel, reassertAppKitFocus: reassertAppKitFocus)
if let focusWindow = activationWindow(for: panel) {
yieldForeignOwnedFocusIfNeeded(
in: focusWindow,
targetPanelId: panelId,
targetIntent: activationIntent
)
}
activatePanel(
panel,
focusIntent: activationIntent,
reassertAppKitFocus: reassertAppKitFocus
)
let focusIntentAllowsBrowserOmnibarAutofocus =
shouldTreatCurrentEventAsExplicitFocusIntent() ||
TerminalController.socketCommandAllowsInAppFocusMutations()
if let browserPanel = panel as? BrowserPanel,
shouldAllowBrowserOmnibarAutofocus(for: activationIntent),
previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard)
}
@ -4149,9 +4198,32 @@ extension Workspace: BonsplitDelegate {
// Converge AppKit first responder with bonsplit's selected tab in the focused pane.
// Without this, keyboard input can remain on a different terminal than the blue tab indicator.
if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel {
if shouldMoveTerminalSurfaceFocus(for: activationIntent),
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
#if DEBUG
let previousExists = previousTerminalHostedView != nil ? 1 : 0
dlog(
"focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " +
"to=\(panelId.uuidString.prefix(5))"
)
#endif
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
}
#if DEBUG
dlog(
"focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " +
"tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))"
)
#endif
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId)
}
if shouldRestoreFocusIntentAfterActivation(activationIntent) {
_ = panel.restoreFocusIntent(activationIntent)
}
// Update current directory if this is a terminal
if let dir = panelDirectories[panelId] {
currentDirectory = dir
@ -4168,28 +4240,108 @@ extension Workspace: BonsplitDelegate {
GhosttyNotificationKey.surfaceId: panelId
]
)
#if DEBUG
let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " +
"focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " +
"prevPanel=\(prevPanelShort)"
)
#endif
}
private func activatePanel(
_ panel: any Panel,
focusIntent: PanelFocusIntent,
reassertAppKitFocus: Bool
) {
guard !reassertAppKitFocus else {
panel.focus()
return
}
// `GhosttyNSView.becomeFirstResponder -> onFocus -> focusPanel` is already inside
// AppKit's responder transition. Re-running `makeFirstResponder` here can recurse,
// but we still need to converge the selected panel's active/focus state and clear any
// stale sibling terminal activation so split-pane clicks recover cleanly.
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.surface.setFocus(true)
let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent)
terminalPanel.surface.setFocus(shouldFocusTerminalSurface)
terminalPanel.hostedView.setActive(true)
if reassertAppKitFocus && shouldFocusTerminalSurface {
terminalPanel.focus()
}
return
}
panel.focus()
if let browserPanel = panel as? BrowserPanel {
guard shouldFocusBrowserWebView(for: focusIntent) else { return }
browserPanel.focus()
return
}
if reassertAppKitFocus {
panel.focus()
}
}
private func activationWindow(for panel: any Panel) -> NSWindow? {
if let terminalPanel = panel as? TerminalPanel {
return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
}
if let browserPanel = panel as? BrowserPanel {
return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
}
return NSApp.keyWindow ?? NSApp.mainWindow
}
private func yieldForeignOwnedFocusIfNeeded(
in window: NSWindow,
targetPanelId: UUID,
targetIntent: PanelFocusIntent
) {
guard let firstResponder = window.firstResponder else { return }
for (panelId, panel) in panels where panelId != targetPanelId {
guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue }
#if DEBUG
dlog(
"focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " +
"fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " +
"fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))"
)
#endif
_ = panel.yieldFocusIntent(ownedIntent, in: window)
return
}
}
private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool {
switch intent {
case .terminal(.findField):
return false
default:
return true
}
}
private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool {
switch intent {
case .browser(.addressBar), .browser(.findField):
return false
default:
return true
}
}
private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool {
switch intent {
case .browser(.webView), .panel:
return true
default:
return false
}
}
private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool {
switch intent {
case .browser(.addressBar), .browser(.findField), .terminal(.findField):
return true
case .panel, .browser(.webView), .terminal(.surface):
return false
}
}
private func beginNonFocusSplitFocusReassert(