Fix command palette focus after terminal find (#2089)

* test: cover command palette focus guard

* fix: block terminal find from stealing palette focus

* test: cover text view focus-stealer fallback

* Add regression for hidden DevTools sync republish loop

* Avoid redundant DevTools visibility publishes

* test: cover browser find focus after workspace round-trip

* fix: restore browser find focus after workspace round-trip

* fix: keep browser find caret on workspace return

* Add workspace round-trip split find regressions

* Keep inactive find overlays from stealing focus

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-25 17:27:54 -07:00 committed by GitHub
parent b42f64fbe3
commit 8a3ab6b3f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 402 additions and 59 deletions

View file

@ -37,6 +37,38 @@ private enum CmuxThemeNotifications {
static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config")
}
func isCommandPaletteFocusStealingTerminalOrBrowserResponder(_ responder: NSResponder) -> Bool {
if responder is GhosttyNSView || responder is WKWebView {
return true
}
if let textView = responder as? NSTextView,
!textView.isFieldEditor,
let delegateView = textView.delegate as? NSView {
return isCommandPaletteFocusStealingTerminalOrBrowserView(delegateView)
}
if let view = responder as? NSView {
return isCommandPaletteFocusStealingTerminalOrBrowserView(view)
}
return false
}
func isCommandPaletteFocusStealingTerminalOrBrowserView(_ view: NSView) -> Bool {
if view is GhosttyNSView || view is GhosttySurfaceScrollView || view is WKWebView {
return true
}
var current: NSView? = view.superview
while let candidate = current {
if candidate is GhosttyNSView || candidate is GhosttySurfaceScrollView || candidate is WKWebView {
return true
}
current = candidate.superview
}
return false
}
#if DEBUG
enum CmuxTypingTiming {
static let isEnabled: Bool = {
@ -4656,35 +4688,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool {
if responder is GhosttyNSView || responder is WKWebView {
return true
}
if let textView = responder as? NSTextView,
!textView.isFieldEditor,
let delegateView = textView.delegate as? NSView {
return isTerminalOrBrowserView(delegateView)
}
if let view = responder as? NSView {
return isTerminalOrBrowserView(view)
}
return false
}
private func isTerminalOrBrowserView(_ view: NSView) -> Bool {
if view is GhosttyNSView || view is WKWebView {
return true
}
var current: NSView? = view.superview
while let candidate = current {
if candidate is GhosttyNSView || candidate is WKWebView {
return true
}
current = candidate.superview
}
return false
isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder)
}
private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool {

View file

@ -222,6 +222,16 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable {
}
}
func focusField(_ field: BrowserSearchNativeTextField, in window: NSWindow) {
guard window.makeFirstResponder(field) else { return }
DispatchQueue.main.async { [weak field] in
guard let field,
let editor = field.currentEditor() as? NSTextView else { return }
let end = field.stringValue.utf16.count
editor.setSelectedRange(NSRange(location: end, length: 0))
}
}
func controlTextDidChange(_ obj: Notification) {
guard !isProgrammaticMutation else { return }
guard let field = obj.object as? NSTextField else { return }
@ -294,7 +304,7 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable {
field.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === field
guard !alreadyFocused else { return }
window.makeFirstResponder(field)
coordinator.focusField(field, in: window)
}
return field
}
@ -337,7 +347,7 @@ private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable {
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
guard !alreadyFocused else { return }
window.makeFirstResponder(nsView)
coordinator.focusField(nsView, in: window)
}
}
}

View file

