Fix terminal keys swallowed after opening browser (#45)

* Fix terminal keys (arrows, Ctrl+N/P) swallowed after opening browser

After a browser panel is shown, SwiftUI's internal focus system activates
and its _NSHostingView starts consuming arrow keys and other non-Command
key events via performKeyEquivalent, preventing them from reaching the
terminal's keyDown handler.

Fix: In the NSWindow performKeyEquivalent swizzle, when GhosttyNSView is
the first responder and the event has no Command modifier, route directly
to the terminal's performKeyEquivalent — bypassing SwiftUI's view hierarchy
walk entirely.

Also clear stale browserAddressBarFocusedPanelId when a terminal surface
has focus, preventing Cmd+N from being eaten by omnibar selection logic
after focus transitions away from a browser.

Adds DEBUG-only keyboard event ring buffer (KeyDebugLog) that dumps to
/tmp/cmux-key-debug.log for diagnosing future key routing issues.

* Fix split focus and Cmd+Shift+N swallowed after opening browser

Split focus: capture the source terminal's hostedView before bonsplit
mutates focusedPaneId, so focusPanel moves focus FROM the old pane
instead of from the new pane to itself. Also retry ensureFocus when the
new terminal's view has no window yet (matching the existing retry
pattern for isVisibleInUI).

Cmd+Shift+N: after WKWebView has been in the responder chain, SwiftUI's
internal focus system can intercept Command-key events in the content
view hierarchy (returning true) without firing the CommandGroup action
closure. Fix by dispatching Command-key events directly to NSApp.mainMenu
when the terminal is first responder, bypassing the broken SwiftUI path.
Also add Cmd+Shift+N to handleCustomShortcut so it's customizable and
doesn't depend on SwiftUI menu dispatch at all.

* Unified debug event log: merge key/mouse/focus into /tmp/cmux-debug.log

- Delete KeyDebugLog, MouseDebugLog, klog(), mlog() from AppDelegate
- Replace all klog/mlog calls with dlog() (provided by bonsplit)
- Remove debugLogCallback wiring from Workspace
- Add focus change logging: focus.panel, focus.firstResponder,
  split.created, focus.moveFocus
- Add import Bonsplit where needed for dlog access
- Fix stale drag state on cancelled tab drags (bonsplit submodule)

* Fix split focus stolen by re-entrant becomeFirstResponder during reparenting

During programmatic splits (Cmd+D / Cmd+Shift+D), SwiftUI reparents the old
terminal view, which fires becomeFirstResponder → onFocus → focusPanel for the
OLD panel, stealing focus from the newly created pane.

Add programmaticFocusTargetPanelId guard to suppress re-entrant focusPanel
calls for non-target panels during split creation.

Also document the unified debug event log in CLAUDE.md.

* Clear stale title/favicon when browser navigation fails

When a page fails to load (e.g. connection refused), the tab was still
showing the previous page's title and favicon. Now didFailProvisionalNavigation
resets pageTitle to the failed URL and clears faviconPNGData.

* Fix Cmd+N swallowed by browser omnibar and improve split focus suppression

- Only Ctrl+N/P trigger omnibar navigation, not Cmd+N/P (Cmd+N should
  always create new workspace regardless of address bar focus)
- Move split focus suppression from workspace-level guard to source:
  suppress becomeFirstResponder side-effects (onFocus + ghostty_surface_set_focus)
  directly on the old GhosttyNSView during reparenting, preventing both
  model-level and libghostty-level focus divergence
- Remove programmaticFocusTargetPanelId from Workspace.focusPanel

* Fix omnibar hang, WebView white flash, drag-over-browser, and idle CPU spin

- Omnibar: first click selects all without entering NSTextView tracking loop;
  subsequent clicks have 3s synthetic mouseUp safety net to prevent hang
- WebView: set underPageBackgroundColor to match window so new browsers don't
  flash white before content loads
- Drag/drop: register custom UTType (com.splittabbar.tabtransfer) in Info.plist
  so WKWebView doesn't intercept tab drags; override registerForDraggedTypes
  on CmuxWebView as belt-and-suspenders
