From 484b66c8ace112d0cb0cafff73b8f91cc2f753ae Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 17 Feb 2026 03:21:08 -0800 Subject: [PATCH] Fix terminal keys swallowed after opening browser (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- CLAUDE.md | 17 +++ Resources/Info.plist | 13 +++ Sources/AppDelegate.swift | 108 ++++++++++++++++++- Sources/ContentView.swift | 29 ++++- Sources/Find/SurfaceSearchOverlay.swift | 14 ++- Sources/GhosttyTerminalView.swift | 34 +++++- Sources/KeyboardShortcutSettings.swift | 5 + Sources/Panels/BrowserPanel.swift | 16 +++ Sources/Panels/BrowserPanelView.swift | 69 +++++++++++- Sources/Panels/CmuxWebView.swift | 14 +++ Sources/Update/UpdateTitlebarAccessory.swift | 22 +++- Sources/Workspace.swift | 38 +++++-- Sources/WorkspaceContentView.swift | 6 ++ vendor/bonsplit | 2 +- 14 files changed, 364 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bf060569..0bf75b01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Resources/Info.plist b/Resources/Info.plist index 6387ec67..4a293313 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -69,6 +69,19 @@ + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.splittabbar.tabtransfer + UTTypeDescription + Bonsplit Tab Transfer + UTTypeConformsTo + + public.data + + + SUFeedURL https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml SUPublicEDKey diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c2951cde..e6bb46b2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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: "+") } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 58444ba0..4364fa01 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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) diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index cbf8f644..d8bf5463 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -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()) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 29b73468..b79fb836 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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. diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 6ba5b438..82a7fe84 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -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: diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index a8c0bdab..728b63a4 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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) } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 588dbd7f..3bde66de 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -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) { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 978d0a5f..a1627e7f 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -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) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index cd213be8..b6e004ef 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -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") diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4af84b12..510c9479 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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 diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index c385f4ef..d92e2b42 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -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) } diff --git a/vendor/bonsplit b/vendor/bonsplit index 74ea74ea..d8af8119 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 74ea74ea6294b92d50f5d27dd15daf3aebbfa987 +Subproject commit d8af81190c90a0ddb28f8dbd5ad79070564b2234