@ -19,6 +19,7 @@ struct SurfaceSearchOverlay: View {
let tabId: UUID
let surfaceId: UUID
@ObservedObject var searchState: TerminalSurface.SearchState
let canApplyFocusRequest: () -> Bool
let onMoveFocusToTerminal: () -> Void
let onNavigateSearch: (_ action: String) -> Void
let onFieldDidFocus: () -> Void
@ -37,6 +38,7 @@ struct SurfaceSearchOverlay: View {
text: $searchState.needle,
isFocused: $isSearchFieldFocused,
surfaceId: surfaceId,
canApplyFocusRequest: canApplyFocusRequest,
onFieldDidFocus: onFieldDidFocus,
onEscape: {
#if DEBUG
@ -227,6 +229,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
let surfaceId: UUID
let canApplyFocusRequest: () -> Bool
let onFieldDidFocus: () -> Void
let onEscape: () -> Void
let onReturn: (_ isShift: Bool) -> Void
@ -319,6 +322,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable {
guard let field, let coordinator else { return }
guard let surface = notification.object as? TerminalSurface,
surface.id == coordinator.parent.surfaceId else { return }
guard coordinator.parent.canApplyFocusRequest() else { return }
guard let window = field.window else { return }
// Don't re-focus if already first responder. makeFirstResponder on an
// already-editing NSTextField ends the editing session and restarts it
@ -370,11 +374,16 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable {
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
if isFocused,
canApplyFocusRequest(),
!isFirstResponder,
context.coordinator.pendingFocusRequest != true {
context.coordinator.pendingFocusRequest = true
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let coordinator, coordinator.parent.isFocused else { return }
guard let coordinator,
coordinator.parent.isFocused,
coordinator.parent.canApplyFocusRequest() else { return }
guard let nsView, let window = nsView.window else { return }
let fr = window.firstResponder
let alreadyFocused = fr === nsView ||

View file

@ -7868,6 +7868,9 @@ final class GhosttySurfaceScrollView: NSView {
tabId: terminalSurface.tabId,
surfaceId: terminalSurface.id,
searchState: searchState,
canApplyFocusRequest: { [weak self] in
self?.canApplyMountedSearchFieldFocusRequest() ?? false
},
onMoveFocusToTerminal: { [weak self] in
self?.searchFocusTarget = .terminal
self?.moveFocus()
@ -7899,6 +7902,17 @@ final class GhosttySurfaceScrollView: NSView {
return nil
}
private func canApplyMountedSearchFieldFocusRequest() -> Bool {
guard let terminalSurface = surfaceView.terminalSurface,
let app = AppDelegate.shared,
let manager = app.tabManagerFor(tabId: terminalSurface.tabId),
manager.selectedTabId == terminalSurface.tabId,
let workspace = manager.tabs.first(where: { $0.id == terminalSurface.tabId }) else {
return false
}
return workspace.focusedPanelId == terminalSurface.id
}
private func requestMountedSearchFieldFocus(
generation: UInt64,
force: Bool,
@ -7906,6 +7920,7 @@ final class GhosttySurfaceScrollView: NSView {
) {
guard searchOverlayMutationGeneration == generation else { return }
guard force || searchFocusTarget == .searchField else { return }
guard canApplyMountedSearchFieldFocusRequest() else { return }
guard let overlay = searchOverlayHostingView,
overlay.superview === self,
let window,

View file

@ -3939,7 +3939,7 @@ extension BrowserPanel {
_ = hideDeveloperTools()
cancelDeveloperToolsRestoreRetry()
preferredDeveloperToolsVisible = false
setPreferredDeveloperToolsVisible(false)
preferredDeveloperToolsPresentation = .unknown
forceDeveloperToolsRefreshOnNextAttach = false
developerToolsDetachedOpenGraceDeadline = nil
@ -4219,6 +4219,11 @@ extension BrowserPanel {
}
}
private func setPreferredDeveloperToolsVisible(_ next: Bool) {
guard preferredDeveloperToolsVisible != next else { return }
preferredDeveloperToolsVisible = next
}
private func syncDeveloperToolsPresentationPreferenceFromUI() {
if !detachedDeveloperToolsWindows().isEmpty {
setPreferredDeveloperToolsPresentation(.detached)
@ -4247,7 +4252,7 @@ extension BrowserPanel {
guard self.preferredDeveloperToolsVisible else { return }
guard !self.isDeveloperToolsVisible() else { return }
self.developerToolsDetachedOpenGraceDeadline = nil
self.preferredDeveloperToolsVisible = false
self.setPreferredDeveloperToolsVisible(false)
self.cancelDeveloperToolsRestoreRetry()
#if DEBUG
dlog(
@ -4383,7 +4388,7 @@ extension BrowserPanel {
) -> Bool {
if isDeveloperToolsTransitionInFlight {
pendingDeveloperToolsTransitionTargetVisible = targetVisible
preferredDeveloperToolsVisible = targetVisible
setPreferredDeveloperToolsVisible(targetVisible)
if !targetVisible {
developerToolsDetachedOpenGraceDeadline = nil
forceDeveloperToolsRefreshOnNextAttach = false
@ -4410,7 +4415,7 @@ extension BrowserPanel {
let isVisibleSelector = NSSelectorFromString("isVisible")
let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
preferredDeveloperToolsVisible = targetVisible
setPreferredDeveloperToolsVisible(targetVisible)
developerToolsTransitionTargetVisible = targetVisible
if targetVisible {
@ -4512,7 +4517,7 @@ extension BrowserPanel {
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
if isDeveloperToolsTransitionInFlight {
let targetVisible = pendingDeveloperToolsTransitionTargetVisible ?? developerToolsTransitionTargetVisible ?? visible
preferredDeveloperToolsVisible = targetVisible
setPreferredDeveloperToolsVisible(targetVisible)
if targetVisible, visible {
developerToolsDetachedOpenGraceDeadline = nil
syncDeveloperToolsPresentationPreferenceFromUI()
@ -4527,7 +4532,7 @@ extension BrowserPanel {
if visible {
developerToolsDetachedOpenGraceDeadline = nil
syncDeveloperToolsPresentationPreferenceFromUI()
preferredDeveloperToolsVisible = true
setPreferredDeveloperToolsVisible(true)
developerToolsLastKnownVisibleAt = Date()
cancelDeveloperToolsRestoreRetry()
return
@ -4535,7 +4540,7 @@ extension BrowserPanel {
if preserveVisibleIntent && preferredDeveloperToolsVisible {
return
}
preferredDeveloperToolsVisible = false
setPreferredDeveloperToolsVisible(false)
developerToolsLastKnownVisibleAt = nil
cancelDeveloperToolsRestoreRetry()
}
@ -4590,7 +4595,7 @@ extension BrowserPanel {
return false
}
preferredDeveloperToolsVisible = false
setPreferredDeveloperToolsVisible(false)
developerToolsDetachedOpenGraceDeadline = nil
developerToolsLastKnownVisibleAt = nil
forceDeveloperToolsRefreshOnNextAttach = false
@ -4636,7 +4641,7 @@ extension BrowserPanel {
let detachedOpenStillSettling = developerToolsDetachedOpenGraceDeadline.map { $0 > Date() } ?? false
if preferredDeveloperToolsPresentation == .detached && !detachedOpenStillSettling {
preferredDeveloperToolsVisible = false
setPreferredDeveloperToolsVisible(false)
developerToolsDetachedOpenGraceDeadline = nil
cancelDeveloperToolsRestoreRetry()
#if DEBUG
@ -4663,7 +4668,7 @@ extension BrowserPanel {
cmuxWithWindowFirstResponderBypass {
_ = revealDeveloperTools(inspector)
}
preferredDeveloperToolsVisible = true
setPreferredDeveloperToolsVisible(true)
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visibleAfterShow {
syncDeveloperToolsPresentationPreferenceFromUI()

View file

@ -460,7 +460,7 @@ struct BrowserPanelView: View {
searchState: searchState,
focusRequestGeneration: panel.searchFocusRequestGeneration,
canApplyFocusRequest: { generation in
panel.canApplySearchFocusRequest(generation)
canApplyBrowserFindFieldFocusRequest(generation)
},
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
@ -1133,7 +1133,7 @@ struct BrowserPanelView: View {
searchState: searchState,
focusRequestGeneration: panel.searchFocusRequestGeneration,
canApplyFocusRequest: { generation in
panel.canApplySearchFocusRequest(generation)
canApplyBrowserFindFieldFocusRequest(generation)
},
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
@ -1299,6 +1299,10 @@ struct BrowserPanelView: View {
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

View file

@ -3079,17 +3079,17 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else { return }
// Try to restore previous focus
let panelId: UUID
if let restoredPanelId = lastFocusedPanelByTab[selectedTabId],
tab.panels[restoredPanelId] != nil,
tab.focusedPanelId != restoredPanelId {
tab.focusPanel(restoredPanelId)
tab.panels[restoredPanelId] != nil {
panelId = restoredPanelId
} else if let focusedPanelId = tab.focusedPanelId,
tab.panels[focusedPanelId] != nil {
panelId = focusedPanelId
} else {
return
}
// Focus the panel
guard let panelId = tab.focusedPanelId,
let panel = tab.panels[panelId] else { return }
// Defer unfocusing the previous workspace's panel until ContentView confirms handoff
// completion (new workspace has focus or timeout fallback), to avoid a visible freeze gap.
if let previousTabId,
@ -3101,12 +3101,9 @@ class TabManager: ObservableObject {
)
}
panel.focus()
// For terminal panels, ensure proper focus handling
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.hostedView.ensureFocus(for: selectedTabId, surfaceId: panelId)
}
// Route workspace reactivation through the normal focus machinery so panel-local
// activation intents like browser find-field focus are restored on return.
tab.focusPanel(panelId)
}
func completePendingWorkspaceUnfocus(reason: String) {