- CPU: fix infinite makeFirstResponder loop in controlTextDidEndEditing by
  checking both the text field and its field editor (the actual first responder)
This commit is contained in:
Lawrence Chen 2026-02-17 03:21:08 -08:00 committed by GitHub
parent 0a2ef499b5
commit 484b66c8ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 364 additions and 23 deletions

View file

@ -68,6 +68,23 @@ This creates an isolated app with its own name, bundle ID, socket, and derived d
Before launching a new tagged run, clean up any older tags you started in this session (quit old tagged app + remove its `/tmp` socket/derived data).
## Debug event log
All debug events (keys, mouse, focus, splits, tabs) go to a single unified log in DEBUG builds:
```bash
tail -f /tmp/cmux-debug.log
```
- Implementation: `vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift`
- Free function `dlog("message")` — logs with timestamp and appends to file in real time
- Entire file is `#if DEBUG`; all call sites must be wrapped in `#if DEBUG` / `#endif`
- 500-entry ring buffer; `DebugEventLog.shared.dump()` writes full buffer to file
- Key events logged in `AppDelegate.swift` (monitor, performKeyEquivalent)
- Mouse/UI events logged inline in views (ContentView, BrowserPanelView, etc.)
- Focus events: `focus.panel`, `focus.bonsplit`, `focus.firstResponder`, `focus.moveFocus`
- Bonsplit events: `tab.select`, `tab.close`, `tab.dragStart`, `tab.drop`, `pane.focus`, `pane.drop`, `divider.dragStart`
## Pitfalls
- Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag.

View file

@ -69,6 +69,19 @@
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.splittabbar.tabtransfer</string>
<key>UTTypeDescription</key>
<string>Bonsplit Tab Transfer</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
</dict>
</array>
<key>SUFeedURL</key>
<string>https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>

View file

