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