cmux/Sources/Find/BrowserSearchOverlay.swift
Lawrence Chen ec10dfdaec
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
2026-03-10 19:31:52 -07:00

251 lines
9.4 KiB
Swift

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
@FocusState private var isSearchFieldFocused: Bool
private let padding: CGFloat = 8
#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) {
guard canApplyFocusRequest(focusRequestGeneration) else {
#if DEBUG
logFocusState("request.skipRetry origin=\(origin) generation=\(focusRequestGeneration)")
#endif
return
}
requestSearchFieldFocus(maxAttempts: maxAttempts - 1, origin: origin)
}
}
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
TextField("Search", text: $searchState.needle)
.textFieldStyle(.plain)
.accessibilityIdentifier("BrowserFindSearchTextField")
.frame(width: 180)
.padding(.leading, 8)
.padding(.trailing, 50)
.padding(.vertical, 6)
.background(Color.primary.opacity(0.1))
.cornerRadius(6)
.focused($isSearchFieldFocused)
.overlay(alignment: .trailing) {
if let selected = searchState.selected {
let totalText = searchState.total.map { String($0) } ?? "?"
Text("\(selected + 1)/\(totalText)")
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
.padding(.trailing, 8)
} else if let total = searchState.total {
Text(total == 0 ? "0/0" : "-/\(total)")
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
.padding(.trailing, 8)
}
}
.onExitCommand {
onClose()
}
.onSubmit {
// onSubmit fires only after IME composition is committed.
if NSEvent.modifierFlags.contains(.shift) {
onPrevious()
} else {
onNext()
}
}
Button(action: {
#if DEBUG
dlog("browser.findbar.next panel=\(panelId.uuidString.prefix(5))")
#endif
onNext()
}) {
Image(systemName: "chevron.up")
}
.buttonStyle(SearchButtonStyle())
.safeHelp("Next match (Return)")
Button(action: {
#if DEBUG
dlog("browser.findbar.prev panel=\(panelId.uuidString.prefix(5))")
#endif
onPrevious()
}) {
Image(systemName: "chevron.down")
}
.buttonStyle(SearchButtonStyle())
.safeHelp("Previous match (Shift+Return)")
Button(action: {
#if DEBUG
dlog("browser.findbar.close panel=\(panelId.uuidString.prefix(5))")
#endif
onClose()
}) {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())
.safeHelp("Close (Esc)")
}
.padding(8)
.background(.background)
.clipShape(clipShape)
.shadow(radius: 4)
.onAppear {
#if DEBUG
dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))")
#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(origin: "notification")
}
}
.background(
GeometryReader { barGeo in
Color.clear.onAppear {
barSize = barGeo.size
}
}
)
.padding(padding)
.offset(dragOffset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment)
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation
}
.onEnded { value in
let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize)
let newCenter = CGPoint(
x: centerPos.x + value.translation.width,
y: centerPos.y + value.translation.height
)
let newCorner = closestCorner(to: newCenter, in: geo.size)
withAnimation(.easeOut(duration: 0.2)) {
corner = newCorner
dragOffset = .zero
}
}
)
}
}
private var clipShape: some Shape {
RoundedRectangle(cornerRadius: 8)
}
enum Corner {
case topLeft
case topRight
case bottomLeft
case bottomRight
var alignment: Alignment {
switch self {
case .topLeft: return .topLeading
case .topRight: return .topTrailing
case .bottomLeft: return .bottomLeading
case .bottomRight: return .bottomTrailing
}
}
}
private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint {
let halfWidth = barSize.width / 2 + padding
let halfHeight = barSize.height / 2 + padding
switch corner {
case .topLeft:
return CGPoint(x: halfWidth, y: halfHeight)
case .topRight:
return CGPoint(x: containerSize.width - halfWidth, y: halfHeight)
case .bottomLeft:
return CGPoint(x: halfWidth, y: containerSize.height - halfHeight)
case .bottomRight:
return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight)
}
}
private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner {
let midX = containerSize.width / 2
let midY = containerSize.height / 2
if point.x < midX {
return point.y < midY ? .topLeft : .bottomLeft
}
return point.y < midY ? .topRight : .bottomRight
}
}