@ -73,7 +73,7 @@ func browserOmnibarSelectionDeltaForCommandNavigation(
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
guard normalizedFlags == [.command] || normalizedFlags == [.control] else { return nil }
guard normalizedFlags == [.control] else { return nil }
if chars == "n" { return 1 }
if chars == "p" { return -1 }
return nil
@ -1418,9 +1418,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in
guard let self else { return event }
if event.type == .keyDown {
#if DEBUG
let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")")
#endif
if self.handleCustomShortcut(event: event) {
#if DEBUG
dlog(" → consumed by handleCustomShortcut")
DebugEventLog.shared.dump()
#endif
return nil // Consume the event
}
#if DEBUG
DebugEventLog.shared.dump()
#endif
return event // Pass through
}
self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event)
@ -1623,6 +1634,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return false
}
// Guard against stale browserAddressBarFocusedPanelId after focus transitions
// (e.g., split that doesn't properly blur the address bar). If the first responder
// is a terminal surface, the address bar can't be focused.
if browserAddressBarFocusedPanelId != nil,
NSApp.keyWindow?.firstResponder is GhosttyNSView {
#if DEBUG
dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId")
#endif
browserAddressBarFocusedPanelId = nil
stopBrowserOmnibarSelectionRepeat()
}
// Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P.
if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) {
dispatchBrowserOmnibarSelectionMove(delta: delta)
@ -1663,6 +1686,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
// New Window: Cmd+Shift+N
// Handled here instead of relying on SwiftUI's CommandGroup menu item because
// after a browser panel has been shown, SwiftUI's menu dispatch can silently
// consume the key equivalent without firing the action closure.
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newWindow)) {
openNewMainWindow(nil)
return true
}
// Check Show Notifications shortcut
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showNotifications)) {
toggleNotificationsPopover(animated: false)
@ -1863,7 +1895,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
guard normalizedFlags == [.command] || normalizedFlags == [.control] else { return false }
guard normalizedFlags == [.control] else { return false }
return chars == "n" || chars == "p"
}
@ -2289,6 +2321,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar()
self.browserAddressBarFocusedPanelId = panelId
self.stopBrowserOmnibarSelectionRepeat()
#if DEBUG
dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))")
#endif
}
browserAddressBarBlurObserver = NotificationCenter.default.addObserver(
@ -2301,6 +2336,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if self.browserAddressBarFocusedPanelId == panelId {
self.browserAddressBarFocusedPanelId = nil
self.stopBrowserOmnibarSelectionRepeat()
#if DEBUG
dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))")
#endif
}
}
}
@ -3247,11 +3285,75 @@ enum MenuBarIconRenderer {
}
}
private extension NSWindow {
@objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool {
#if DEBUG
let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)")
#endif
// When the terminal surface is the first responder, prevent SwiftUI's
// hosting view from consuming key events via performKeyEquivalent.
// After a browser panel (WKWebView) has been in the responder chain,
// SwiftUI's internal focus system can get into a broken state where it
// intercepts key events in the content view hierarchy, returns true
// (claiming consumption), but never actually fires the action closure.
//
// For non-Command keys: bypass the view hierarchy entirely and send
// directly to the terminal so arrow keys, Ctrl+N/P, etc. reach keyDown.
//
// For Command keys: bypass the SwiftUI content view hierarchy and
// dispatch directly to the main menu. No SwiftUI view should be handling
// Command shortcuts when the terminal is focused the local event monitor
// (handleCustomShortcut) already handles app-level shortcuts, and anything
// remaining should be menu items.
if let ghosttyView = self.firstResponder as? GhosttyNSView {
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if !flags.contains(.command) {
let result = ghosttyView.performKeyEquivalent(with: event)
#if DEBUG
dlog(" → ghostty direct: \(result)")
#endif
return result
}
}
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
#if DEBUG
dlog(" → consumed by handleBrowserSurfaceKeyEquivalent")
#endif
return true
}
return cmux_performKeyEquivalent(with: event)
// When the terminal is focused, skip the full NSWindow.performKeyEquivalent
// (which walks the SwiftUI content view hierarchy) and dispatch Command-key
// events directly to the main menu. This avoids the broken SwiftUI focus path.
if self.firstResponder is GhosttyNSView,
event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
let mainMenu = NSApp.mainMenu, mainMenu.performKeyEquivalent(with: event) {
#if DEBUG
dlog(" → consumed by mainMenu (bypassed SwiftUI)")
#endif
return true
}
let result = cmux_performKeyEquivalent(with: event)
#if DEBUG
if result { dlog(" → consumed by original performKeyEquivalent") }
#endif
return result
}
static func keyDescription(_ event: NSEvent) -> String {
var parts: [String] = []
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.command) { parts.append("Cmd") }
if flags.contains(.shift) { parts.append("Shift") }
if flags.contains(.option) { parts.append("Opt") }
if flags.contains(.control) { parts.append("Ctrl") }
let chars = event.charactersIgnoringModifiers ?? "?"
parts.append("'\(chars)'(\(event.keyCode))")
return parts.joined(separator: "+")
}
}

View file

@ -1,4 +1,5 @@
import AppKit
import Bonsplit
import SwiftUI
import ObjectiveC
import UniformTypeIdentifiers
@ -216,6 +217,9 @@ struct ContentView: View {
.onChanged { value in
if !isResizerDragging {
isResizerDragging = true
#if DEBUG
dlog("sidebar.resizeDragStart")
#endif
if !isResizerHovering {
NSCursor.resizeLeftRight.push()
isResizerHovering = true
@ -973,7 +977,12 @@ private struct TabItemView: View {
Spacer()
ZStack(alignment: .trailing) {
Button(action: { tabManager.closeWorkspaceWithConfirmation(tab) }) {
Button(action: {
#if DEBUG
dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button")
#endif
tabManager.closeWorkspaceWithConfirmation(tab)
}) {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .medium))
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary)
@ -1105,6 +1114,9 @@ private struct TabItemView: View {
.opacity(isBeingDragged ? 0.6 : 1)
.overlay {
MiddleClickCapture {
#if DEBUG
dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick")
#endif
tabManager.closeWorkspaceWithConfirmation(tab)
}
}
@ -1268,6 +1280,15 @@ private struct TabItemView: View {
}
private func updateSelection() {
#if DEBUG
let mods = NSEvent.modifierFlags
var modStr = ""
if mods.contains(.command) { modStr += "cmd " }
if mods.contains(.shift) { modStr += "shift " }
if mods.contains(.option) { modStr += "opt " }
if mods.contains(.control) { modStr += "ctrl " }
dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))")
#endif
let modifiers = NSEvent.modifierFlags
let isCommand = modifiers.contains(.command)
let isShift = modifiers.contains(.shift)
@ -1847,6 +1868,9 @@ private struct SidebarTabDropDelegate: DropDelegate {
dropIndicator = nil
dragAutoScrollController.stop()
}
#if DEBUG
dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")")
#endif
guard let draggedTabId else { return false }
guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { return false }
let tabIds = tabManager.tabs.map(\.id)
@ -2057,6 +2081,9 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
}
override func mouseDown(with event: NSEvent) {
#if DEBUG
dlog("folder.dragStart dir=\(directory)")
#endif
let fileURL = URL(fileURLWithPath: directory)
let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL)

View file

@ -1,3 +1,4 @@
import Bonsplit
import SwiftUI
struct SurfaceSearchOverlay: View {
@ -55,6 +56,9 @@ struct SurfaceSearchOverlay: View {
}
Button(action: {
#if DEBUG
dlog("findbar.next surface=\(surface.id.uuidString.prefix(5))")
#endif
_ = surface.performBindingAction("navigate_search:next")
}) {
Image(systemName: "chevron.up")
@ -63,6 +67,9 @@ struct SurfaceSearchOverlay: View {
.help("Next match (Return)")
Button(action: {
#if DEBUG
dlog("findbar.prev surface=\(surface.id.uuidString.prefix(5))")
#endif
_ = surface.performBindingAction("navigate_search:previous")
}) {
Image(systemName: "chevron.down")
@ -70,7 +77,12 @@ struct SurfaceSearchOverlay: View {
.buttonStyle(SearchButtonStyle())
.help("Previous match (Shift+Return)")
Button(action: onClose) {
Button(action: {
#if DEBUG
dlog("findbar.close surface=\(surface.id.uuidString.prefix(5))")
#endif
onClose()
}) {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())

View file

@ -1496,6 +1496,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
var scrollbar: GhosttyScrollbar?
var cellSize: CGSize = .zero
var desiredFocus: Bool = false
var suppressingReparentFocus: Bool = false
var tabId: UUID?
var onFocus: (() -> Void)?
var onTriggerFlash: (() -> Void)?
@ -1782,6 +1783,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// If we become first responder before the ghostty surface exists (e.g. during
// split/tab creation while the surface is still being created), record the desired focus.
desiredFocus = true
// During programmatic splits, SwiftUI reparents the old NSView which triggers
// becomeFirstResponder. Suppress onFocus + ghostty_surface_set_focus to prevent
// the old view from stealing focus and creating model/surface divergence.
if suppressingReparentFocus {
#if DEBUG
dlog("focus.firstResponder SUPPRESSED (reparent) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
return result
}
// Always notify the host app that this pane became the first responder so bonsplit
// focus/selection can converge. Previously this was gated on `surface != nil`, which
// allowed a mismatch where AppKit focus moved but the UI focus indicator (bonsplit)
@ -1795,6 +1807,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
let deltaMs = (now - lastScrollEventTime) * 1000
Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
#if DEBUG
dlog("focus.firstResponder surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
if let terminalSurface {
AppDelegate.shared?.recordJumpUnreadFocusIfExpected(
tabId: terminalSurface.tabId,
@ -2248,6 +2261,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// MARK: - Mouse Handling
override func mouseDown(with event: NSEvent) {
#if DEBUG
dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
window?.makeFirstResponder(self)
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
@ -2504,7 +2520,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
insertDroppedPasteboard(sender.draggingPasteboard)
#if DEBUG
dlog("terminal.fileDrop surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
return insertDroppedPasteboard(sender.draggingPasteboard)
}
}
@ -2903,6 +2922,9 @@ final class GhosttySurfaceScrollView: NSView {
}
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
#if DEBUG
dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
let work = { [weak self] in
guard let self else { return }
guard let window = self.window else { return }
@ -3030,6 +3052,16 @@ final class GhosttySurfaceScrollView: NSView {
}
}
/// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during
/// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles.
func suppressReparentFocus() {
surfaceView.suppressingReparentFocus = true
}
func clearSuppressReparentFocus() {
surfaceView.suppressingReparentFocus = false
}
/// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder.
/// This is stricter than checking `hostedView` descendants, since the scroll view can sometimes become
/// first responder transiently while focus is being applied.

View file

@ -7,6 +7,7 @@ enum KeyboardShortcutSettings {
// Titlebar / primary UI
case toggleSidebar
case newTab
case newWindow
case showNotifications
case jumpToUnread
case triggerFlash
@ -35,6 +36,7 @@ enum KeyboardShortcutSettings {
switch self {
case .toggleSidebar: return "Toggle Sidebar"
case .newTab: return "New Tab"
case .newWindow: return "New Window"
case .showNotifications: return "Show Notifications"
case .jumpToUnread: return "Jump to Latest Unread"
case .triggerFlash: return "Flash Focused Panel"
@ -57,6 +59,7 @@ enum KeyboardShortcutSettings {
switch self {
case .toggleSidebar: return "shortcut.toggleSidebar"
case .newTab: return "shortcut.newTab"
case .newWindow: return "shortcut.newWindow"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
@ -81,6 +84,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "b", command: true, shift: false, option: false, control: false)
case .newTab:
return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false)
case .newWindow:
return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:

View file

@ -861,6 +861,10 @@ final class BrowserPanel: Panel, ObservableObject {
let webView = CmuxWebView(frame: .zero, configuration: config)
webView.allowsBackForwardNavigationGestures = true
// Match the empty-page background to the window so newly-created browsers
// don't flash white before content loads.
webView.underPageBackgroundColor = .windowBackgroundColor
// Always present as Safari.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
@ -874,6 +878,16 @@ final class BrowserPanel: Panel, ObservableObject {
self?.refreshFavicon(from: webView)
}
}
navDelegate.didFailNavigation = { [weak self] _, failedURL in
Task { @MainActor in
guard let self else { return }
// Clear stale title/favicon from the previous page so the tab
// shows the failed URL instead of the old page's branding.
self.pageTitle = failedURL.isEmpty ? "" : failedURL
self.faviconPNGData = nil
self.lastFaviconURLString = nil
}
}
navDelegate.openInNewTab = { [weak self] url in
self?.openLinkInNewTab(url: url)
}
@ -1397,6 +1411,7 @@ private extension BrowserPanel {
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var didFinish: ((WKWebView) -> Void)?
var didFailNavigation: ((WKWebView, String) -> Void)?
var openInNewTab: ((URL) -> Void)?
/// The URL of the last navigation that was attempted. Used to preserve the omnibar URL
/// when a provisional navigation fails (e.g. connection refused on localhost:3000).
@ -1426,6 +1441,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String
?? lastAttemptedURL?.absoluteString
?? ""
didFailNavigation?(webView, failedURL)
loadErrorPage(in: webView, failedURL: failedURL, error: nsError)
}

View file

@ -1,3 +1,4 @@
import Bonsplit
import SwiftUI
import WebKit
import AppKit
@ -197,7 +198,12 @@ struct BrowserPanelView: View {
let navButtonSize: CGFloat = 22
return HStack(spacing: 0) {
Button(action: { panel.goBack() }) {
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: navButtonSize, height: navButtonSize, alignment: .center)
@ -208,7 +214,12 @@ struct BrowserPanelView: View {
.opacity(panel.canGoBack ? 1.0 : 0.4)
.help("Go Back")
Button(action: { panel.goForward() }) {
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: navButtonSize, height: navButtonSize, alignment: .center)
@ -221,8 +232,14 @@ struct BrowserPanelView: View {
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()
}
}) {
@ -1711,8 +1728,44 @@ private final class OmnibarNativeTextField: NSTextField {
}
override func mouseDown(with event: NSEvent) {
#if DEBUG
dlog("browser.omnibarClick")
#endif
onPointerDown?()
super.mouseDown(with: event)
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).
window?.makeFirstResponder(self)
currentEditor()?.selectAll(nil)
} else {
// Already editing allow normal click-to-place-cursor and drag-to-select.
// Guard against a stuck tracking loop by posting a synthetic mouseUp after
// a timeout so the main thread can't be blocked indefinitely.
var trackingFinished = false
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
guard !trackingFinished, let self, let window = self.window else { return }
#if DEBUG
dlog("browser.omnibarTrackingTimeout — forcing mouseUp")
#endif
if let fakeUp = NSEvent.mouseEvent(
with: .leftMouseUp,
location: event.locationInWindow,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 0,
clickCount: 1,
pressure: 0.0
) {
NSApp.postEvent(fakeUp, atStart: true)
}
}
super.mouseDown(with: event)
trackingFinished = true
}
}
override func keyDown(with event: NSEvent) {
@ -1783,7 +1836,12 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
guard self.parent.isFocused else { return }
guard self.parent.shouldSuppressWebViewFocus() else { return }
guard let field = self.parentField, let window = field.window else { return }
if !(window.firstResponder === field) {
// 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 ||
((fr as? NSTextView)?.delegate as? NSTextField) === field
if !isAlreadyFocused {
window.makeFirstResponder(field)
}
}
@ -2116,6 +2174,9 @@ private struct OmnibarSuggestionsView: 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) {

View file

@ -32,6 +32,20 @@ final class CmuxWebView: WKWebView {
super.keyDown(with: event)
}
// MARK: - Drag-and-drop passthrough
// WKWebView inherently calls registerForDraggedTypes with public.text (and others).
// Bonsplit tab drags use NSString (public.utf8-plain-text) which conforms to public.text,
// so AppKit's view-hierarchy-based drag routing delivers the session to WKWebView instead
// of SwiftUI's sibling .onDrop overlays. Rejecting in draggingEntered doesn't help because
// AppKit only bubbles up through superviews, not siblings.
//
// Fix: prevent WKWebView from registering as a drag destination entirely. AppKit won't
// route drags here, so they reach the SwiftUI overlay drop zones as intended.
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
// No-op: suppress WKWebView's automatic drag type registration.
}
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)

View file

@ -1,4 +1,5 @@
import AppKit
import Bonsplit
import Combine
import SwiftUI
@ -299,14 +300,24 @@ private struct TitlebarControlsView: View {
private func controlsGroup(config: TitlebarControlsStyleConfig) -> some View {
let hintLayoutItems = titlebarHintLayoutItems(config: config)
let content = HStack(spacing: config.spacing) {
TitlebarControlButton(config: config, action: onToggleSidebar) {
TitlebarControlButton(config: config, action: {
#if DEBUG
dlog("titlebar.toggleSidebar")
#endif
onToggleSidebar()
}) {
iconLabel(systemName: "sidebar.left", config: config)
}
.accessibilityIdentifier("titlebarControl.toggleSidebar")
.accessibilityLabel("Toggle Sidebar")
.help(KeyboardShortcutSettings.Action.toggleSidebar.tooltip("Show or hide the sidebar"))
TitlebarControlButton(config: config, action: onToggleNotifications) {
TitlebarControlButton(config: config, action: {
#if DEBUG
dlog("titlebar.notifications")
#endif
onToggleNotifications()
}) {
ZStack(alignment: .topTrailing) {
iconLabel(systemName: "bell", config: config)
@ -328,7 +339,12 @@ private struct TitlebarControlsView: View {
.accessibilityLabel("Notifications")
.help(KeyboardShortcutSettings.Action.showNotifications.tooltip("Show notifications"))
TitlebarControlButton(config: config, action: onNewTab) {
TitlebarControlButton(config: config, action: {
#if DEBUG
dlog("titlebar.newTab")
#endif
onNewTab()
}) {
iconLabel(systemName: "plus", config: config)
}
.accessibilityIdentifier("titlebarControl.newTab")

View file

@ -58,6 +58,7 @@ final class Workspace: Identifiable, ObservableObject {
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
private var isProgrammaticSplit = false
// Closing tabs mutates split layout immediately; terminal views handle their own AppKit
// layout/size synchronization.
@ -392,6 +393,10 @@ final class Workspace: Identifiable, ObservableObject {
)
surfaceIdToPanelId[newTab.id] = newPanel.id
// Capture the source terminal's hosted view before bonsplit mutates focusedPaneId,
// so we can hand it to focusPanel as the "move focus FROM" view.
let previousHostedView = focusedTerminalPanel?.hostedView
// Create the split with the new tab already present in the new pane.
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
@ -401,10 +406,18 @@ final class Workspace: Identifiable, ObservableObject {
return nil
}
// SplitViewController focuses the newly created pane, but the AppKit first responder can lag
// (or remain on the source surface) during SwiftUI/bonsplit structural updates. Explicitly
// focus the new panel so model focus + responder chain converge deterministically.
focusPanel(newPanel.id)
#if DEBUG
dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)")
#endif
// Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting.
// Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view,
// stealing focus from the new panel and creating model/surface divergence.
previousHostedView?.suppressReparentFocus()
focusPanel(newPanel.id, previousHostedView: previousHostedView)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
return newPanel
}
@ -505,9 +518,13 @@ final class Workspace: Identifiable, ObservableObject {
return nil
}
// See newTerminalSplit: explicitly focus the newly created panel so focus state is
// deterministic for both user and socket-driven workflows.
// See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
let previousHostedView = focusedTerminalPanel?.hostedView
previousHostedView?.suppressReparentFocus()
focusPanel(browserPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
installBrowserPanelSubscription(browserPanel)
@ -790,9 +807,10 @@ final class Workspace: Identifiable, ObservableObject {
}
// MARK: - Focus Management
func focusPanel(_ panelId: UUID) {
func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) {
#if DEBUG
let pane = bonsplitController.focusedPaneId?.id.uuidString ?? "nil"
let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)")
FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)")
#endif
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
@ -801,7 +819,9 @@ final class Workspace: Identifiable, ObservableObject {
// Capture the currently focused terminal view so we can explicitly move AppKit first
// responder when focusing another terminal (helps avoid "highlighted but typing goes to
// another pane" after heavy split/tab mutations).
let previousTerminalHostedView = focusedTerminalPanel?.hostedView
// When a caller passes an explicit previousHostedView (e.g. during split creation where
// bonsplit has already mutated focusedPaneId), prefer it over the derived value.
let previousTerminalHostedView = previousHostedView ?? focusedTerminalPanel?.hostedView
// `selectTab` does not necessarily move bonsplit's focused pane. For programmatic focus
// (socket API, notification click, etc.), ensure the target tab's pane becomes focused

View file

@ -137,11 +137,17 @@ struct EmptyPanelView: View {
}
private func createTerminal() {
#if DEBUG
dlog("emptyPane.newTerminal pane=\(paneId.id.uuidString.prefix(5))")
#endif
focusPane()
_ = workspace.newTerminalSurface(inPane: paneId)
}
private func createBrowser() {
#if DEBUG
dlog("emptyPane.newBrowser pane=\(paneId.id.uuidString.prefix(5))")
#endif
focusPane()
_ = workspace.newBrowserSurface(inPane: paneId)
}

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 74ea74ea6294b92d50f5d27dd15daf3aebbfa987
Subproject commit d8af81190c90a0ddb28f8dbd5ad79070564b2234