From 743cfcdc6d346834cf4670b83a938919832622e9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:20:42 -0800 Subject: [PATCH 01/26] Fix browser devtools persistence and Safari shortcut wiring --- README.md | 5 +- Sources/AppDelegate.swift | 15 ++ Sources/KeyboardShortcutSettings.swift | 14 ++ Sources/Panels/BrowserPanel.swift | 118 ++++++++++ Sources/Panels/BrowserPanelView.swift | 213 ++++++++++++++---- Sources/TabManager.swift | 10 + Sources/cmuxApp.swift | 106 ++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 84 +++++++ skills/cmux-debug-windows/SKILL.md | 3 + .../scripts/debug_windows_snapshot.sh | 13 +- vendor/bonsplit | 2 +- 11 files changed, 538 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 8157d38a..0b284537 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta ### Browser +Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`. + | Shortcut | Action | |----------|--------| | ⌘ ⇧ L | Open browser in split | @@ -143,7 +145,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta | ⌘ [ | Back | | ⌘ ] | Forward | | ⌘ R | Reload page | -| ⌥ ⌘ I | Open Developer Tools | +| ⌥ ⌘ I | Toggle Developer Tools (Safari default) | +| ⌥ ⌘ C | Show JavaScript Console (Safari default) | ### Notifications diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cede8954..45ee1aa1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1878,6 +1878,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Safari defaults: + // - Option+Command+I => Show/Toggle Web Inspector + // - Option+Command+C => Show JavaScript Console + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false + if !didHandle { NSSound.beep() } + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false + if !didHandle { NSSound.beep() } + return true + } + // Focus browser address bar: Cmd+L if flags == [.command] && chars == "l" { if let focusedPanel = tabManager?.focusedBrowserPanel { diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 82a7fe84..e3e27884 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -29,6 +29,8 @@ enum KeyboardShortcutSettings { // Panels case openBrowser + case toggleBrowserDeveloperTools + case showBrowserJavaScriptConsole var id: String { rawValue } @@ -52,6 +54,8 @@ enum KeyboardShortcutSettings { case .splitRight: return "Split Right" case .splitDown: return "Split Down" case .openBrowser: return "Open Browser" + case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools" + case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console" } } @@ -75,6 +79,8 @@ enum KeyboardShortcutSettings { case .prevSurface: return "shortcut.prevSurface" case .newSurface: return "shortcut.newSurface" case .openBrowser: return "shortcut.openBrowser" + case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" + case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" } } @@ -116,6 +122,12 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) + case .toggleBrowserDeveloperTools: + // Safari default: Show Web Inspector. + return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false) + case .showBrowserJavaScriptConsole: + // Safari default: Show JavaScript Console. + return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) } } @@ -182,6 +194,8 @@ enum KeyboardShortcutSettings { static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) } static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) } + static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) } + static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) } } /// A keyboard shortcut that can be stored in UserDefaults diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 6c405b3d..bd50abc6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -825,6 +825,8 @@ final class BrowserPanel: Panel, ObservableObject { private let minPageZoom: CGFloat = 0.25 private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 + // Persist user intent across WebKit detach/reattach churn (split/layout updates). + private var preferredDeveloperToolsVisible: Bool = false var displayTitle: String { if !pageTitle.isEmpty { @@ -865,6 +867,11 @@ final class BrowserPanel: Panel, ObservableObject { let webView = CmuxWebView(frame: .zero, configuration: config) webView.allowsBackForwardNavigationGestures = true + // Required for Web Inspector support on recent WebKit SDKs. + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + // Match the empty-page background to the window so newly-created browsers // don't flash white before content loads. webView.underPageBackgroundColor = .windowBackgroundColor @@ -1296,6 +1303,90 @@ extension BrowserPanel { webView.stopLoading() } + @discardableResult + func toggleDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + let targetVisible = !visible + let selector = NSSelectorFromString(targetVisible ? "show" : "close") + guard inspector.responds(to: selector) else { return false } + inspector.cmuxCallVoid(selector: selector) + preferredDeveloperToolsVisible = targetVisible + return true + } + + @discardableResult + func showDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if !visible { + let showSelector = NSSelectorFromString("show") + guard inspector.responds(to: showSelector) else { return false } + inspector.cmuxCallVoid(selector: showSelector) + } + preferredDeveloperToolsVisible = true + return true + } + + @discardableResult + func showDeveloperToolsConsole() -> Bool { + guard showDeveloperTools() else { return false } + guard let inspector = webView.cmuxInspectorObject() else { return true } + // WebKit private inspector API differs by OS; try known console selectors. + let consoleSelectors = [ + "showConsole", + "showConsoleTab", + "showConsoleView", + ] + for raw in consoleSelectors { + let selector = NSSelectorFromString(raw) + if inspector.responds(to: selector) { + inspector.cmuxCallVoid(selector: selector) + break + } + } + return true + } + + /// Called before WKWebView detaches so manual inspector closes are respected. + func syncDeveloperToolsPreferenceFromInspector() { + guard let inspector = webView.cmuxInspectorObject() else { return } + if let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) { + preferredDeveloperToolsVisible = visible + } + } + + /// Called after WKWebView reattaches to keep inspector stable across split/layout churn. + func restoreDeveloperToolsAfterAttachIfNeeded() { + guard preferredDeveloperToolsVisible else { return } + guard let inspector = webView.cmuxInspectorObject() else { return } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + guard !visible else { return } + let selector = NSSelectorFromString("show") + guard inspector.responds(to: selector) else { return } + inspector.cmuxCallVoid(selector: selector) + preferredDeveloperToolsVisible = true + } + + @discardableResult + func isDeveloperToolsVisible() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + return inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + } + + @discardableResult + func hideDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if visible { + let selector = NSSelectorFromString("close") + guard inspector.responds(to: selector) else { return false } + inspector.cmuxCallVoid(selector: selector) + } + preferredDeveloperToolsVisible = false + return true + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) @@ -1427,6 +1518,33 @@ private extension BrowserPanel { } } +private extension WKWebView { + func cmuxInspectorObject() -> NSObject? { + let selector = NSSelectorFromString("_inspector") + guard responds(to: selector), + let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else { + return nil + } + return inspector + } +} + +private extension NSObject { + func cmuxCallBool(selector: Selector) -> Bool? { + guard responds(to: selector) else { return nil } + typealias Fn = @convention(c) (AnyObject, Selector) -> Bool + let fn = unsafeBitCast(method(for: selector), to: Fn.self) + return fn(self, selector) + } + + func cmuxCallVoid(selector: Selector) { + guard responds(to: selector) else { return } + typealias Fn = @convention(c) (AnyObject, Selector) -> Void + let fn = unsafeBitCast(method(for: selector), to: Fn.self) + fn(self, selector) + } +} + // MARK: - Navigation Delegate private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 76e5e9a1..54ed2aab 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3,6 +3,113 @@ import SwiftUI import WebKit import AppKit +enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable { + case wrenchAndScrewdriver = "wrench.and.screwdriver" + case wrenchAndScrewdriverFill = "wrench.and.screwdriver.fill" + case curlyBracesSquare = "curlybraces.square" + case curlyBraces = "curlybraces" + case terminalFill = "terminal.fill" + case terminal = "terminal" + case hammer = "hammer" + case hammerCircle = "hammer.circle" + case ladybug = "ladybug" + case ladybugFill = "ladybug.fill" + case scope = "scope" + case codeChevrons = "chevron.left.slash.chevron.right" + case gearshape = "gearshape" + case gearshapeFill = "gearshape.fill" + case globe = "globe" + case globeAmericas = "globe.americas.fill" + + var id: String { rawValue } + + var title: String { + switch self { + case .wrenchAndScrewdriver: return "Wrench + Screwdriver" + case .wrenchAndScrewdriverFill: return "Wrench + Screwdriver (Fill)" + case .curlyBracesSquare: return "Curly Braces" + case .curlyBraces: return "Curly Braces (Plain)" + case .terminalFill: return "Terminal (Fill)" + case .terminal: return "Terminal" + case .hammer: return "Hammer" + case .hammerCircle: return "Hammer Circle" + case .ladybug: return "Bug" + case .ladybugFill: return "Bug (Fill)" + case .scope: return "Scope" + case .codeChevrons: return "Code Chevrons" + case .gearshape: return "Gear" + case .gearshapeFill: return "Gear (Fill)" + case .globe: return "Globe" + case .globeAmericas: return "Globe Americas (Fill)" + } + } +} + +enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { + case bonsplitInactive + case bonsplitActive + case accent + case tertiary + + var id: String { rawValue } + + var title: String { + switch self { + case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)" + case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)" + case .accent: return "Accent" + case .tertiary: return "Tertiary" + } + } + + var color: Color { + switch self { + case .bonsplitInactive: + // Matches Bonsplit tab icon tint for inactive tabs. + return Color(nsColor: .secondaryLabelColor) + case .bonsplitActive: + // Matches Bonsplit tab icon tint for active tabs. + return Color(nsColor: .labelColor) + case .accent: + return .accentColor + case .tertiary: + return Color(nsColor: .tertiaryLabelColor) + } + } +} + +enum BrowserDevToolsButtonDebugSettings { + static let iconNameKey = "browserDevToolsIconName" + static let iconColorKey = "browserDevToolsIconColor" + static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver + static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive + + static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption { + guard let raw = defaults.string(forKey: iconNameKey), + let option = BrowserDevToolsIconOption(rawValue: raw) else { + return defaultIcon + } + return option + } + + static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption { + guard let raw = defaults.string(forKey: iconColorKey), + let option = BrowserDevToolsIconColorOption(rawValue: raw) else { + return defaultColor + } + return option + } + + static func copyPayload(defaults: UserDefaults = .standard) -> String { + let icon = iconOption(defaults: defaults) + let color = colorOption(defaults: defaults) + return """ + browserDevToolsIconName=\(icon.rawValue) + browserDevToolsIconColor=\(color.rawValue) + """ + } +} + struct OmnibarInlineCompletion: Equatable { let typedText: String let displayText: String @@ -25,6 +132,8 @@ struct BrowserPanelView: View { @State private var addressBarFocused: Bool = false @AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled + @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @State private var suggestionTask: Task? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @@ -38,6 +147,8 @@ struct BrowserPanelView: View { @State private var omnibarPillFrame: CGRect = .zero @State private var lastHandledAddressBarFocusRequestId: UUID? private let omnibarPillCornerRadius: CGFloat = 12 + private let addressBarButtonSize: CGFloat = 22 + private let devToolsButtonIconSize: CGFloat = 11 private var searchEngine: BrowserSearchEngine { BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine @@ -63,6 +174,14 @@ struct BrowserPanelView: View { return searchSuggestionsEnabled } + private var devToolsIconOption: BrowserDevToolsIconOption { + BrowserDevToolsIconOption(rawValue: devToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + } + + private var devToolsColorOption: BrowserDevToolsIconColorOption { + BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + } + var body: some View { VStack(spacing: 0) { addressBar @@ -202,6 +321,8 @@ struct BrowserPanelView: View { omnibarField .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") + + developerToolsButton } .padding(.horizontal, 8) .padding(.vertical, 6) @@ -211,8 +332,6 @@ struct BrowserPanelView: View { } private var addressBarButtonBar: some View { - let navButtonSize: CGFloat = 22 - return HStack(spacing: 0) { Button(action: { #if DEBUG @@ -222,10 +341,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.left") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoBack) .opacity(panel.canGoBack ? 1.0 : 0.4) .help("Go Back") @@ -238,10 +357,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoForward) .opacity(panel.canGoForward ? 1.0 : 0.4) .help("Go Forward") @@ -261,14 +380,29 @@ struct BrowserPanelView: View { }) { Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .help(panel.isLoading ? "Stop" : "Reload") } } + private var developerToolsButton: some View { + Button(action: { + openDevTools() + }) { + Image(systemName: devToolsIconOption.rawValue) + .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .foregroundStyle(devToolsColorOption.color) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } + .buttonStyle(.plain) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .help("Toggle Developer Tools") + .accessibilityIdentifier("BrowserToggleDevToolsButton") + } + private var omnibarField: some View { let showSecureBadge = panel.currentURL?.scheme == "https" @@ -376,12 +510,6 @@ struct BrowserPanelView: View { } }) .zIndex(0) - .contextMenu { - Button("Open Developer Tools") { - openDevTools() - } - .keyboardShortcut("i", modifiers: [.command, .option]) - } } private func triggerFocusFlashAnimation() { @@ -445,10 +573,11 @@ struct BrowserPanelView: View { } private func openDevTools() { - // WKWebView with developerExtrasEnabled allows right-click > Inspect Element - // We can also trigger via JavaScript - Task { - try? await panel.evaluateJavaScript("window.webkit?.messageHandlers?.devTools?.postMessage('open')") + #if DEBUG + dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") + #endif + if !panel.toggleDeveloperTools() { + NSSound.beep() } } @@ -2426,7 +2555,6 @@ struct WebViewRepresentable: NSViewRepresentable { final class Coordinator { weak var webView: WKWebView? - var constraints: [NSLayoutConstraint] = [] var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 var attachGeneration: Int = 0 @@ -2453,7 +2581,7 @@ struct WebViewRepresentable: NSViewRepresentable { return container } - private static func attachWebView(_ webView: WKWebView, to host: NSView, coordinator: Coordinator) { + private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. if let window = webView.window, @@ -2466,15 +2594,11 @@ struct WebViewRepresentable: NSViewRepresentable { host.subviews.forEach { $0.removeFromSuperview() } host.addSubview(webView) - webView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints = [ - webView.leadingAnchor.constraint(equalTo: host.leadingAnchor), - webView.trailingAnchor.constraint(equalTo: host.trailingAnchor), - webView.topAnchor.constraint(equalTo: host.topAnchor), - webView.bottomAnchor.constraint(equalTo: host.bottomAnchor), - ] - NSLayoutConstraint.activate(coordinator.constraints) + // Work around WebKit bug 272474 where Inspect Element can render blank/flicker + // when WKWebView is edge-pinned using Auto Layout constraints. + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = host.bounds // Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out. webView.needsLayout = true @@ -2483,7 +2607,13 @@ struct WebViewRepresentable: NSViewRepresentable { webView.displayIfNeeded() } - private static func scheduleAttachRetry(_ webView: WKWebView, to host: NSView, coordinator: Coordinator, generation: Int) { + private static func scheduleAttachRetry( + _ webView: WKWebView, + panel: BrowserPanel, + to host: NSView, + coordinator: Coordinator, + generation: Int + ) { // Don't schedule multiple overlapping retries. guard coordinator.attachRetryWorkItem == nil else { return } @@ -2506,14 +2636,21 @@ struct WebViewRepresentable: NSViewRepresentable { // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - scheduleAttachRetry(webView, to: host, coordinator: coordinator, generation: generation) + scheduleAttachRetry( + webView, + panel: panel, + to: host, + coordinator: coordinator, + generation: generation + ) } } return } coordinator.attachRetryCount = 0 - attachWebView(webView, to: host, coordinator: coordinator) + attachWebView(webView, to: host) + panel.restoreDeveloperToolsAfterAttachIfNeeded() } coordinator.attachRetryWorkItem = work @@ -2528,6 +2665,7 @@ struct WebViewRepresentable: NSViewRepresentable { // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { + panel.syncDeveloperToolsPreferenceFromInspector() context.coordinator.attachRetryWorkItem?.cancel() context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 @@ -2539,9 +2677,6 @@ struct WebViewRepresentable: NSViewRepresentable { window.makeFirstResponder(nil) } - NSLayoutConstraint.deactivate(context.coordinator.constraints) - context.coordinator.constraints.removeAll() - if webView.superview != nil { webView.removeFromSuperview() } @@ -2560,12 +2695,14 @@ struct WebViewRepresentable: NSViewRepresentable { // can create containers that are never inserted into the window. Self.scheduleAttachRetry( webView, + panel: panel, to: nsView, coordinator: context.coordinator, generation: context.coordinator.attachGeneration ) } else { - Self.attachWebView(webView, to: nsView, coordinator: context.coordinator) + Self.attachWebView(webView, to: nsView) + panel.restoreDeveloperToolsAfterAttachIfNeeded() } } else { // Already attached; no need for any pending retry. @@ -2573,6 +2710,7 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 + panel.restoreDeveloperToolsAfterAttachIfNeeded() } // Focus handling. Avoid fighting the address bar when it is focused. @@ -2601,9 +2739,6 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachRetryCount = 0 coordinator.attachGeneration += 1 - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints.removeAll() - guard let webView = coordinator.webView else { return } // If we're being torn down while the WKWebView (or one of its subviews) is first responder, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f3c43522..90baf6ac 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -823,6 +823,16 @@ class TabManager: ObservableObject { focusedBrowserPanel?.resetZoom() ?? false } + @discardableResult + func toggleDeveloperToolsFocusedBrowser() -> Bool { + focusedBrowserPanel?.toggleDeveloperTools() ?? false + } + + @discardableResult + func showJavaScriptConsoleFocusedBrowser() -> Bool { + focusedBrowserPanel?.showDeveloperToolsConsole() ?? false + } + /// Backwards compatibility: returns the focused surface ID func focusedSurfaceId(for tabId: UUID) -> UUID? { focusedPanelId(for: tabId) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 973409e9..26e4d504 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -15,6 +15,10 @@ struct cmuxApp: App { @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) + private var toggleBrowserDeveloperToolsShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey) + private var showBrowserJavaScriptConsoleShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -422,6 +426,20 @@ struct cmuxApp: App { } .keyboardShortcut("r", modifiers: .command) + splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { + let manager = (AppDelegate.shared?.tabManager ?? tabManager) + if !manager.toggleDeveloperToolsFocusedBrowser() { + NSSound.beep() + } + } + + splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) { + let manager = (AppDelegate.shared?.tabManager ?? tabManager) + if !manager.showJavaScriptConsoleFocusedBrowser() { + NSSound.beep() + } + } + Button("Zoom In") { _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() } @@ -536,6 +554,20 @@ struct cmuxApp: App { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } + private var toggleBrowserDeveloperToolsMenuShortcut: StoredShortcut { + decodeShortcut( + from: toggleBrowserDeveloperToolsShortcutData, + fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + ) + } + + private var showBrowserJavaScriptConsoleMenuShortcut: StoredShortcut { + decodeShortcut( + from: showBrowserJavaScriptConsoleShortcutData, + fallback: KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } @@ -1123,6 +1155,7 @@ private enum DebugWindowConfigSnapshot { """ let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults) + let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) return """ # Sidebar Debug @@ -1133,6 +1166,9 @@ private enum DebugWindowConfigSnapshot { # Menu Bar Extra Debug \(menuBarPayload) + + # Browser DevTools Button + \(browserDevToolsPayload) """ } @@ -1199,6 +1235,16 @@ private struct DebugWindowControlsView: View { @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 + @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + + private var selectedDevToolsIconOption: BrowserDevToolsIconOption { + BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + } + + private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption { + BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + } var body: some View { ScrollView { @@ -1282,12 +1328,58 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } + GroupBox("Browser DevTools Button") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Icon") + Picker("Icon", selection: $browserDevToolsIconNameRaw) { + ForEach(BrowserDevToolsIconOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Color") + Picker("Color", selection: $browserDevToolsIconColorRaw) { + ForEach(BrowserDevToolsIconColorOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Preview") + Spacer() + Image(systemName: selectedDevToolsIconOption.rawValue) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(selectedDevToolsColorOption.color) + } + + HStack(spacing: 12) { + Button("Reset Button") { + resetBrowserDevToolsButton() + } + Button("Copy Button Config") { + copyBrowserDevToolsButtonConfig() + } + } + } + .padding(.top, 2) + } + GroupBox("Copy") { VStack(alignment: .leading, spacing: 8) { Button("Copy All Debug Config") { DebugWindowConfigSnapshot.copyCombinedToPasteboard() } - Text("Copies sidebar, background, and menu bar debug settings as one payload.") + Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.") .font(.caption) .foregroundColor(.secondary) } @@ -1348,6 +1440,18 @@ private struct DebugWindowControlsView: View { pasteboard.clearContents() pasteboard.setString(payload, forType: .string) } + + private func resetBrowserDevToolsButton() { + browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + } + + private func copyBrowserDevToolsButtonConfig() { + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } } private final class AboutWindowController: NSWindowController, NSWindowDelegate { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 1630f254..9728e504 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -88,6 +88,90 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create defaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + } + return defaults + } + + func testIconCatalogIncludesExpandedChoices() { + XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare)) + } + + func testIconOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultIcon + ) + } + + func testColorOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultColor + ) + } + + func testCopyPayloadUsesPersistedValues() { + let defaults = makeIsolatedDefaults() + defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) + XCTAssertTrue(payload.contains("browserDevToolsIconName=scope")) + XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive")) + } +} + +final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { + func testSafariDefaultShortcutForToggleDeveloperTools() { + let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + XCTAssertEqual(shortcut.key, "i") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } + + func testSafariDefaultShortcutForShowJavaScriptConsole() { + let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut + XCTAssertEqual(shortcut.key, "c") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } +} + +@MainActor +final class BrowserDeveloperToolsConfigurationTests: XCTestCase { + func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { + let panel = BrowserPanel(workspaceId: UUID()) + let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool + XCTAssertEqual(developerExtras, true) + + if #available(macOS 13.3, *) { + XCTAssertTrue(panel.webView.isInspectable) + } + } +} + final class WorkspaceShortcutMapperTests: XCTestCase { func testCommandNineMapsToLastWorkspaceIndex() { XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0) diff --git a/skills/cmux-debug-windows/SKILL.md b/skills/cmux-debug-windows/SKILL.md index fb5dc859..885c5f49 100644 --- a/skills/cmux-debug-windows/SKILL.md +++ b/skills/cmux-debug-windows/SKILL.md @@ -10,6 +10,9 @@ Keep this workflow focused on existing debug windows and menu entries. Do not ad ## Workflow 1. Verify debug menu wiring in `Sources/cmuxApp.swift` under `CommandMenu("Debug")`. + - Menu path in app: `Debug` → `Debug Windows` → window entry. + - The `Debug` menu only exists in DEBUG builds (`./scripts/reload.sh --tag ...`). + - Release builds (`reloadp.sh`, `reloads.sh`) do not show this menu. 2. Keep these actions available in `Menu("Debug Windows")`: - `Sidebar Debug…` - `Background Debug…` diff --git a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh index c4461cb0..ac08502d 100755 --- a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh +++ b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh @@ -5,8 +5,8 @@ usage() { cat <<'USAGE' Usage: debug_windows_snapshot.sh [--domain ] [--copy] -Collect Sidebar Debug, Background Debug, and Menu Bar Extra debug values from macOS defaults -and print a combined payload. Use --copy to also copy the payload to clipboard. +Collect Sidebar Debug, Background Debug, Menu Bar Extra, and Browser DevTools debug values +from macOS defaults and print a combined payload. Use --copy to also copy the payload. Examples: debug_windows_snapshot.sh @@ -118,13 +118,16 @@ menubarDebugSingleDigitYOffset="$(format_number "$(read_value menubarDebugSingle menubarDebugMultiDigitYOffset="$(format_number "$(read_value menubarDebugMultiDigitYOffset 0.60)" 2)" legacySingleDigitX="$(read_value menubarDebugTextRectXAdjust '')" if [[ -n "$legacySingleDigitX" ]]; then - menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)" +menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)" else menubarDebugSingleDigitXAdjust="$(format_number "$(read_value menubarDebugSingleDigitXAdjust -1.10)" 2)" fi menubarDebugMultiDigitXAdjust="$(format_number "$(read_value menubarDebugMultiDigitXAdjust 2.42)" 2)" menubarDebugTextRectWidthAdjust="$(format_number "$(read_value menubarDebugTextRectWidthAdjust 1.80)" 2)" +browserDevToolsIconName="$(read_value browserDevToolsIconName 'wrench.and.screwdriver')" +browserDevToolsIconColor="$(read_value browserDevToolsIconColor bonsplitInactive)" + payload="$(cat < Date: Thu, 19 Feb 2026 20:24:32 -0800 Subject: [PATCH 02/26] Add regression tests for browser devtools visibility persistence --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9728e504..8221d677 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1,6 +1,7 @@ import XCTest import AppKit import WebKit +import ObjectiveC.runtime #if canImport(cmux_DEV) @testable import cmux_DEV @@ -8,6 +9,49 @@ import WebKit @testable import cmux #endif +private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0 +private var cmuxUnitTestInspectorOverrideInstalled = false + +private extension CmuxWebView { + @objc func cmuxUnitTestInspector() -> NSObject? { + objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject + } +} + +private extension WKWebView { + func cmuxSetUnitTestInspector(_ inspector: NSObject?) { + objc_setAssociatedObject( + self, + &cmuxUnitTestInspectorAssociationKey, + inspector, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } +} + +private func installCmuxUnitTestInspectorOverride() { + guard !cmuxUnitTestInspectorOverrideInstalled else { return } + + guard let replacementMethod = class_getInstanceMethod( + CmuxWebView.self, + #selector(CmuxWebView.cmuxUnitTestInspector) + ) else { + fatalError("Unable to locate test inspector replacement method") + } + + let added = class_addMethod( + CmuxWebView.self, + NSSelectorFromString("_inspector"), + method_getImplementation(replacementMethod), + method_getTypeEncoding(replacementMethod) + ) + guard added else { + fatalError("Unable to install CmuxWebView _inspector test override") + } + + cmuxUnitTestInspectorOverrideInstalled = true +} + final class CmuxWebViewKeyEquivalentTests: XCTestCase { private final class ActionSpy: NSObject { private(set) var invoked: Bool = false @@ -172,6 +216,73 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { } } +@MainActor +final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { + private final class FakeInspector: NSObject { + private(set) var showCount = 0 + private(set) var closeCount = 0 + private var visible = false + + @objc func isVisible() -> Bool { + visible + } + + @objc func show() { + showCount += 1 + visible = true + } + + @objc func close() { + closeCount += 1 + visible = false + } + } + + override class func setUp() { + super.setUp() + installCmuxUnitTestInspectorOverride() + } + + private func makePanelWithInspector() -> (BrowserPanel, FakeInspector) { + let panel = BrowserPanel(workspaceId: UUID()) + let inspector = FakeInspector() + panel.webView.cmuxSetUnitTestInspector(inspector) + return (panel, inspector) + } + + func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate WebKit closing inspector during detach/reattach churn. + inspector.close() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.closeCount, 1) + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 2) + } + + func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate user closing inspector before detach. + inspector.close() + panel.syncDeveloperToolsPreferenceFromInspector() + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + } +} + final class WorkspaceShortcutMapperTests: XCTestCase { func testCommandNineMapsToLastWorkspaceIndex() { XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0) From 397e46a66768bfc6f0344737e90393e821b6f232 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:31:00 -0800 Subject: [PATCH 03/26] Add devtools split diagnostics and restore retries --- Sources/AppDelegate.swift | 22 +++++ Sources/Panels/BrowserPanel.swift | 93 +++++++++++++++++-- Sources/Panels/BrowserPanelView.swift | 76 ++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 15 +++ 4 files changed, 198 insertions(+), 8 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 45ee1aa1..1eb89d94 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2042,8 +2042,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { + #if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + if let browser = tabManager?.focusedBrowserPanel { + dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())") + } else { + dlog("split.shortcut dir=\(directionLabel) pre panel=nil") + } + #endif + tabManager?.createSplit(direction: direction) #if DEBUG + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + if let browser = self?.tabManager?.focusedBrowserPanel { + dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())") + } else { + dlog("split.shortcut dir=\(directionLabel) post panel=nil") + } + } recordGotoSplitSplitIfNeeded(direction: direction) #endif return true diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index bd50abc6..545cfafa 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -827,6 +827,10 @@ final class BrowserPanel: Panel, ObservableObject { private let pageZoomStep: CGFloat = 0.1 // Persist user intent across WebKit detach/reattach churn (split/layout updates). private var preferredDeveloperToolsVisible: Bool = false + private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? + private var developerToolsRestoreRetryAttempt: Int = 0 + private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 + private let developerToolsRestoreRetryMaxAttempts: Int = 40 var displayTitle: String { if !pageTitle.isEmpty { @@ -1236,6 +1240,8 @@ final class BrowserPanel: Panel, ObservableObject { } deinit { + developerToolsRestoreRetryWorkItem?.cancel() + developerToolsRestoreRetryWorkItem = nil webViewObservers.removeAll() } } @@ -1312,6 +1318,11 @@ extension BrowserPanel { guard inspector.responds(to: selector) else { return false } inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = targetVisible + if targetVisible { + developerToolsRestoreRetryAttempt = 0 + } else { + cancelDeveloperToolsRestoreRetry() + } return true } @@ -1325,6 +1336,11 @@ extension BrowserPanel { inspector.cmuxCallVoid(selector: showSelector) } preferredDeveloperToolsVisible = true + if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) { + cancelDeveloperToolsRestoreRetry() + } else { + scheduleDeveloperToolsRestoreRetry() + } return true } @@ -1349,23 +1365,49 @@ extension BrowserPanel { } /// Called before WKWebView detaches so manual inspector closes are respected. - func syncDeveloperToolsPreferenceFromInspector() { + func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) { guard let inspector = webView.cmuxInspectorObject() else { return } - if let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) { - preferredDeveloperToolsVisible = visible + guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return } + if visible { + preferredDeveloperToolsVisible = true + cancelDeveloperToolsRestoreRetry() + return } + if preserveVisibleIntent && preferredDeveloperToolsVisible { + return + } + preferredDeveloperToolsVisible = false + cancelDeveloperToolsRestoreRetry() } /// Called after WKWebView reattaches to keep inspector stable across split/layout churn. func restoreDeveloperToolsAfterAttachIfNeeded() { - guard preferredDeveloperToolsVisible else { return } - guard let inspector = webView.cmuxInspectorObject() else { return } + guard preferredDeveloperToolsVisible else { + cancelDeveloperToolsRestoreRetry() + return + } + guard let inspector = webView.cmuxInspectorObject() else { + scheduleDeveloperToolsRestoreRetry() + return + } let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - guard !visible else { return } + guard !visible else { + cancelDeveloperToolsRestoreRetry() + return + } let selector = NSSelectorFromString("show") - guard inspector.responds(to: selector) else { return } + guard inspector.responds(to: selector) else { + cancelDeveloperToolsRestoreRetry() + return + } inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = true + let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if visibleAfterShow { + cancelDeveloperToolsRestoreRetry() + } else { + scheduleDeveloperToolsRestoreRetry() + } } @discardableResult @@ -1384,6 +1426,7 @@ extension BrowserPanel { inspector.cmuxCallVoid(selector: selector) } preferredDeveloperToolsVisible = false + cancelDeveloperToolsRestoreRetry() return true } @@ -1495,6 +1538,42 @@ extension BrowserPanel { } +private extension BrowserPanel { + func scheduleDeveloperToolsRestoreRetry() { + guard preferredDeveloperToolsVisible else { return } + guard developerToolsRestoreRetryWorkItem == nil else { return } + guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return } + + developerToolsRestoreRetryAttempt += 1 + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.developerToolsRestoreRetryWorkItem = nil + self.restoreDeveloperToolsAfterAttachIfNeeded() + } + developerToolsRestoreRetryWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsRestoreRetryDelay, execute: work) + } + + func cancelDeveloperToolsRestoreRetry() { + developerToolsRestoreRetryWorkItem?.cancel() + developerToolsRestoreRetryWorkItem = nil + developerToolsRestoreRetryAttempt = 0 + } +} + +#if DEBUG +extension BrowserPanel { + func debugDeveloperToolsStateSummary() -> String { + let preferred = preferredDeveloperToolsVisible ? 1 : 0 + let visible = isDeveloperToolsVisible() ? 1 : 0 + let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1 + let attached = webView.superview == nil ? 0 : 1 + let inWindow = webView.window == nil ? 0 : 1 + return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt)" + } +} +#endif + private extension BrowserPanel { @discardableResult func applyPageZoom(_ candidate: CGFloat) -> Bool { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 54ed2aab..7b3c3ea8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2560,6 +2560,19 @@ struct WebViewRepresentable: NSViewRepresentable { var attachGeneration: Int = 0 } + #if DEBUG + private static func logDevToolsState( + _ panel: BrowserPanel, + event: String, + generation: Int, + retryCount: Int + ) { + dlog( + "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" + ) + } + #endif + private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool { var r = start var hops = 0 @@ -2632,6 +2645,16 @@ struct WebViewRepresentable: NSViewRepresentable { // is in a window during bonsplit tree updates; moving the webview too early can be flaky. guard host.window != nil else { coordinator.attachRetryCount += 1 + #if DEBUG + if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 { + logDevToolsState( + panel, + event: "retry.waitingForWindow", + generation: generation, + retryCount: coordinator.attachRetryCount + ) + } + #endif // Be generous here: bonsplit structural updates can keep a representable // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { @@ -2651,6 +2674,9 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachRetryCount = 0 attachWebView(webView, to: host) panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + logDevToolsState(panel, event: "retry.attached", generation: generation, retryCount: 0) + #endif } coordinator.attachRetryWorkItem = work @@ -2665,7 +2691,23 @@ struct WebViewRepresentable: NSViewRepresentable { // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { - panel.syncDeveloperToolsPreferenceFromInspector() + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.beforeSync", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif + panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.afterSync", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif context.coordinator.attachRetryWorkItem?.cancel() context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 @@ -2681,6 +2723,14 @@ struct WebViewRepresentable: NSViewRepresentable { webView.removeFromSuperview() } nsView.subviews.forEach { $0.removeFromSuperview() } + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.done", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif return } @@ -2693,6 +2743,14 @@ struct WebViewRepresentable: NSViewRepresentable { if nsView.window == nil { // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI // can create containers that are never inserted into the window. + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.defer.offWindow", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif Self.scheduleAttachRetry( webView, panel: panel, @@ -2703,6 +2761,14 @@ struct WebViewRepresentable: NSViewRepresentable { } else { Self.attachWebView(webView, to: nsView) panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.immediate", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif } } else { // Already attached; no need for any pending retry. @@ -2711,6 +2777,14 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.alreadyAttached", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif } // Focus handling. Avoid fighting the address bar when it is focused. diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8221d677..5913fd79 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -281,6 +281,21 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertFalse(panel.isDeveloperToolsVisible()) XCTAssertEqual(inspector.showCount, 1) } + + func testSyncCanPreserveVisibleIntentDuringDetachChurn() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate a transient close caused by view detach, not user intent. + inspector.close() + panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 2) + } } final class WorkspaceShortcutMapperTests: XCTestCase { From f546c289c3162699bb21adde9eea2f0f4178c12d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:38:31 -0800 Subject: [PATCH 04/26] Preserve devtools webview during split teardown --- Sources/Panels/BrowserPanel.swift | 7 +++ Sources/Panels/BrowserPanelView.swift | 50 ++++++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 50 +++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 545cfafa..5ed71a7b 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1430,6 +1430,13 @@ extension BrowserPanel { return true } + /// During split/layout transitions SwiftUI can briefly mark the browser surface hidden + /// while its container is off-window. Avoid detaching in that transient phase if + /// DevTools is intended to remain open, because detach/reattach can blank inspector content. + func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool { + preferredDeveloperToolsVisible + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 7b3c3ea8..c0aeee61 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2554,6 +2554,7 @@ struct WebViewRepresentable: NSViewRepresentable { let isPanelFocused: Bool final class Coordinator { + weak var panel: BrowserPanel? weak var webView: WKWebView? var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 @@ -2585,7 +2586,9 @@ struct WebViewRepresentable: NSViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator() + let coordinator = Coordinator() + coordinator.panel = panel + return coordinator } func makeNSView(context: Context) -> NSView { @@ -2685,12 +2688,29 @@ struct WebViewRepresentable: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let webView = panel.webView + context.coordinator.panel = panel context.coordinator.webView = webView // Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { + // Split/layout churn can briefly create an off-window phase while DevTools is open. + // Detaching here can blank inspector content even when visibility preference stays true. + if nsView.window == nil, + webView.superview != nil, + panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.skipped.offWindowDevTools", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount + ) + #endif + return + } + #if DEBUG Self.logDevToolsState( panel, @@ -2814,6 +2834,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachGeneration += 1 guard let webView = coordinator.webView else { return } + let panel = coordinator.panel // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. @@ -2821,8 +2842,35 @@ struct WebViewRepresentable: NSViewRepresentable { if let window, responderChainContains(window.firstResponder, target: webView) { window.makeFirstResponder(nil) } + + // During split/layout churn, SwiftUI may tear down a host view while a new one is still + // coming online. When DevTools is intended open, avoid eagerly detaching here. + if let panel, + panel.shouldPreserveWebViewAttachmentDuringTransientHide(), + webView.superview === nsView { + #if DEBUG + logDevToolsState( + panel, + event: "dismantle.skipDetach.devTools", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount + ) + #endif + return + } + if webView.superview === nsView { webView.removeFromSuperview() + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.detached", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount + ) + } + #endif } } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5913fd79..a51ba07b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -296,6 +296,56 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertTrue(panel.isDeveloperToolsVisible()) XCTAssertEqual(inspector.showCount, 2) } + + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { + let (panel, _) = makePanelWithInspector() + + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.hideDeveloperTools()) + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } + + func testWebViewDismantleSkipsDetachWhenDeveloperToolsIntentIsVisible() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let representable = WebViewRepresentable( + panel: panel, + shouldAttachWebView: true, + shouldFocusWebView: false, + isPanelFocused: true + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + host.addSubview(panel.webView) + + WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + + XCTAssertTrue(panel.webView.superview === host) + } + + func testWebViewDismantleDetachesWhenDeveloperToolsIntentIsHidden() { + let (panel, _) = makePanelWithInspector() + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + + let representable = WebViewRepresentable( + panel: panel, + shouldAttachWebView: true, + shouldFocusWebView: false, + isPanelFocused: true + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + host.addSubview(panel.webView) + + WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + + XCTAssertNil(panel.webView.superview) + } } final class WorkspaceShortcutMapperTests: XCTestCase { From 6170143b6d9cc8a6532217ab3dbdfa910f1ad4c3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:41:22 -0800 Subject: [PATCH 05/26] Add deep split/devtools reparent diagnostics --- Sources/AppDelegate.swift | 40 +++++++++++-- Sources/Panels/BrowserPanelView.swift | 84 ++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 19 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1eb89d94..8091005e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2050,20 +2050,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case .up: directionLabel = "up" case .down: directionLabel = "down" } + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 + } + if let w = firstResponder as? NSWindow { + return w.windowNumber + } + return -1 + }() + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" if let browser = tabManager?.focusedBrowserPanel { - dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())") + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") } else { - dlog("split.shortcut dir=\(directionLabel) pre panel=nil") + dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") } #endif tabManager?.createSplit(direction: direction) #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 + } + if let w = firstResponder as? NSWindow { + return w.windowNumber + } + return -1 + }() + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" if let browser = self?.tabManager?.focusedBrowserPanel { - dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary())") + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") } else { - dlog("split.shortcut dir=\(directionLabel) post panel=nil") + dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") } } recordGotoSplitSplitIfNeeded(direction: direction) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index c0aeee61..954d3768 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2566,11 +2566,31 @@ struct WebViewRepresentable: NSViewRepresentable { _ panel: BrowserPanel, event: String, generation: Int, - retryCount: Int + retryCount: Int, + details: String? = nil ) { - dlog( - "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" - ) + var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" + if let details, !details.isEmpty { + line += " \(details)" + } + dlog(line) + } + + private static func objectID(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private static func responderDescription(_ responder: NSResponder?) -> String { + guard let responder else { return "nil" } + return "\(type(of: responder))@\(objectID(responder))" + } + + private static func attachContext(webView: WKWebView, host: NSView) -> String { + let hostWindow = host.window?.windowNumber ?? -1 + let webWindow = webView.window?.windowNumber ?? -1 + let firstResponder = (webView.window ?? host.window)?.firstResponder + return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) fr=\(responderDescription(firstResponder))" } #endif @@ -2654,7 +2674,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "retry.waitingForWindow", generation: generation, - retryCount: coordinator.attachRetryCount + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: host) ) } #endif @@ -2675,10 +2696,25 @@ struct WebViewRepresentable: NSViewRepresentable { } coordinator.attachRetryCount = 0 + #if DEBUG + logDevToolsState( + panel, + event: "retry.attach.begin", + generation: generation, + retryCount: 0, + details: attachContext(webView: webView, host: host) + ) + #endif attachWebView(webView, to: host) panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG - logDevToolsState(panel, event: "retry.attached", generation: generation, retryCount: 0) + logDevToolsState( + panel, + event: "retry.attached", + generation: generation, + retryCount: 0, + details: attachContext(webView: webView, host: host) + ) #endif } @@ -2705,7 +2741,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "detach.skipped.offWindowDevTools", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif return @@ -2716,7 +2753,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "detach.beforeSync", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) @@ -2725,7 +2763,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "detach.afterSync", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif context.coordinator.attachRetryWorkItem?.cancel() @@ -2748,7 +2787,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "detach.done", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif return @@ -2768,7 +2808,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "attach.defer.offWindow", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif Self.scheduleAttachRetry( @@ -2779,6 +2820,15 @@ struct WebViewRepresentable: NSViewRepresentable { generation: context.coordinator.attachGeneration ) } else { + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.immediate.begin", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif Self.attachWebView(webView, to: nsView) panel.restoreDeveloperToolsAfterAttachIfNeeded() #if DEBUG @@ -2786,7 +2836,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "attach.immediate", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif } @@ -2802,7 +2853,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "attach.alreadyAttached", generation: context.coordinator.attachGeneration, - retryCount: context.coordinator.attachRetryCount + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) ) #endif } @@ -2853,7 +2905,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "dismantle.skipDetach.devTools", generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) ) #endif return @@ -2867,7 +2920,8 @@ struct WebViewRepresentable: NSViewRepresentable { panel, event: "dismantle.detached", generation: coordinator.attachGeneration, - retryCount: coordinator.attachRetryCount + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) ) } #endif From 54a0f78d75e9e3d579a881aed92854a49e2f38f2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:13:41 -0800 Subject: [PATCH 06/26] Harden browser devtools split reparent flow --- Sources/AppDelegate.swift | 48 +++++++- Sources/Panels/BrowserPanel.swift | 37 +++++- Sources/Panels/BrowserPanelView.swift | 109 ++++++++++++++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 21 ++++ 4 files changed, 204 insertions(+), 11 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8091005e..f4f5a63e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2040,9 +2040,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true + } + node = current.superview + hops += 1 + } + return false + } + + private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { + guard let browser = tabManager?.focusedBrowserPanel else { return } + guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } + guard let keyWindow = NSApp.keyWindow else { return } + guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + + let beforeResponder = keyWindow.firstResponder + let movedToWebView = keyWindow.makeFirstResponder(browser.webView) + let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + browser.requestDeveloperToolsRefreshAfterNextAttach(reason: "split.\(directionLabel).inspectorFirstResponder") + + #if DEBUG + let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" + let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let afterResponder = keyWindow.firstResponder + let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil" + let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog( + "split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " + + "before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " + + "moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())" + ) + #endif + } + @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { - #if DEBUG let directionLabel: String switch direction { case .left: directionLabel = "left" @@ -2050,6 +2093,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent case .up: directionLabel = "up" case .down: directionLabel = "down" } + + #if DEBUG let keyWindow = NSApp.keyWindow let firstResponder = keyWindow?.firstResponder let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" @@ -2073,6 +2118,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) tabManager?.createSplit(direction: direction) #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5ed71a7b..106dc646 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2,6 +2,7 @@ import Foundation import Combine import WebKit import AppKit +import Bonsplit enum BrowserSearchEngine: String, CaseIterable, Identifiable { case google @@ -827,6 +828,7 @@ final class BrowserPanel: Panel, ObservableObject { private let pageZoomStep: CGFloat = 0.1 // Persist user intent across WebKit detach/reattach churn (split/layout updates). private var preferredDeveloperToolsVisible: Bool = false + private var forceDeveloperToolsRefreshOnNextAttach: Bool = false private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 @@ -1322,6 +1324,7 @@ extension BrowserPanel { developerToolsRestoreRetryAttempt = 0 } else { cancelDeveloperToolsRestoreRetry() + forceDeveloperToolsRefreshOnNextAttach = false } return true } @@ -1384,22 +1387,42 @@ extension BrowserPanel { func restoreDeveloperToolsAfterAttachIfNeeded() { guard preferredDeveloperToolsVisible else { cancelDeveloperToolsRestoreRetry() + forceDeveloperToolsRefreshOnNextAttach = false return } guard let inspector = webView.cmuxInspectorObject() else { scheduleDeveloperToolsRestoreRetry() return } + + let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach + forceDeveloperToolsRefreshOnNextAttach = false + + let closeSelector = NSSelectorFromString("close") + if shouldForceRefresh, + inspector.responds(to: closeSelector) { + #if DEBUG + dlog("browser.devtools refresh.forceClose panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + #endif + inspector.cmuxCallVoid(selector: closeSelector) + } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - guard !visible else { + if visible && !shouldForceRefresh { cancelDeveloperToolsRestoreRetry() return } + let selector = NSSelectorFromString("show") guard inspector.responds(to: selector) else { cancelDeveloperToolsRestoreRetry() return } + #if DEBUG + if shouldForceRefresh { + dlog("browser.devtools refresh.forceShow panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + } + #endif inspector.cmuxCallVoid(selector: selector) preferredDeveloperToolsVisible = true let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false @@ -1426,6 +1449,7 @@ extension BrowserPanel { inspector.cmuxCallVoid(selector: selector) } preferredDeveloperToolsVisible = false + forceDeveloperToolsRefreshOnNextAttach = false cancelDeveloperToolsRestoreRetry() return true } @@ -1437,6 +1461,14 @@ extension BrowserPanel { preferredDeveloperToolsVisible } + func requestDeveloperToolsRefreshAfterNextAttach(reason: String) { + guard preferredDeveloperToolsVisible else { return } + forceDeveloperToolsRefreshOnNextAttach = true + #if DEBUG + dlog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())") + #endif + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) @@ -1576,7 +1608,8 @@ extension BrowserPanel { let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1 let attached = webView.superview == nil ? 0 : 1 let inWindow = webView.window == nil ? 0 : 1 - return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt)" + let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 + return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" } } #endif diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 954d3768..dc094f9e 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2605,6 +2605,38 @@ struct WebViewRepresentable: NSViewRepresentable { return false } + private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true + } + node = current.superview + hops += 1 + } + return false + } + + private static func firstResponderResignState( + _ responder: NSResponder?, + webView: WKWebView + ) -> (needsResign: Bool, flags: String) { + let inWebViewChain = responderChainContains(responder, target: webView) + let inspectorResponder = isLikelyInspectorResponder(responder) + let needsResign = inWebViewChain || inspectorResponder + return ( + needsResign: needsResign, + flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)" + ) + } + func makeCoordinator() -> Coordinator { let coordinator = Coordinator() coordinator.panel = panel @@ -2620,9 +2652,20 @@ struct WebViewRepresentable: NSViewRepresentable { private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. - if let window = webView.window, - responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window = webView.window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + window.makeFirstResponder(nil) + } + } + + // The target host can already be in-window while the source host is tearing down. + // Re-check against the target window too (it can differ during split churn). + if let window = host.window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + window.makeFirstResponder(nil) + } } // Detach from any previous host (bonsplit/SwiftUI may rearrange views). @@ -2773,9 +2816,20 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachGeneration += 1 // Resign focus if WebKit currently owns first responder. - if let window = webView.window, - Self.responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window = webView.window ?? nsView.window { + let state = Self.firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.resignFirstResponder", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + ) + #endif + window.makeFirstResponder(nil) + } } if webView.superview != nil { @@ -2800,6 +2854,31 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachGeneration += 1 + if let window = webView.window ?? nsView.window { + let state = Self.firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.reparent.resignFirstResponder.begin", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + ) + #endif + let resigned = window.makeFirstResponder(nil) + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.reparent.resignFirstResponder.end", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + " resigned=\(resigned ? 1 : 0)" + ) + #endif + } + } + if nsView.window == nil { // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI // can create containers that are never inserted into the window. @@ -2891,8 +2970,22 @@ struct WebViewRepresentable: NSViewRepresentable { // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. let window = webView.window ?? nsView.window - if let window, responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.resignFirstResponder", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) + " " + state.flags + ) + } + #endif + window.makeFirstResponder(nil) + } } // During split/layout churn, SwiftUI may tear down a host view while a new one is still diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a51ba07b..45b19aca 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -297,6 +297,27 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } + func testForcedRefreshAfterAttachReopensVisibleInspectorOnce() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.closeCount, 1) + XCTAssertEqual(inspector.showCount, 2) + + // The force-refresh request should be one-shot. + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertEqual(inspector.closeCount, 1) + XCTAssertEqual(inspector.showCount, 2) + } + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { let (panel, _) = makePanelWithInspector() From 943858acff7a358f25106a6d67075fbc0a66efe3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:17:55 -0800 Subject: [PATCH 07/26] Defer and auto-arm devtools refresh across reparent --- Sources/Panels/BrowserPanel.swift | 4 +++ Sources/Panels/BrowserPanelView.swift | 26 ++++++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 13 ++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 106dc646..9cd75c87 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1469,6 +1469,10 @@ extension BrowserPanel { #endif } + func hasPendingDeveloperToolsRefreshAfterAttach() -> Bool { + forceDeveloperToolsRefreshOnNextAttach + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index dc094f9e..50fc3ab3 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2882,6 +2882,18 @@ struct WebViewRepresentable: NSViewRepresentable { if nsView.window == nil { // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI // can create containers that are never inserted into the window. + if panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "attach.defer.offWindow") + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.defer.requestRefresh", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif + } #if DEBUG Self.logDevToolsState( panel, @@ -2926,7 +2938,19 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 - panel.restoreDeveloperToolsAfterAttachIfNeeded() + if panel.hasPendingDeveloperToolsRefreshAfterAttach() { + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.alreadyAttached.deferPendingRefresh", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif + } else { + panel.restoreDeveloperToolsAfterAttachIfNeeded() + } #if DEBUG Self.logDevToolsState( panel, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 45b19aca..f16b434a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -318,6 +318,19 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } + func testRefreshRequestTracksPendingStateUntilRestoreRuns() { + let (panel, _) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") + XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + } + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { let (panel, _) = makePanelWithInspector() From db66fc4bb0d9339df38b00004aedd22f825a8403 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:19:00 -0800 Subject: [PATCH 08/26] Stabilize browser portal bounds and log inspector height metrics --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/AppDelegate.swift | 1 - Sources/BrowserWindowPortal.swift | 867 ++++++++++++++++++ Sources/Panels/BrowserPanel.swift | 22 +- Sources/Panels/BrowserPanelView.swift | 204 ++++- Sources/Panels/PanelContentView.swift | 1 + cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 232 ++++- 7 files changed, 1297 insertions(+), 34 deletions(-) create mode 100644 Sources/BrowserWindowPortal.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d69a5be5..4777e4cc 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; + A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; @@ -135,6 +136,7 @@ A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; + A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; @@ -309,6 +311,7 @@ A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, A5001531 /* TerminalWindowPortal.swift */, + A5001533 /* BrowserWindowPortal.swift */, A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, @@ -535,6 +538,7 @@ A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, + A5001534 /* BrowserWindowPortal.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f4f5a63e..cc5e863b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2068,7 +2068,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let beforeResponder = keyWindow.firstResponder let movedToWebView = keyWindow.makeFirstResponder(browser.webView) let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) - browser.requestDeveloperToolsRefreshAfterNextAttach(reason: "split.\(directionLabel).inspectorFirstResponder") #if DEBUG let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift new file mode 100644 index 00000000..726a90d7 --- /dev/null +++ b/Sources/BrowserWindowPortal.swift @@ -0,0 +1,867 @@ +import AppKit +import ObjectiveC +import WebKit +#if DEBUG +import Bonsplit +#endif + +private var cmuxWindowBrowserPortalKey: UInt8 = 0 +private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0 + +#if DEBUG +private func browserPortalDebugToken(_ view: NSView?) -> String { + guard let view else { return "nil" } + let ptr = Unmanaged.passUnretained(view).toOpaque() + return String(describing: ptr) +} + +private func browserPortalDebugFrame(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) +} +#endif + +final class WindowBrowserHostView: NSView { + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + if shouldPassThroughToSplitDivider(at: point) { + return nil + } + let hitView = super.hitTest(point) + return hitView === self ? nil : hitView + } + + private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { + guard let window else { return false } + let windowPoint = convert(point, to: nil) + guard let rootView = window.contentView else { return false } + return Self.containsSplitDivider(at: windowPoint, in: rootView) + } + + private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool { + guard !view.isHidden else { return false } + + if let splitView = view as? NSSplitView { + let pointInSplit = splitView.convert(windowPoint, from: nil) + if splitView.bounds.contains(pointInSplit) { + let expansion: CGFloat = 5 + let dividerCount = max(0, splitView.arrangedSubviews.count - 1) + for dividerIndex in 0.. 1, second.width > 1 else { continue } + let x = max(0, first.maxX) + dividerRect = NSRect( + x: x, + y: 0, + width: thickness, + height: splitView.bounds.height + ) + } else { + guard first.height > 1, second.height > 1 else { continue } + let y = max(0, first.maxY) + dividerRect = NSRect( + x: 0, + y: y, + width: splitView.bounds.width, + height: thickness + ) + } + let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion) + if expanded.contains(pointInSplit) { + return true + } + } + } + } + + for subview in view.subviews.reversed() { + if containsSplitDivider(at: windowPoint, in: subview) { + return true + } + } + + return false + } +} + +final class WindowBrowserSlotView: NSView { + override var isOpaque: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + translatesAutoresizingMaskIntoConstraints = true + autoresizingMask = [] + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } +} + +@MainActor +final class WindowBrowserPortal: NSObject { + private weak var window: NSWindow? + private let hostView = WindowBrowserHostView(frame: .zero) + private weak var installedContainerView: NSView? + private weak var installedReferenceView: NSView? + private var hasDeferredFullSyncScheduled = false + + private struct Entry { + weak var webView: WKWebView? + weak var containerView: WindowBrowserSlotView? + weak var anchorView: NSView? + var visibleInUI: Bool + var zPriority: Int + } + + private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:] + private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:] + + init(window: NSWindow) { + self.window = window + super.init() + hostView.wantsLayer = true + hostView.layer?.masksToBounds = true + hostView.translatesAutoresizingMaskIntoConstraints = true + hostView.autoresizingMask = [] + _ = ensureInstalled() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window else { return false } + guard let (container, reference) = installationTarget(for: window) else { return false } + + if hostView.superview !== container || + installedContainerView !== container || + installedReferenceView !== reference { + hostView.removeFromSuperview() + container.addSubview(hostView, positioned: .above, relativeTo: reference) + installedContainerView = container + installedReferenceView = reference + } else if !Self.isView(hostView, above: reference, in: container) { + container.addSubview(hostView, positioned: .above, relativeTo: reference) + } + + synchronizeHostFrameToReference() + return true + } + + @discardableResult + private func synchronizeHostFrameToReference() -> Bool { + guard let container = installedContainerView, + let reference = installedReferenceView else { + return false + } + let frameInContainer = container.convert(reference.bounds, from: reference) + let hasFiniteFrame = + frameInContainer.origin.x.isFinite && + frameInContainer.origin.y.isFinite && + frameInContainer.size.width.isFinite && + frameInContainer.size.height.isFinite + guard hasFiniteFrame else { return false } + + if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostView.frame = frameInContainer + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " + + "frame=\(browserPortalDebugFrame(frameInContainer))" + ) +#endif + } + return frameInContainer.width > 1 && frameInContainer.height > 1 + } + + private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { + guard let contentView = window.contentView else { return nil } + + if contentView.className == "NSGlassEffectView", + let foreground = contentView.subviews.first(where: { $0 !== hostView }) { + return (contentView, foreground) + } + + guard let themeFrame = contentView.superview else { return nil } + return (themeFrame, contentView) + } + + private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool { + if view.isHidden { return true } + var current = view.superview + while let v = current { + if v.isHidden { return true } + current = v.superview + } + return false + } + + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } + + private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { + frame.minX < bounds.minX - epsilon || + frame.minY < bounds.minY - epsilon || + frame.maxX > bounds.maxX + epsilon || + frame.maxY > bounds.maxY + epsilon + } + +#if DEBUG + private static func inspectorSubviewCount(in root: NSView) -> Int { + var stack: [NSView] = [root] + var count = 0 + while let current = stack.popLast() { + for subview in current.subviews { + if String(describing: type(of: subview)).contains("WKInspector") { + count += 1 + } + stack.append(subview) + } + } + return count + } +#endif + + private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool { + guard let viewIndex = container.subviews.firstIndex(of: view), + let referenceIndex = container.subviews.firstIndex(of: reference) else { + return false + } + return viewIndex > referenceIndex + } + + private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView { + if let existing = entry.containerView { + return existing + } + let created = WindowBrowserSlotView(frame: .zero) +#if DEBUG + dlog( + "browser.portal.container.create web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(created))" + ) +#endif + return created + } + + private func moveWebKitRelatedSubviewsIfNeeded( + from sourceSuperview: NSView, + to containerView: WindowBrowserSlotView, + primaryWebView: WKWebView, + reason: String + ) { + guard sourceSuperview !== containerView else { return } + // When Web Inspector is docked, WebKit can inject companion WK* subviews + // next to the primary WKWebView. Move those with the web view so inspector + // UI state does not get orphaned in the old host during split churn. + let relatedSubviews = sourceSuperview.subviews.filter { view in + if view === primaryWebView { return true } + return String(describing: type(of: view)).contains("WK") + } + guard !relatedSubviews.isEmpty else { return } +#if DEBUG + dlog( + "browser.portal.reparent.batch reason=\(reason) source=\(browserPortalDebugToken(sourceSuperview)) " + + "container=\(browserPortalDebugToken(containerView)) count=\(relatedSubviews.count) " + + "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: containerView))) " + + "sourceFlipped=\(sourceSuperview.isFlipped ? 1 : 0) targetFlipped=\(containerView.isFlipped ? 1 : 0) " + + "sourceBounds=\(browserPortalDebugFrame(sourceSuperview.bounds)) targetBounds=\(browserPortalDebugFrame(containerView.bounds))" + ) +#endif + for view in relatedSubviews { + let frameInWindow = sourceSuperview.convert(view.frame, to: nil) + let className = String(describing: type(of: view)) + view.removeFromSuperview() + containerView.addSubview(view, positioned: .above, relativeTo: nil) + let convertedFrame = containerView.convert(frameInWindow, from: nil) + view.frame = convertedFrame +#if DEBUG + dlog( + "browser.portal.reparent.batch.item reason=\(reason) class=\(className) " + + "view=\(browserPortalDebugToken(view)) frameInWindow=\(browserPortalDebugFrame(frameInWindow)) " + + "converted=\(browserPortalDebugFrame(convertedFrame))" + ) +#endif + } + } + + func detachWebView(withId webViewId: ObjectIdentifier) { + guard let entry = entriesByWebViewId.removeValue(forKey: webViewId) else { return } + if let anchor = entry.anchorView { + webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } +#if DEBUG + let hadContainerSuperview = (entry.containerView?.superview === hostView) ? 1 : 0 + let hadWebSuperview = entry.webView?.superview == nil ? 0 : 1 + dlog( + "browser.portal.detach web=\(browserPortalDebugToken(entry.webView)) " + + "container=\(browserPortalDebugToken(entry.containerView)) " + + "anchor=\(browserPortalDebugToken(entry.anchorView)) " + + "hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)" + ) +#endif + entry.webView?.removeFromSuperview() + entry.containerView?.removeFromSuperview() + } + + func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { + guard ensureInstalled() else { return } + + let webViewId = ObjectIdentifier(webView) + let anchorId = ObjectIdentifier(anchorView) + let previousEntry = entriesByWebViewId[webViewId] + let containerView = ensureContainerView( + for: previousEntry ?? Entry(webView: nil, containerView: nil, anchorView: nil, visibleInUI: false, zPriority: 0), + webView: webView + ) + + if let previousWebViewId = webViewByAnchorId[anchorId], previousWebViewId != webViewId { +#if DEBUG + let previousToken = entriesByWebViewId[previousWebViewId] + .map { browserPortalDebugToken($0.webView) } + ?? String(describing: previousWebViewId) + dlog( + "browser.portal.bind.replace anchor=\(browserPortalDebugToken(anchorView)) " + + "oldWeb=\(previousToken) newWeb=\(browserPortalDebugToken(webView))" + ) +#endif + detachWebView(withId: previousWebViewId) + } + + if let oldEntry = entriesByWebViewId[webViewId], + let oldAnchor = oldEntry.anchorView, + oldAnchor !== anchorView { + webViewByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor)) + } + + webViewByAnchorId[anchorId] = webViewId + entriesByWebViewId[webViewId] = Entry( + webView: webView, + containerView: containerView, + anchorView: anchorView, + visibleInUI: visibleInUI, + zPriority: zPriority + ) + + let didChangeAnchor: Bool = { + guard let previousAnchor = previousEntry?.anchorView else { return true } + return previousAnchor !== anchorView + }() + let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI + let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min) +#if DEBUG + if previousEntry == nil || + didChangeAnchor || + becameVisible || + priorityIncreased || + webView.superview !== containerView || + containerView.superview !== hostView { + dlog( + "browser.portal.bind web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) " + + "anchor=\(browserPortalDebugToken(anchorView)) prevAnchor=\(browserPortalDebugToken(previousEntry?.anchorView)) " + + "visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " + + "z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)" + ) + } +#endif + + if webView.superview !== containerView { +#if DEBUG + dlog( + "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + + "reason=attachContainer super=\(browserPortalDebugToken(webView.superview)) " + + "container=\(browserPortalDebugToken(containerView))" + ) +#endif + if let sourceSuperview = webView.superview { + moveWebKitRelatedSubviewsIfNeeded( + from: sourceSuperview, + to: containerView, + primaryWebView: webView, + reason: "bind.attachContainer" + ) + } else { + containerView.addSubview(webView, positioned: .above, relativeTo: nil) + } + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = containerView.bounds + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() + } + + if containerView.superview !== hostView { +#if DEBUG + dlog( + "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " + + "reason=attach super=\(browserPortalDebugToken(containerView.superview))" + ) +#endif + hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + } else if (becameVisible || priorityIncreased), hostView.subviews.last !== containerView { +#if DEBUG + dlog( + "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) reason=raise " + + "didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " + + "priorityIncreased=\(priorityIncreased ? 1 : 0)" + ) +#endif + hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + } + + synchronizeWebView(withId: webViewId, source: "bind") + pruneDeadEntries() + } + + func synchronizeWebViewForAnchor(_ anchorView: NSView) { + pruneDeadEntries() + let anchorId = ObjectIdentifier(anchorView) + let primaryWebViewId = webViewByAnchorId[anchorId] + if let primaryWebViewId { + synchronizeWebView(withId: primaryWebViewId, source: "anchorPrimary") + } + + synchronizeAllWebViews(excluding: primaryWebViewId, source: "anchorSecondary") + scheduleDeferredFullSynchronizeAll() + } + + private func scheduleDeferredFullSynchronizeAll() { + guard !hasDeferredFullSyncScheduled else { return } + hasDeferredFullSyncScheduled = true +#if DEBUG + dlog("browser.portal.sync.defer.schedule entries=\(entriesByWebViewId.count)") +#endif + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasDeferredFullSyncScheduled = false +#if DEBUG + dlog("browser.portal.sync.defer.tick entries=\(self.entriesByWebViewId.count)") +#endif + self.synchronizeAllWebViews(excluding: nil, source: "deferredTick") + } + } + + private func synchronizeAllWebViews(excluding webViewIdToSkip: ObjectIdentifier?, source: String) { + guard ensureInstalled() else { return } + pruneDeadEntries() + let webViewIds = Array(entriesByWebViewId.keys) + for webViewId in webViewIds { + if webViewId == webViewIdToSkip { continue } + synchronizeWebView(withId: webViewId, source: source) + } + } + + private func synchronizeWebView(withId webViewId: ObjectIdentifier, source: String) { + guard ensureInstalled() else { return } + guard let entry = entriesByWebViewId[webViewId] else { return } + guard let webView = entry.webView else { + entriesByWebViewId.removeValue(forKey: webViewId) + return + } + guard let containerView = entry.containerView else { + entriesByWebViewId.removeValue(forKey: webViewId) + if let anchor = entry.anchorView { + webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } + return + } + guard let anchorView = entry.anchorView, let window else { +#if DEBUG + if !containerView.isHidden { + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=1 reason=missingAnchorOrWindow" + ) + } +#endif + containerView.isHidden = true + return + } + guard anchorView.window === window else { +#if DEBUG + if !containerView.isHidden { + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=1 " + + "reason=anchorWindowMismatch anchorWindow=\(browserPortalDebugToken(anchorView.window?.contentView))" + ) + } +#endif + containerView.isHidden = true + return + } + + if containerView.superview !== hostView { +#if DEBUG + dlog( + "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " + + "reason=syncAttach super=\(browserPortalDebugToken(containerView.superview))" + ) +#endif + hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + } + if webView.superview !== containerView { +#if DEBUG + dlog( + "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + + "reason=syncAttachContainer super=\(browserPortalDebugToken(webView.superview)) " + + "container=\(browserPortalDebugToken(containerView))" + ) +#endif + if let sourceSuperview = webView.superview { + moveWebKitRelatedSubviewsIfNeeded( + from: sourceSuperview, + to: containerView, + primaryWebView: webView, + reason: "sync.attachContainer" + ) + } else { + containerView.addSubview(webView, positioned: .above, relativeTo: nil) + } + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = containerView.bounds + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() + } + + _ = synchronizeHostFrameToReference() + let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + let frameInHost = hostView.convert(frameInWindow, from: nil) + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 + if !hostBoundsReady { +#if DEBUG + dlog( + "browser.portal.sync.defer container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) " + + "reason=hostBoundsNotReady host=\(browserPortalDebugFrame(hostBounds)) " + + "anchor=\(browserPortalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" + ) +#endif + containerView.isHidden = true + scheduleDeferredFullSynchronizeAll() + return + } + let oldFrame = containerView.frame + let hasFiniteFrame = + frameInHost.origin.x.isFinite && + frameInHost.origin.y.isFinite && + frameInHost.size.width.isFinite && + frameInHost.size.height.isFinite + let clampedFrame = frameInHost.intersection(hostBounds) + let hasVisibleIntersection = + !clampedFrame.isNull && + clampedFrame.width > 1 && + clampedFrame.height > 1 + let targetFrame = hasVisibleIntersection ? clampedFrame : frameInHost + let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) + let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1 + let outsideHostBounds = !hasVisibleIntersection + let shouldHide = + !entry.visibleInUI || + anchorHidden || + tinyFrame || + !hasFiniteFrame || + outsideHostBounds +#if DEBUG + let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) + if frameWasClamped { + dlog( + "browser.portal.frame.clamp container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + + "raw=\(browserPortalDebugFrame(frameInHost)) clamped=\(browserPortalDebugFrame(targetFrame)) " + + "host=\(browserPortalDebugFrame(hostBounds))" + ) + } + let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame + let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame + if collapsedToTiny { + dlog( + "browser.portal.frame.collapse container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + + "old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))" + ) + } else if restoredFromTiny { + dlog( + "browser.portal.frame.restore container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + + "old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))" + ) + } +#endif + if !Self.rectApproximatelyEqual(oldFrame, targetFrame) { + CATransaction.begin() + CATransaction.setDisableActions(true) + containerView.frame = targetFrame + CATransaction.commit() + } + + let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) + if !Self.rectApproximatelyEqual(containerView.bounds, expectedContainerBounds) { + let oldContainerBounds = containerView.bounds + CATransaction.begin() + CATransaction.setDisableActions(true) + containerView.bounds = expectedContainerBounds + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.bounds.normalize container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) old=\(browserPortalDebugFrame(oldContainerBounds)) " + + "target=\(browserPortalDebugFrame(expectedContainerBounds))" + ) +#endif + } + + let containerBounds = containerView.bounds + let preNormalizeWebFrame = webView.frame + let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height) + let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY) + let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow) +#if DEBUG + let inspectorSubviews = Self.inspectorSubviewCount(in: containerView) +#endif + if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) { + let oldWebFrame = preNormalizeWebFrame + CATransaction.begin() + CATransaction.setDisableActions(true) + webView.frame = containerBounds + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.webframe.normalize web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " + + "new=\(browserPortalDebugFrame(webView.frame)) bounds=\(browserPortalDebugFrame(containerBounds)) " + + "inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " + + "inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " + + "inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " + + "inspectorSubviews=\(inspectorSubviews) " + + "source=\(source)" + ) +#endif + } + + if containerView.isHidden != shouldHide { +#if DEBUG + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " + + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " + + "host=\(browserPortalDebugFrame(hostBounds))" + ) +#endif + containerView.isHidden = shouldHide + } +#if DEBUG + dlog( + "browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " + + "container=\(browserPortalDebugToken(containerView)) " + + "anchor=\(browserPortalDebugToken(anchorView)) host=\(browserPortalDebugToken(hostView)) " + + "hostWin=\(hostView.window?.windowNumber ?? -1) " + + "old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " + + "target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + + "entryVisible=\(entry.visibleInUI ? 1 : 0) " + + "containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " + + "containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " + + "preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " + + "webFrame=\(browserPortalDebugFrame(webView.frame)) webBounds=\(browserPortalDebugFrame(webView.bounds)) " + + "inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " + + "inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " + + "inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " + + "inspectorSubviews=\(inspectorSubviews)" + ) +#endif + } + + private func pruneDeadEntries() { + let currentWindow = window + let deadWebViewIds = entriesByWebViewId.compactMap { webViewId, entry -> ObjectIdentifier? in + guard entry.webView != nil else { return webViewId } + guard let container = entry.containerView else { return webViewId } + guard let anchor = entry.anchorView else { return webViewId } + if container.superview == nil || !container.isDescendant(of: hostView) { + return webViewId + } + if anchor.window !== currentWindow || anchor.superview == nil { + return webViewId + } + if let reference = installedReferenceView, + !anchor.isDescendant(of: reference) { + return webViewId + } + return nil + } + + for webViewId in deadWebViewIds { + detachWebView(withId: webViewId) + } + + let validAnchorIds = Set(entriesByWebViewId.compactMap { _, entry in + entry.anchorView.map { ObjectIdentifier($0) } + }) + webViewByAnchorId = webViewByAnchorId.filter { validAnchorIds.contains($0.key) } + } + + func webViewIds() -> Set { + Set(entriesByWebViewId.keys) + } + + func tearDown() { + for webViewId in Array(entriesByWebViewId.keys) { + detachWebView(withId: webViewId) + } + hostView.removeFromSuperview() + installedContainerView = nil + installedReferenceView = nil + } + +#if DEBUG + func debugEntryCount() -> Int { + entriesByWebViewId.count + } + + func debugHostedSubviewCount() -> Int { + hostView.subviews.count + } +#endif + + func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? { + guard ensureInstalled() else { return nil } + let point = hostView.convert(windowPoint, from: nil) + for subview in hostView.subviews.reversed() { + guard let container = subview as? WindowBrowserSlotView else { continue } + guard !container.isHidden else { continue } + guard container.frame.contains(point) else { continue } + guard let webView = entriesByWebViewId + .first(where: { _, entry in entry.containerView === container })? + .value + .webView else { continue } + return webView + } + return nil + } +} + +@MainActor +enum BrowserWindowPortalRegistry { + private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] + private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + + private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { + guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return } + let windowId = ObjectIdentifier(window) + let observer = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak window] _ in + MainActor.assumeIsolated { + if let window { + removePortal(for: window) + } else { + removePortal(windowId: windowId, window: nil) + } + } + } + objc_setAssociatedObject( + window, + &cmuxWindowBrowserPortalCloseObserverKey, + observer, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + private static func removePortal(for window: NSWindow) { + removePortal(windowId: ObjectIdentifier(window), window: window) + } + + private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) { + if let portal = portalsByWindowId.removeValue(forKey: windowId) { + portal.tearDown() + } + webViewToWindowId = webViewToWindowId.filter { $0.value != windowId } + + guard let window else { return } + if let observer = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) { + NotificationCenter.default.removeObserver(observer) + } + objc_setAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, nil, .OBJC_ASSOCIATION_RETAIN) + } + + private static func pruneWebViewMappings(for windowId: ObjectIdentifier, validWebViewIds: Set) { + webViewToWindowId = webViewToWindowId.filter { webViewId, mappedWindowId in + mappedWindowId != windowId || validWebViewIds.contains(webViewId) + } + } + + private static func portal(for window: NSWindow) -> WindowBrowserPortal { + if let existing = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalKey) as? WindowBrowserPortal { + portalsByWindowId[ObjectIdentifier(window)] = existing + installWindowCloseObserverIfNeeded(for: window) + return existing + } + + let portal = WindowBrowserPortal(window: window) + objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, portal, .OBJC_ASSOCIATION_RETAIN) + portalsByWindowId[ObjectIdentifier(window)] = portal + installWindowCloseObserverIfNeeded(for: window) + return portal + } + + static func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { + guard let window = anchorView.window else { return } + + let windowId = ObjectIdentifier(window) + let webViewId = ObjectIdentifier(webView) + let nextPortal = portal(for: window) + + if let oldWindowId = webViewToWindowId[webViewId], + oldWindowId != windowId { + portalsByWindowId[oldWindowId]?.detachWebView(withId: webViewId) + } + + nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) + webViewToWindowId[webViewId] = windowId + pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds()) + } + + static func synchronizeForAnchor(_ anchorView: NSView) { + guard let window = anchorView.window else { return } + let portal = portal(for: window) + portal.synchronizeWebViewForAnchor(anchorView) + } + + static func detach(webView: WKWebView) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } + portalsByWindowId[windowId]?.detachWebView(withId: webViewId) + } + +#if DEBUG + static func debugPortalCount() -> Int { + portalsByWindowId.count + } +#endif +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9cd75c87..628931d6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1244,6 +1244,10 @@ final class BrowserPanel: Panel, ObservableObject { deinit { developerToolsRestoreRetryWorkItem?.cancel() developerToolsRestoreRetryWorkItem = nil + let webView = webView + Task { @MainActor in + BrowserWindowPortalRegistry.detach(webView: webView) + } webViewObservers.removeAll() } } @@ -1398,17 +1402,13 @@ extension BrowserPanel { let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach forceDeveloperToolsRefreshOnNextAttach = false - let closeSelector = NSSelectorFromString("close") - if shouldForceRefresh, - inspector.responds(to: closeSelector) { - #if DEBUG - dlog("browser.devtools refresh.forceClose panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") - #endif - inspector.cmuxCallVoid(selector: closeSelector) - } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if visible && !shouldForceRefresh { + if visible { + #if DEBUG + if shouldForceRefresh { + dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + } + #endif cancelDeveloperToolsRestoreRetry() return } @@ -1420,7 +1420,7 @@ extension BrowserPanel { } #if DEBUG if shouldForceRefresh { - dlog("browser.devtools refresh.forceShow panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") } #endif inspector.cmuxCallVoid(selector: selector) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 50fc3ab3..1d849576 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -127,6 +127,7 @@ struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel let isFocused: Bool let isVisibleInUI: Bool + let portalPriority: Int let onRequestPanelFocus: () -> Void @State private var omnibarState = OmnibarState() @State private var addressBarFocused: Bool = false @@ -496,7 +497,8 @@ struct BrowserPanelView: View { panel: panel, shouldAttachWebView: isVisibleInUI, shouldFocusWebView: isFocused && !addressBarFocused, - isPanelFocused: isFocused + isPanelFocused: isFocused, + portalZPriority: portalPriority ) // Keep the representable identity stable across bonsplit structural updates. // This reduces WKWebView reparenting churn (and the associated WebKit crashes). @@ -2552,6 +2554,7 @@ struct WebViewRepresentable: NSViewRepresentable { let shouldAttachWebView: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool + let portalZPriority: Int final class Coordinator { weak var panel: BrowserPanel? @@ -2559,6 +2562,41 @@ struct WebViewRepresentable: NSViewRepresentable { var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 var attachGeneration: Int = 0 + var usesWindowPortal: Bool = false + var desiredPortalVisibleInUI: Bool = true + var desiredPortalZPriority: Int = 0 + var lastPortalHostId: ObjectIdentifier? + } + + private final class HostContainerView: NSView { + var onDidMoveToWindow: (() -> Void)? + var onGeometryChanged: (() -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + onDidMoveToWindow?() + onGeometryChanged?() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + onGeometryChanged?() + } + + override func layout() { + super.layout() + onGeometryChanged?() + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + onGeometryChanged?() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + onGeometryChanged?() + } } #if DEBUG @@ -2586,11 +2624,15 @@ struct WebViewRepresentable: NSViewRepresentable { return "\(type(of: responder))@\(objectID(responder))" } + private static func rectDescription(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) + } + private static func attachContext(webView: WKWebView, host: NSView) -> String { let hostWindow = host.window?.windowNumber ?? -1 let webWindow = webView.window?.windowNumber ?? -1 let firstResponder = (webView.window ?? host.window)?.firstResponder - return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) fr=\(responderDescription(firstResponder))" + return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))" } #endif @@ -2644,11 +2686,85 @@ struct WebViewRepresentable: NSViewRepresentable { } func makeNSView(context: Context) -> NSView { - let container = NSView() + let container = HostContainerView() container.wantsLayer = true return container } + private static func clearPortalCallbacks(for host: NSView) { + guard let host = host as? HostContainerView else { return } + host.onDidMoveToWindow = nil + host.onGeometryChanged = nil + } + + private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { + guard let host = nsView as? HostContainerView else { return } + + let coordinator = context.coordinator + let previousVisible = coordinator.desiredPortalVisibleInUI + let previousZPriority = coordinator.desiredPortalZPriority + coordinator.desiredPortalVisibleInUI = shouldAttachWebView + coordinator.desiredPortalZPriority = portalZPriority + coordinator.attachGeneration += 1 + let generation = coordinator.attachGeneration + + host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in + guard let host, let webView, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard host.window != nil else { return } + BrowserWindowPortalRegistry.bind( + webView: webView, + to: host, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + coordinator.lastPortalHostId = ObjectIdentifier(host) + } + host.onGeometryChanged = { [weak host, weak coordinator] in + guard let host, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return } + BrowserWindowPortalRegistry.synchronizeForAnchor(host) + } + + if !shouldAttachWebView { + // In portal mode we no longer detach/re-attach to preserve DevTools state. + // Sync the inspector preference directly so manual closes are respected. + panel.syncDeveloperToolsPreferenceFromInspector() + } + + if host.window != nil { + let hostId = ObjectIdentifier(host) + let shouldBindNow = + coordinator.lastPortalHostId != hostId || + webView.superview == nil || + previousVisible != shouldAttachWebView || + previousZPriority != portalZPriority + if shouldBindNow { + BrowserWindowPortalRegistry.bind( + webView: webView, + to: host, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + coordinator.lastPortalHostId = hostId + } + BrowserWindowPortalRegistry.synchronizeForAnchor(host) + } + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + #if DEBUG + Self.logDevToolsState( + panel, + event: "portal.update", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: host) + ) + #endif + } + private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. @@ -2770,6 +2886,28 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.panel = panel context.coordinator.webView = webView + let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide() + if shouldUseWindowPortal { + context.coordinator.usesWindowPortal = true + Self.clearPortalCallbacks(for: nsView) + updateUsingWindowPortal(nsView, context: context, webView: webView) + Self.applyFocus( + panel: panel, + webView: webView, + nsView: nsView, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused + ) + return + } + + if context.coordinator.usesWindowPortal { + BrowserWindowPortalRegistry.detach(webView: webView) + context.coordinator.usesWindowPortal = false + context.coordinator.lastPortalHostId = nil + } + Self.clearPortalCallbacks(for: nsView) + // Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. @@ -2938,20 +3076,18 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 - if panel.hasPendingDeveloperToolsRefreshAfterAttach() { - #if DEBUG + let hadPendingRefresh = panel.hasPendingDeveloperToolsRefreshAfterAttach() + panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + if hadPendingRefresh { Self.logDevToolsState( panel, - event: "attach.alreadyAttached.deferPendingRefresh", + event: "attach.alreadyAttached.consumePendingRefresh", generation: context.coordinator.attachGeneration, retryCount: context.coordinator.attachRetryCount, details: Self.attachContext(webView: webView, host: nsView) ) - #endif - } else { - panel.restoreDeveloperToolsAfterAttachIfNeeded() } - #if DEBUG Self.logDevToolsState( panel, event: "attach.alreadyAttached", @@ -2962,23 +3098,37 @@ struct WebViewRepresentable: NSViewRepresentable { #endif } + Self.applyFocus( + panel: panel, + webView: webView, + nsView: nsView, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused + ) + } + + private static func applyFocus( + panel: BrowserPanel, + webView: WKWebView, + nsView: NSView, + shouldFocusWebView: Bool, + isPanelFocused: Bool + ) { // Focus handling. Avoid fighting the address bar when it is focused. guard let window = nsView.window else { return } if shouldFocusWebView { if panel.shouldSuppressWebViewFocus() { return } - if Self.responderChainContains(window.firstResponder, target: webView) { + if responderChainContains(window.firstResponder, target: webView) { return } window.makeFirstResponder(webView) - } else { + } else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) { // Only force-resign WebView focus when this panel itself is not focused. // If the panel is focused but the omnibar-focus state is briefly stale, aggressively // clearing first responder here can undo programmatic webview focus (socket tests). - if !isPanelFocused && Self.responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) - } + window.makeFirstResponder(nil) } } @@ -2987,10 +3137,34 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachRetryWorkItem = nil coordinator.attachRetryCount = 0 coordinator.attachGeneration += 1 + clearPortalCallbacks(for: nsView) guard let webView = coordinator.webView else { return } let panel = coordinator.panel + if coordinator.usesWindowPortal { + coordinator.usesWindowPortal = false + coordinator.lastPortalHostId = nil + + // During split/layout churn we keep the WKWebView portal-hosted so DevTools + // does not lose state. BrowserPanel deinit explicitly detaches on real teardown. + if let panel, panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + #if DEBUG + logDevToolsState( + panel, + event: "dismantle.portal.keepAttached", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) + ) + #endif + return + } + + BrowserWindowPortalRegistry.detach(webView: webView) + return + } + // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. let window = webView.window ?? nsView.window diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 9049e166..f4c16fbc 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -37,6 +37,7 @@ struct PanelContentView: View { panel: browserPanel, isFocused: isFocused, isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, onRequestPanelFocus: onRequestPanelFocus ) } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index f16b434a..af8ae749 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -297,7 +297,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertEqual(inspector.showCount, 2) } - func testForcedRefreshAfterAttachReopensVisibleInspectorOnce() { + func testForcedRefreshAfterAttachKeepsVisibleInspectorState() { let (panel, inspector) = makePanelWithInspector() XCTAssertTrue(panel.showDeveloperTools()) @@ -309,13 +309,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { panel.restoreDeveloperToolsAfterAttachIfNeeded() XCTAssertTrue(panel.isDeveloperToolsVisible()) - XCTAssertEqual(inspector.closeCount, 1) - XCTAssertEqual(inspector.showCount, 2) + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) // The force-refresh request should be one-shot. panel.restoreDeveloperToolsAfterAttachIfNeeded() - XCTAssertEqual(inspector.closeCount, 1) - XCTAssertEqual(inspector.showCount, 2) + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) } func testRefreshRequestTracksPendingStateUntilRestoreRuns() { @@ -349,7 +349,8 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { panel: panel, shouldAttachWebView: true, shouldFocusWebView: false, - isPanelFocused: true + isPanelFocused: true, + portalZPriority: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -369,7 +370,8 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { panel: panel, shouldAttachWebView: true, shouldFocusWebView: false, - isPanelFocused: true + isPanelFocused: true, + portalZPriority: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -2450,3 +2452,219 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { ) } } + +@MainActor +final class BrowserWindowPortalLifecycleTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + + func testPortalHostInstallsAboveContentViewForVisibility() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowBrowserPortal(window: window) + _ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), + let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else { + XCTFail("Expected host/content views in same container") + return + } + + XCTAssertGreaterThan( + hostIndex, + contentIndex, + "Browser portal host must remain above content view so portal-hosted web views stay visible" + ) + } + + func testAnchorRebindKeepsWebViewInStablePortalSuperview() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120)) + contentView.addSubview(anchor1) + contentView.addSubview(anchor2) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor1, visibleInUI: true) + let firstSuperview = webView.superview + + XCTAssertNotNil(firstSuperview) + XCTAssertTrue(firstSuperview is WindowBrowserSlotView) + + portal.bind(webView: webView, to: anchor2, visibleInUI: true) + XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view") + + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor2) + guard let slot = webView.superview as? WindowBrowserSlotView, + let host = slot.superview as? WindowBrowserHostView else { + XCTFail("Expected browser slot + host views") + return + } + let expectedFrame = host.convert(anchor2.bounds, from: anchor2) + XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5) + } + + func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + // Simulate a transient oversized anchor rect during split churn. + let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected web view slot") + return + } + + XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible") + XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5) + } + + func testPortalSyncNormalizesOutOfBoundsWebFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + // Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds. + webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height) + XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY) + + portal.synchronizeWebViewForAnchor(anchor) + XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5) + XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5) + XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5) + XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5) + } + + func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView, + let host = slot.superview as? WindowBrowserHostView else { + XCTFail("Expected portal slot + host views") + return + } + XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync") + XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync") + } + + func testRegistryDetachRemovesPortalHostedWebView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + contentView.addSubview(anchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + XCTAssertNotNil(webView.superview) + + BrowserWindowPortalRegistry.detach(webView: webView) + XCTAssertNil(webView.superview) + } +} From 153b73c952a4a69129b1bcb7fd0c4ca82c244fa3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:04:16 -0800 Subject: [PATCH 09/26] Add earlier devtools shortcut snapshots and geometry probes --- Sources/AppDelegate.swift | 74 +++++++++++++++++++++++++++++++ Sources/Panels/BrowserPanel.swift | 60 +++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cc5e863b..94fbcfaf 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1442,6 +1442,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } 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")") + if let probeKind = self.developerToolsShortcutProbeKind(event: event) { + self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) + } #endif if self.handleCustomShortcut(event: event) { #if DEBUG @@ -1882,13 +1885,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // - Option+Command+I => Show/Toggle Web Inspector // - Option+Command+C => Show JavaScript Console if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) +#endif let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle) + } +#endif if !didHandle { NSSound.beep() } return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) +#endif let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle) + } +#endif if !didHandle { NSSound.beep() } return true } @@ -2059,6 +2080,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } +#if DEBUG + private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + return "toggle.configured" + } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + return "console.configured" + } + + let chars = (event.charactersIgnoringModifiers ?? "").lowercased() + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == [.command, .option] { + if chars == "i" || event.keyCode == 34 { + return "toggle.literal" + } + if chars == "c" || event.keyCode == 8 { + return "console.literal" + } + } + return nil + } + + private func logDeveloperToolsShortcutSnapshot( + phase: String, + event: NSEvent? = nil, + didHandle: Bool? = nil + ) { + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let eventDescription = event.map(NSWindow.keyDescription) ?? "none" + if let browser = tabManager?.focusedBrowserPanel { + var line = + "browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " + + "\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " + + "keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" + } + dlog(line) + return + } + var line = + "browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" + } + dlog(line) + } +#endif + private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { guard let browser = tabManager?.focusedBrowserPanel else { return } guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 628931d6..fd5eb45a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1317,6 +1317,12 @@ extension BrowserPanel { @discardableResult func toggleDeveloperTools() -> Bool { +#if DEBUG + dlog( + "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) +#endif guard let inspector = webView.cmuxInspectorObject() else { return false } let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false let targetVisible = !visible @@ -1330,6 +1336,19 @@ extension BrowserPanel { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false } +#if DEBUG + dlog( + "browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + dlog( + "browser.devtools toggle.tick panel=\(self.id.uuidString.prefix(5)) " + + "\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())" + ) + } +#endif return true } @@ -1606,6 +1625,35 @@ private extension BrowserPanel { #if DEBUG extension BrowserPanel { + private static func debugRectDescription(_ rect: NSRect) -> String { + String( + format: "%.1f,%.1f %.1fx%.1f", + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height + ) + } + + private static func debugObjectToken(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private static func debugInspectorSubviewCount(in root: NSView) -> Int { + var stack: [NSView] = [root] + var count = 0 + while let current = stack.popLast() { + for subview in current.subviews { + if String(describing: type(of: subview)).contains("WKInspector") { + count += 1 + } + stack.append(subview) + } + } + return count + } + func debugDeveloperToolsStateSummary() -> String { let preferred = preferredDeveloperToolsVisible ? 1 : 0 let visible = isDeveloperToolsVisible() ? 1 : 0 @@ -1615,6 +1663,18 @@ extension BrowserPanel { let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" } + + func debugDeveloperToolsGeometrySummary() -> String { + let container = webView.superview + let containerBounds = container?.bounds ?? .zero + let webFrame = webView.frame + let inspectorInsets = max(0, containerBounds.height - webFrame.height) + let inspectorOverflow = max(0, webFrame.maxY - containerBounds.maxY) + let inspectorHeightApprox = max(inspectorInsets, inspectorOverflow) + let inspectorSubviews = container.map { Self.debugInspectorSubviewCount(in: $0) } ?? 0 + let containerType = container.map { String(describing: type(of: $0)) } ?? "nil" + return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)" + } } #endif From 9c523731415694f5a3d2d0d8f88ba78938421a59 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:31:15 -0800 Subject: [PATCH 10/26] Allow HTTP loads in built-in web content Fixes https://github.com/manaflow-ai/cmux/issues/180 by enabling NSAllowsArbitraryLoadsInWebContent for WKWebView and adding a regression test that asserts ATS web-content override exists in Resources/Info.plist. --- Resources/Info.plist | 5 +++ .../UpdatePillReleaseVisibilityTests.swift | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Resources/Info.plist b/Resources/Info.plist index da978c67..8e323ec1 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -92,6 +92,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + SUFeedURL https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml SUPublicEDKey diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 4efc8c2b..9fc53cb7 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -64,3 +64,34 @@ final class UpdatePillReleaseVisibilityTests: XCTestCase { return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) } } + +/// Regression test: ensure WKWebView can load HTTP development URLs (e.g. *.localtest.me). +final class AppTransportSecurityTests: XCTestCase { + func testInfoPlistAllowsArbitraryLoadsInWebContent() throws { + let projectRoot = findProjectRoot() + let infoPlistURL = projectRoot.appendingPathComponent("Resources/Info.plist") + let data = try Data(contentsOf: infoPlistURL) + var format = PropertyListSerialization.PropertyListFormat.xml + let plist = try XCTUnwrap( + PropertyListSerialization.propertyList(from: data, options: [], format: &format) as? [String: Any] + ) + let ats = try XCTUnwrap(plist["NSAppTransportSecurity"] as? [String: Any]) + XCTAssertEqual( + ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool, + true, + "Resources/Info.plist must allow HTTP loads in WKWebView for local dev hostnames." + ) + } + + private func findProjectRoot() -> URL { + var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() + for _ in 0..<10 { + let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") + if FileManager.default.fileExists(atPath: marker.path) { + return dir + } + dir = dir.deletingLastPathComponent() + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } +} From 821e3ab4c3fb5426b43de5e439daa575489a42ee Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:55:26 -0800 Subject: [PATCH 11/26] Guard insecure HTTP in browser with allowlist and proceed flow --- Sources/Panels/BrowserPanel.swift | 274 +++++++++++++++++- Sources/cmuxApp.swift | 33 +++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 21 ++ .../UpdatePillReleaseVisibilityTests.swift | 50 ++++ 4 files changed, 374 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 031887df..f72b3f81 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -75,6 +75,165 @@ enum BrowserLinkOpenSettings { } } +enum BrowserInsecureHTTPSettings { + static let allowlistKey = "browserInsecureHTTPAllowlist" + static let defaultAllowlistPatterns = [ + "127.0.0.1", + "localhost", + "*.localtest.me", + ] + static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n") + + static func normalizedAllowlistPatterns(defaults: UserDefaults = .standard) -> [String] { + normalizedAllowlistPatterns(rawValue: defaults.string(forKey: allowlistKey)) + } + + static func normalizedAllowlistPatterns(rawValue: String?) -> [String] { + let source: String + if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + source = rawValue + } else { + source = defaultAllowlistText + } + let parsed = parsePatterns(from: source) + return parsed.isEmpty ? defaultAllowlistPatterns : parsed + } + + static func isHostAllowed(_ host: String, defaults: UserDefaults = .standard) -> Bool { + isHostAllowed(host, rawAllowlist: defaults.string(forKey: allowlistKey)) + } + + static func isHostAllowed(_ host: String, rawAllowlist: String?) -> Bool { + guard let normalizedHost = normalizeHost(host) else { return false } + return normalizedAllowlistPatterns(rawValue: rawAllowlist).contains { pattern in + hostMatchesPattern(normalizedHost, pattern: pattern) + } + } + + static func addAllowedHost(_ host: String, defaults: UserDefaults = .standard) { + guard let normalizedHost = normalizeHost(host) else { return } + var patterns = normalizedAllowlistPatterns(defaults: defaults) + guard !patterns.contains(normalizedHost) else { return } + patterns.append(normalizedHost) + defaults.set(patterns.joined(separator: "\n"), forKey: allowlistKey) + } + + static func normalizeHost(_ rawHost: String) -> String? { + var value = rawHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !value.isEmpty else { return nil } + + if let parsed = URL(string: value)?.host { + return trimHost(parsed) + } + + if let schemeRange = value.range(of: "://") { + value = String(value[schemeRange.upperBound...]) + } + + if let slash = value.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) { + value = String(value[.. [String] { + let separators = CharacterSet(charactersIn: ",;\n\r\t") + var out: [String] = [] + var seen = Set() + for token in rawValue.components(separatedBy: separators) { + guard let normalized = normalizePattern(token) else { continue } + guard seen.insert(normalized).inserted else { continue } + out.append(normalized) + } + return out + } + + private static func normalizePattern(_ rawPattern: String) -> String? { + let trimmed = rawPattern + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("*.") { + let suffixRaw = String(trimmed.dropFirst(2)) + guard let suffix = normalizeHost(suffixRaw) else { return nil } + return "*.\(suffix)" + } + + return normalizeHost(trimmed) + } + + private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool { + if pattern.hasPrefix("*.") { + let suffix = String(pattern.dropFirst(2)) + return host == suffix || host.hasSuffix(".\(suffix)") + } + return host == pattern + } + + private static func trimHost(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return trimmed.isEmpty ? nil : trimmed + } +} + +func browserShouldBlockInsecureHTTPURL( + _ url: URL, + defaults: UserDefaults = .standard, + runtimeAllowedHosts: Set = [] +) -> Bool { + browserShouldBlockInsecureHTTPURL( + url, + rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey), + runtimeAllowedHosts: runtimeAllowedHosts + ) +} + +func browserShouldBlockInsecureHTTPURL( + _ url: URL, + rawAllowlist: String?, + runtimeAllowedHosts: Set = [] +) -> Bool { + guard url.scheme?.lowercased() == "http" else { return false } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true } + if runtimeAllowedHosts.contains(host) { + return false + } + return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) +} + +@MainActor +enum BrowserInsecureHTTPRuntimeAllowlist { + private static var hosts = Set() + + static func contains(_ host: String) -> Bool { + hosts.contains(host) + } + + static func allow(_ host: String) { + hosts.insert(host) + } + + static func snapshot() -> Set { + hosts + } +} + enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve @@ -769,6 +928,11 @@ actor BrowserSearchSuggestionService { /// BrowserPanel provides a WKWebView-based browser panel. /// All browser panels share a WKProcessPool for cookie sharing. +private enum BrowserInsecureHTTPNavigationIntent { + case currentTab + case newTab +} + @MainActor final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels @@ -907,6 +1071,12 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.openInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } + navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in + self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false + } + navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] url, intent in + self?.presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false) + } webView.navigationDelegate = navDelegate self.navigationDelegate = navDelegate @@ -916,6 +1086,9 @@ final class BrowserPanel: Panel, ObservableObject { guard let self else { return } self.openLinkInNewTab(url: url) } + browserUIDelegate.requestNavigation = { [weak self] url, intent in + self?.requestNavigation(url, intent: intent) + } webView.uiDelegate = browserUIDelegate self.uiDelegate = browserUIDelegate @@ -1208,9 +1381,20 @@ final class BrowserPanel: Panel, ObservableObject { // MARK: - Navigation /// Navigate to a URL - func navigate(to url: URL) { + func navigate(to url: URL, recordTypedNavigation: Bool = false) { + if shouldBlockInsecureHTTPNavigation(to: url) { + presentInsecureHTTPAlert(for: url, intent: .currentTab, recordTypedNavigation: recordTypedNavigation) + return + } + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) + } + + private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) { // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent + if recordTypedNavigation { + BrowserHistoryStore.shared.recordTypedNavigation(url: url) + } navigationDelegate?.lastAttemptedURL = url var request = URLRequest(url: url) // Behave like a normal browser (respect HTTP caching). Reload is handled separately. @@ -1226,8 +1410,7 @@ final class BrowserPanel: Panel, ObservableObject { guard !trimmed.isEmpty else { return } if let url = resolveNavigableURL(from: trimmed) { - BrowserHistoryStore.shared.recordTypedNavigation(url: url) - navigate(to: url) + navigate(to: url, recordTypedNavigation: true) return } @@ -1240,6 +1423,67 @@ final class BrowserPanel: Panel, ObservableObject { resolveBrowserNavigableURL(input) } + private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool { + browserShouldBlockInsecureHTTPURL( + url, + runtimeAllowedHosts: BrowserInsecureHTTPRuntimeAllowlist.snapshot() + ) + } + + private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) { + if shouldBlockInsecureHTTPNavigation(to: url) { + presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false) + return + } + switch intent { + case .currentTab: + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: false) + case .newTab: + openLinkInNewTab(url: url) + } + } + + private func presentInsecureHTTPAlert( + for url: URL, + intent: BrowserInsecureHTTPNavigationIntent, + recordTypedNavigation: Bool + ) { + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Connection isn't secure" + alert.informativeText = """ + \(host) uses plain HTTP, so traffic can be read or modified on the network. + + Open this URL in your default browser, or proceed in cmux. + """ + alert.addButton(withTitle: "Open in Default Browser") + alert.addButton(withTitle: "Proceed in cmux") + alert.addButton(withTitle: "Cancel") + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Always allow this host in cmux" + + let response = alert.runModal() + switch response { + case .alertFirstButtonReturn: + NSWorkspace.shared.open(url) + case .alertSecondButtonReturn: + BrowserInsecureHTTPRuntimeAllowlist.allow(host) + if alert.suppressionButton?.state == .on { + BrowserInsecureHTTPSettings.addAllowedHost(host) + } + switch intent { + case .currentTab: + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) + case .newTab: + openLinkInNewTab(url: url) + } + default: + return + } + } + deinit { webViewObservers.removeAll() } @@ -1445,6 +1689,8 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? var openInNewTab: ((URL) -> Void)? + var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? + var handleBlockedInsecureHTTPNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> 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). var lastAttemptedURL: URL? @@ -1564,6 +1810,21 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { + if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, + shouldBlockInsecureHTTPNavigation?(url) == true { + let intent: BrowserInsecureHTTPNavigationIntent + if navigationAction.navigationType == .linkActivated, + navigationAction.modifierFlags.contains(.command) { + intent = .newTab + } else { + intent = .currentTab + } + handleBlockedInsecureHTTPNavigation?(url, intent) + decisionHandler(.cancel) + return + } + // target=_blank or window.open() — navigate in the current webview if navigationAction.targetFrame == nil, let url = navigationAction.request.url { @@ -1589,6 +1850,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private class BrowserUIDelegate: NSObject, WKUIDelegate { var openInNewTab: ((URL) -> Void)? + var requestNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)? /// Returning nil tells WebKit not to open a new window. /// Cmd+click opens in a new tab; regular target=_blank navigates in-place. @@ -1599,7 +1861,11 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { windowFeatures: WKWindowFeatures ) -> WKWebView? { if let url = navigationAction.request.url { - if navigationAction.modifierFlags.contains(.command) { + if let requestNavigation { + let intent: BrowserInsecureHTTPNavigationIntent = + navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab + requestNavigation(url, intent) + } else if navigationAction.modifierFlags.contains(.command) { openInNewTab?(url) } else { webView.load(URLRequest(url: url)) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f0966a8c..80ee0437 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2275,6 +2275,7 @@ struct SettingsView: View { @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @State private var shortcutResetToken = UUID() @@ -2451,6 +2452,37 @@ struct SettingsView: View { SettingsCardDivider() + VStack(alignment: .leading, spacing: 8) { + Text("HTTP Host Allowlist") + .font(.system(size: 13, weight: .semibold)) + + Text("HTTP loads outside this list show a warning prompt with options to open externally or proceed.") + .font(.caption) + .foregroundStyle(.secondary) + + TextEditor(text: $browserInsecureHTTPAllowlist) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .frame(minHeight: 86) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") + + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + + SettingsCardDivider() + SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) { Button("Clear History…") { showClearBrowserHistoryConfirmation = true @@ -2599,6 +2631,7 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue KeyboardShortcutSettings.resetAll() diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 40662dab..dc99383f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -390,6 +390,27 @@ final class AppearanceSettingsTests: XCTestCase { } } +// Compatibility shim for update-channel tests while feed selection is sourced from Info.plist. +private enum UpdateChannelSettings { + static let includeNightlyBuildsKey = "includeNightlyBuilds" + static let defaultIncludeNightlyBuilds = false + static let stableFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml" + static let nightlyFeedURL = "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" + + static func resolvedFeedURLString(infoFeedURL: String?, defaults: UserDefaults) -> (url: String, isNightly: Bool, usedFallback: Bool) { + let includeNightlyBuilds = defaults.object(forKey: includeNightlyBuildsKey) as? Bool ?? defaultIncludeNightlyBuilds + if includeNightlyBuilds { + return (nightlyFeedURL, true, false) + } + + if let infoFeedURL, !infoFeedURL.isEmpty { + return (infoFeedURL, false, false) + } + + return (stableFeedURL, false, true) + } +} + final class UpdateChannelSettingsTests: XCTestCase { func testDefaultNightlyPreferenceIsDisabled() { XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds) diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 9fc53cb7..cfbcf019 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -1,5 +1,6 @@ import XCTest import Foundation +@testable import cmux_DEV /// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths. /// This prevents accidentally hiding the update UI in Release builds. @@ -95,3 +96,52 @@ final class AppTransportSecurityTests: XCTestCase { return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) } } + +final class BrowserInsecureHTTPSettingsTests: XCTestCase { + func testDefaultAllowlistPatternsArePresent() { + XCTAssertEqual( + BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: nil), + ["127.0.0.1", "localhost", "*.localtest.me"] + ) + } + + func testWildcardAndExactHostMatching() { + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("localhost", rawAllowlist: nil)) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("api.localtest.me", rawAllowlist: nil)) + XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("neverssl.com", rawAllowlist: nil)) + } + + func testCustomAllowlistNormalizesAndDeduplicatesEntries() { + let raw = """ + localhost + *.example.com + 127.0.0.1 + https://dev.internal:8080/path + *.example.com + """ + + XCTAssertEqual( + BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: raw), + ["localhost", "*.example.com", "127.0.0.1", "dev.internal"] + ) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("foo.example.com", rawAllowlist: raw)) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("dev.internal", rawAllowlist: raw)) + XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("example.net", rawAllowlist: raw)) + } + + func testBlockDecisionUsesAllowlistAndRuntimeProceedCache() throws { + let localURL = try XCTUnwrap(URL(string: "http://foo.localtest.me:3000")) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL(localURL, rawAllowlist: nil)) + + let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com")) + XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil)) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL( + insecureURL, + rawAllowlist: nil, + runtimeAllowedHosts: ["neverssl.com"] + )) + + let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com")) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil)) + } +} From 79b34052cb882700f4de194b9c8c6cc5d474a7b7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:00:54 -0800 Subject: [PATCH 12/26] Persist HTTP allowlist selection when opening externally --- Sources/Panels/BrowserPanel.swift | 17 +++++++-- .../UpdatePillReleaseVisibilityTests.swift | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f72b3f81..88c44cca 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -217,6 +217,14 @@ func browserShouldBlockInsecureHTTPURL( return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) } +func browserShouldPersistInsecureHTTPAllowlistSelection( + response: NSApplication.ModalResponse, + suppressionEnabled: Bool +) -> Bool { + guard suppressionEnabled else { return false } + return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn +} + @MainActor enum BrowserInsecureHTTPRuntimeAllowlist { private static var hosts = Set() @@ -1465,14 +1473,17 @@ final class BrowserPanel: Panel, ObservableObject { alert.suppressionButton?.title = "Always allow this host in cmux" let response = alert.runModal() + if browserShouldPersistInsecureHTTPAllowlistSelection( + response: response, + suppressionEnabled: alert.suppressionButton?.state == .on + ) { + BrowserInsecureHTTPSettings.addAllowedHost(host) + } switch response { case .alertFirstButtonReturn: NSWorkspace.shared.open(url) case .alertSecondButtonReturn: BrowserInsecureHTTPRuntimeAllowlist.allow(host) - if alert.suppressionButton?.state == .on { - BrowserInsecureHTTPSettings.addAllowedHost(host) - } switch intent { case .currentTab: navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index cfbcf019..5a3d24a1 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -1,5 +1,6 @@ import XCTest import Foundation +import AppKit @testable import cmux_DEV /// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths. @@ -144,4 +145,41 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase { let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com")) XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil)) } + + func testAddAllowedHostPersistsToDefaultsAndUnblocksHTTP() throws { + let suiteName = "BrowserInsecureHTTPSettingsTests.Persist.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let url = try XCTUnwrap(URL(string: "http://persist-me.test")) + XCTAssertTrue(browserShouldBlockInsecureHTTPURL(url, defaults: defaults)) + + BrowserInsecureHTTPSettings.addAllowedHost("persist-me.test", defaults: defaults) + let persisted = defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey) + XCTAssertNotNil(persisted) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("persist-me.test", defaults: defaults)) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL(url, defaults: defaults)) + } + + func testAllowlistSelectionPersistsForProceedAndOpenExternal() { + XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertFirstButtonReturn, + suppressionEnabled: true + )) + XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertSecondButtonReturn, + suppressionEnabled: true + )) + XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertThirdButtonReturn, + suppressionEnabled: true + )) + XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertSecondButtonReturn, + suppressionEnabled: false + )) + } } From d1bcb17b0d7b222a34bf4a4a41bbc32f1d0ca629 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:04:58 -0800 Subject: [PATCH 13/26] Add explicit save for HTTP host allowlist setting --- Sources/cmuxApp.swift | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 80ee0437..c29c3a0a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2284,6 +2284,7 @@ struct SettingsView: View { @State private var settingsTitleLeadingInset: CGFloat = 92 @State private var showClearBrowserHistoryConfirmation = false @State private var browserHistoryEntryCount: Int = 0 + @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -2304,6 +2305,10 @@ struct SettingsView: View { } } + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { + browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist + } + private func blurOpacity(forContentOffset offset: CGFloat) -> Double { guard let baseline = topBlurBaselineOffset else { return 0 } let reveal = (baseline - offset) / 24 @@ -2460,7 +2465,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) - TextEditor(text: $browserInsecureHTTPAllowlist) + TextEditor(text: $browserInsecureHTTPAllowlistDraft) .font(.system(size: 12, weight: .regular, design: .monospaced)) .frame(minHeight: 86) .padding(6) @@ -2474,6 +2479,17 @@ struct SettingsView: View { ) .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") + HStack { + Spacer(minLength: 0) + Button("Save") { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") .font(.caption) .foregroundStyle(.secondary) @@ -2606,6 +2622,13 @@ struct SettingsView: View { .onAppear { BrowserHistoryStore.shared.loadIfNeeded() browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count + browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist + } + .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in + // Keep draft in sync with external changes unless the user has local unsaved edits. + if browserInsecureHTTPAllowlistDraft == oldValue { + browserInsecureHTTPAllowlistDraft = newValue + } } .onReceive(BrowserHistoryStore.shared.$entries) { entries in browserHistoryEntryCount = entries.count @@ -2632,11 +2655,16 @@ struct SettingsView: View { browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText + browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue KeyboardShortcutSettings.resetAll() shortcutResetToken = UUID() } + + private func saveBrowserInsecureHTTPAllowlist() { + browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft + } } private struct SettingsTopOffsetPreferenceKey: PreferenceKey { From 3ffceb7e8e310773724cc72d88d31a545d872925 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:16:12 -0800 Subject: [PATCH 14/26] Fix insecure HTTP proceed to be one-time unless saved --- Sources/Panels/BrowserPanel.swift | 66 +++++++++---------- Sources/Workspace.swift | 9 ++- .../UpdatePillReleaseVisibilityTests.swift | 25 +++++-- 3 files changed, 58 insertions(+), 42 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 88c44cca..bf98723c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -194,27 +194,35 @@ enum BrowserInsecureHTTPSettings { func browserShouldBlockInsecureHTTPURL( _ url: URL, - defaults: UserDefaults = .standard, - runtimeAllowedHosts: Set = [] + defaults: UserDefaults = .standard ) -> Bool { browserShouldBlockInsecureHTTPURL( url, - rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey), - runtimeAllowedHosts: runtimeAllowedHosts + rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey) ) } func browserShouldBlockInsecureHTTPURL( _ url: URL, - rawAllowlist: String?, - runtimeAllowedHosts: Set = [] + rawAllowlist: String? ) -> Bool { guard url.scheme?.lowercased() == "http" else { return false } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true } - if runtimeAllowedHosts.contains(host) { + return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) +} + +func browserShouldConsumeOneTimeInsecureHTTPBypass( + _ url: URL, + bypassHostOnce: inout String? +) -> Bool { + guard let bypassHost = bypassHostOnce else { return false } + guard url.scheme?.lowercased() == "http", + let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return false } - return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) + guard host == bypassHost else { return false } + bypassHostOnce = nil + return true } func browserShouldPersistInsecureHTTPAllowlistSelection( @@ -225,23 +233,6 @@ func browserShouldPersistInsecureHTTPAllowlistSelection( return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn } -@MainActor -enum BrowserInsecureHTTPRuntimeAllowlist { - private static var hosts = Set() - - static func contains(_ host: String) -> Bool { - hosts.contains(host) - } - - static func allow(_ host: String) { - hosts.insert(host) - } - - static func snapshot() -> Set { - hosts - } -} - enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve @@ -1009,6 +1000,7 @@ final class BrowserPanel: Panel, ObservableObject { private let minPageZoom: CGFloat = 0.25 private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 + private var insecureHTTPBypassHostOnce: String? var displayTitle: String { if !pageTitle.isEmpty { @@ -1028,9 +1020,10 @@ final class BrowserPanel: Panel, ObservableObject { false } - init(workspaceId: UUID, initialURL: URL? = nil) { + init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { self.id = UUID() self.workspaceId = workspaceId + self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") // Configure web view let config = WKWebViewConfiguration() @@ -1432,10 +1425,10 @@ final class BrowserPanel: Panel, ObservableObject { } private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool { - browserShouldBlockInsecureHTTPURL( - url, - runtimeAllowedHosts: BrowserInsecureHTTPRuntimeAllowlist.snapshot() - ) + if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) { + return false + } + return browserShouldBlockInsecureHTTPURL(url) } private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) { @@ -1483,12 +1476,12 @@ final class BrowserPanel: Panel, ObservableObject { case .alertFirstButtonReturn: NSWorkspace.shared.open(url) case .alertSecondButtonReturn: - BrowserInsecureHTTPRuntimeAllowlist.allow(host) switch intent { case .currentTab: + insecureHTTPBypassHostOnce = host navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) case .newTab: - openLinkInNewTab(url: url) + openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host) } default: return @@ -1545,11 +1538,16 @@ extension BrowserPanel { } /// Open a link in a new browser surface in the same pane - func openLinkInNewTab(url: URL) { + func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) { guard let tabManager = AppDelegate.shared?.tabManager, let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }), let paneId = workspace.paneId(forPanelId: id) else { return } - workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) + workspace.newBrowserSurface( + inPane: paneId, + url: url, + focus: true, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ) } /// Reload the current page diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7ed6e468..e724c6ef 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -571,11 +571,16 @@ final class Workspace: Identifiable, ObservableObject { inPane paneId: PaneID, url: URL? = nil, focus: Bool? = nil, - insertAtEnd: Bool = false + insertAtEnd: Bool = false, + bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let browserPanel = BrowserPanel(workspaceId: id, initialURL: url) + let browserPanel = BrowserPanel( + workspaceId: id, + initialURL: url, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ) panels[browserPanel.id] = browserPanel guard let newTabId = bonsplitController.createTab( diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 5a3d24a1..2dc252fd 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -130,22 +130,35 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase { XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("example.net", rawAllowlist: raw)) } - func testBlockDecisionUsesAllowlistAndRuntimeProceedCache() throws { + func testBlockDecisionUsesAllowlistAndSchemeRules() throws { let localURL = try XCTUnwrap(URL(string: "http://foo.localtest.me:3000")) XCTAssertFalse(browserShouldBlockInsecureHTTPURL(localURL, rawAllowlist: nil)) let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com")) XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil)) - XCTAssertFalse(browserShouldBlockInsecureHTTPURL( - insecureURL, - rawAllowlist: nil, - runtimeAllowedHosts: ["neverssl.com"] - )) let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com")) XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil)) } + func testOneTimeBypassIsConsumedAfterFirstNavigation() throws { + let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com")) + var bypassHostOnce: String? = "neverssl.com" + + XCTAssertTrue(browserShouldConsumeOneTimeInsecureHTTPBypass( + insecureURL, + bypassHostOnce: &bypassHostOnce + )) + XCTAssertNil(bypassHostOnce) + + // Subsequent visits should prompt again unless host was saved. + XCTAssertFalse(browserShouldConsumeOneTimeInsecureHTTPBypass( + insecureURL, + bypassHostOnce: &bypassHostOnce + )) + XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil)) + } + func testAddAllowedHostPersistsToDefaultsAndUnblocksHTTP() throws { let suiteName = "BrowserInsecureHTTPSettingsTests.Persist.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { From df9ba6dcd92c520ea9cc463e81b2fa5a6b810fb1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:17:00 -0800 Subject: [PATCH 15/26] Fix #155: remap-aware bonsplit tooltips + browser split shortcuts (#200) * Issue #155: remap bonsplit tooltips and add browser split shortcuts * Fix split button mousedown feedback regression * Match split button sizing with main --- Sources/AppDelegate.swift | 44 ++++++++++++ Sources/KeyboardShortcutSettings.swift | 12 ++++ Sources/TabManager.swift | 37 +++++++++- Sources/Workspace.swift | 16 +++++ Sources/cmuxApp.swift | 31 +++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 68 +++++++++++++++++++ vendor/bonsplit | 2 +- web/app/keyboard-shortcuts.tsx | 14 +++- 8 files changed, 219 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d353ee7d..77645d1f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -187,6 +187,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var workspaceObserver: NSObjectProtocol? private var windowKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? + private var shortcutDefaultsObserver: NSObjectProtocol? private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? @@ -336,6 +337,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installWindowKeyEquivalentSwizzle() installBrowserAddressBarFocusObservers() installShortcutMonitor() + installShortcutDefaultsObserver() NSApp.servicesProvider = self #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) @@ -1460,6 +1462,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func installShortcutDefaultsObserver() { + guard shortcutDefaultsObserver == nil else { return } + shortcutDefaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshSplitButtonTooltipsAcrossWorkspaces() + } + } + + private func refreshSplitButtonTooltipsAcrossWorkspaces() { + var refreshedManagers: Set = [] + if let manager = tabManager { + manager.refreshSplitButtonTooltips() + refreshedManagers.insert(ObjectIdentifier(manager)) + } + for context in mainWindowContexts.values { + let manager = context.tabManager + let identifier = ObjectIdentifier(manager) + guard refreshedManagers.insert(identifier).inserted else { continue } + manager.refreshSplitButtonTooltips() + } + } + private func installGhosttyConfigObserver() { guard ghosttyConfigObserver == nil else { return } ghosttyConfigObserver = NotificationCenter.default.addObserver( @@ -1861,6 +1888,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) { + _ = performBrowserSplitShortcut(direction: .right) + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) { + _ = performBrowserSplitShortcut(direction: .down) + return true + } + // Surface navigation (legacy Ctrl+Tab support) if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { tabManager?.selectNextSurface() @@ -2041,6 +2078,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + @discardableResult + func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false } + _ = focusBrowserAddressBar(panelId: panelId) + return true + } + /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts /// through the same app-level shortcut handler used by the local key monitor. @discardableResult diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 6ecc0e6c..689b1161 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -26,6 +26,8 @@ enum KeyboardShortcutSettings { case focusDown case splitRight case splitDown + case splitBrowserRight + case splitBrowserDown // Panels case openBrowser @@ -51,6 +53,8 @@ enum KeyboardShortcutSettings { case .focusDown: return "Focus Pane Down" case .splitRight: return "Split Right" case .splitDown: return "Split Down" + case .splitBrowserRight: return "Split Browser Right" + case .splitBrowserDown: return "Split Browser Down" case .openBrowser: return "Open Browser" } } @@ -71,6 +75,8 @@ enum KeyboardShortcutSettings { case .focusDown: return "shortcut.focusDown" case .splitRight: return "shortcut.splitRight" case .splitDown: return "shortcut.splitDown" + case .splitBrowserRight: return "shortcut.splitBrowserRight" + case .splitBrowserDown: return "shortcut.splitBrowserDown" case .nextSurface: return "shortcut.nextSurface" case .prevSurface: return "shortcut.prevSurface" case .newSurface: return "shortcut.newSurface" @@ -108,6 +114,10 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) case .splitDown: return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false) + case .splitBrowserRight: + return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false) + case .splitBrowserDown: + return StoredShortcut(key: "d", command: true, shift: true, option: true, control: false) case .nextSurface: return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false) case .prevSurface: @@ -176,6 +186,8 @@ enum KeyboardShortcutSettings { static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) } static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) } + static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) } + static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) } static func nextSurfaceShortcut() -> StoredShortcut { shortcut(for: .nextSurface) } static func prevSurfaceShortcut() -> StoredShortcut { shortcut(for: .prevSurface) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f7585f2b..5dd3cfc3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1253,6 +1253,28 @@ class TabManager: ObservableObject { _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } + /// Create a new browser split from the currently focused panel. + @discardableResult + func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? { + guard let selectedTabId, + let tab = tabs.first(where: { $0.id == selectedTabId }), + let focusedPanelId = tab.focusedPanelId else { return nil } + return newBrowserSplit( + tabId: selectedTabId, + fromPanelId: focusedPanelId, + orientation: direction.orientation, + insertFirst: direction.insertFirst, + url: url + ) + } + + /// Refresh Bonsplit right-side action button tooltips for all workspaces. + func refreshSplitButtonTooltips() { + for workspace in tabs { + workspace.refreshSplitButtonTooltips() + } + } + // MARK: - Pane Focus Navigation /// Move focus to an adjacent pane in the specified direction @@ -1393,9 +1415,20 @@ class TabManager: ObservableObject { // MARK: - Browser Panel Operations /// Create a new browser panel in a split - func newBrowserSplit(tabId: UUID, fromPanelId: UUID, orientation: SplitOrientation, url: URL? = nil) -> UUID? { + func newBrowserSplit( + tabId: UUID, + fromPanelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + url: URL? = nil + ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - return tab.newBrowserSplit(from: fromPanelId, orientation: orientation, url: url)?.id + return tab.newBrowserSplit( + from: fromPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url + )?.id } /// Create a new browser surface in a pane diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7ed6e468..71d28ded 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -105,12 +105,22 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Initialization + private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { + BonsplitConfiguration.SplitButtonTooltips( + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") + ) + } + private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { bonsplitAppearance(from: config.backgroundColor) } private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { BonsplitConfiguration.Appearance( + splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init(backgroundHex: backgroundColor.hexString()) ) @@ -208,6 +218,12 @@ final class Workspace: Identifiable, ObservableObject { } } + func refreshSplitButtonTooltips() { + var configuration = bonsplitController.configuration + configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips() + bonsplitController.configuration = configuration + } + // MARK: - Surface ID to Panel ID Mapping /// Mapping from bonsplit TabID (surface ID) to panel UUID diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 3a2e5264..daf9ecd6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -15,6 +15,8 @@ struct cmuxApp: App { @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -463,6 +465,14 @@ struct cmuxApp: App { performSplitFromMenu(direction: .down) } + splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) { + performBrowserSplitFromMenu(direction: .right) + } + + splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) { + performBrowserSplitFromMenu(direction: .down) + } + Divider() // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) @@ -545,6 +555,20 @@ struct cmuxApp: App { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } + private var splitBrowserRightMenuShortcut: StoredShortcut { + decodeShortcut( + from: splitBrowserRightShortcutData, + fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut + ) + } + + private var splitBrowserDownMenuShortcut: StoredShortcut { + decodeShortcut( + from: splitBrowserDownShortcutData, + fallback: KeyboardShortcutSettings.Action.splitBrowserDown.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } @@ -577,6 +601,13 @@ struct cmuxApp: App { tabManager.createSplit(direction: direction) } + private func performBrowserSplitFromMenu(direction: SplitDirection) { + if AppDelegate.shared?.performBrowserSplitShortcut(direction: direction) == true { + return + } + _ = tabManager.createBrowserSplit(direction: direction) + } + @ViewBuilder private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View { if let key = keyEquivalent(for: shortcut) { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0060171f..98f6914a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -225,6 +225,74 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { } } +final class KeyboardShortcutSettingsTests: XCTestCase { + func testBrowserSplitShortcutDefaults() { + let keys = [ + KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey, + KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey + ] + let defaults = UserDefaults.standard + let previousValues = keys.map { key in (key, defaults.data(forKey: key)) } + defer { + for (key, value) in previousValues { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + keys.forEach { defaults.removeObject(forKey: $0) } + + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserRight).displayString, "⌥⌘D") + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserDown).displayString, "⌥⇧⌘D") + } + + @MainActor + func testWorkspaceConfiguresSplitButtonTooltipsWithEffectiveShortcuts() throws { + let keys = [ + KeyboardShortcutSettings.Action.newSurface.defaultsKey, + KeyboardShortcutSettings.Action.openBrowser.defaultsKey, + KeyboardShortcutSettings.Action.splitRight.defaultsKey, + KeyboardShortcutSettings.Action.splitDown.defaultsKey + ] + let defaults = UserDefaults.standard + let previousValues = keys.map { key in (key, defaults.data(forKey: key)) } + defer { + for (key, value) in previousValues { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + + let customPairs: [(KeyboardShortcutSettings.Action, StoredShortcut)] = [ + (.newSurface, StoredShortcut(key: "1", command: true, shift: false, option: false, control: false)), + (.openBrowser, StoredShortcut(key: "2", command: true, shift: false, option: false, control: false)), + (.splitRight, StoredShortcut(key: "3", command: true, shift: false, option: false, control: false)), + (.splitDown, StoredShortcut(key: "4", command: true, shift: false, option: false, control: false)), + ] + + for (action, shortcut) in customPairs { + guard let data = try? JSONEncoder().encode(shortcut) else { + XCTFail("Failed to encode shortcut for \(action.rawValue)") + return + } + defaults.set(data, forKey: action.defaultsKey) + } + + let workspace = Workspace(title: "Tooltip Test") + let tooltips = workspace.bonsplitController.configuration.appearance.splitButtonTooltips + + XCTAssertEqual(tooltips.newTerminal, "New Terminal (⌘1)") + XCTAssertEqual(tooltips.newBrowser, "New Browser (⌘2)") + XCTAssertEqual(tooltips.splitRight, "Split Right (⌘3)") + XCTAssertEqual(tooltips.splitDown, "Split Down (⌘4)") + } +} + final class ShortcutHintLanePlannerTests: XCTestCase { func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() { let intervals: [ClosedRange] = [0...20, 28...40, 48...64] diff --git a/vendor/bonsplit b/vendor/bonsplit index ae234a22..6ac667d3 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87 +Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745 diff --git a/web/app/keyboard-shortcuts.tsx b/web/app/keyboard-shortcuts.tsx index e70d174a..f4c483c0 100644 --- a/web/app/keyboard-shortcuts.tsx +++ b/web/app/keyboard-shortcuts.tsx @@ -80,6 +80,16 @@ const CATEGORIES: ShortcutCategory[] = [ combos: [["⌥", "⌘", "←/→/↑/↓"]], description: "Focus pane directionally", }, + { + id: "sp-browser-right", + combos: [["⌥", "⌘", "D"]], + description: "Split browser right", + }, + { + id: "sp-browser-down", + combos: [["⌥", "⌘", "⇧", "D"]], + description: "Split browser down", + }, ], }, { @@ -88,8 +98,8 @@ const CATEGORIES: ShortcutCategory[] = [ shortcuts: [ { id: "br-open", - combos: [["⌘", "⇧", "B"]], - description: "Open browser in split", + combos: [["⌘", "⇧", "L"]], + description: "Open browser surface", }, { id: "br-addr", combos: [["⌘", "L"]], description: "Focus address bar" }, { id: "br-forward", combos: [["⌘", "]"]], description: "Forward" }, From 6133da0b2008281ff2c44da567f8a57635eb883d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:19:06 -0800 Subject: [PATCH 16/26] Prefer navigate row over switch-to-tab for identical URL --- Sources/Panels/BrowserPanelView.swift | 14 +++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 89a64487..65cf42ce 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1071,7 +1071,19 @@ func buildOmnibarSuggestions( ) order += 1 if let existing = bestByCompletion[key] { - if ranked.score > existing.score { + let shouldReplaceExisting: Bool = { + // For identical completions, keep "go to URL" over "switch to tab" so + // pressing Enter performs navigation unless the user explicitly picks a tab row. + switch (existing.suggestion.kind, ranked.suggestion.kind) { + case (.navigate, .switchToTab): + return false + case (.switchToTab, .navigate): + return true + default: + return ranked.score > existing.score + } + }() + if shouldReplaceExisting { bestByCompletion[key] = ranked } } else { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index dc99383f..8bcaac46 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1332,6 +1332,39 @@ final class OmnibarSuggestionRankingTests: XCTestCase { XCTAssertEqual(results.first?.completion, "https://gmail.com/") } + func testNavigateSuggestionRanksAheadOfSwitchToTabForSameResolvedURL() throws { + let targetURL = try XCTUnwrap(URL(string: "http://http.badssl.com/")) + + let results = buildOmnibarSuggestions( + query: targetURL.absoluteString, + engineName: "Google", + historyEntries: [], + openTabMatches: [ + .init( + tabId: UUID(), + panelId: UUID(), + url: targetURL.absoluteString, + title: "http.badssl.com", + isKnownOpenTab: true + ), + ], + remoteQueries: [], + resolvedURL: targetURL, + limit: 8, + now: fixedNow + ) + + guard let first = results.first else { + XCTFail("Expected at least one suggestion") + return + } + guard case .navigate(let navigateURL) = first.kind else { + XCTFail("Expected first suggestion to be navigate, got \(first.kind)") + return + } + XCTAssertEqual(navigateURL, targetURL.absoluteString) + } + func testSuggestionSelectionPrefersAutocompletionCandidateAfterSuggestionsUpdate() { let entries: [BrowserHistoryStore.Entry] = [ .init( From 834e156556f4d6512e94b8999b9d968498e4ca28 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:20:44 -0800 Subject: [PATCH 17/26] Make HTTP allowlist helper and save row responsive --- Sources/cmuxApp.swift | 47 +++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index c29c3a0a..029506ac 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2479,20 +2479,41 @@ struct SettingsView: View { ) .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") - HStack { - Spacer(minLength: 0) - Button("Save") { - saveBrowserInsecureHTTPAllowlist() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) - .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") - } + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 10) { + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) - Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") - .font(.caption) - .foregroundStyle(.secondary) + Spacer(minLength: 0) + + Button("Save") { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + + VStack(alignment: .leading, spacing: 8) { + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Spacer(minLength: 0) + Button("Save") { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + } + } } .padding(.horizontal, 14) .padding(.vertical, 10) From 270115ccbb0c42927a594495917d31c87f479b94 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 20 Feb 2026 15:36:24 -0800 Subject: [PATCH 18/26] Fix Cmd+Enter being routed as browser reload (#213) --- Sources/AppDelegate.swift | 7 +++++ Sources/Panels/BrowserPanelView.swift | 3 +++ Sources/Panels/CmuxWebView.swift | 7 +++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 26 +++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 77645d1f..081f9fe9 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -96,6 +96,13 @@ func browserOmnibarSelectionDeltaForArrowNavigation( } } +func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + return normalizedFlags == [] || normalizedFlags == [.shift] +} + enum BrowserZoomShortcutAction: Equatable { case zoomIn case zoomOut diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 89a64487..aa8a4b0f 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1970,6 +1970,8 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { parent.onMoveSelection(-1) return true case #selector(NSResponder.insertNewline(_:)): + let currentFlags = NSApp.currentEvent?.modifierFlags ?? [] + guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false } parent.onSubmit() return true case #selector(NSResponder.cancelOperation(_:)): @@ -2080,6 +2082,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { switch keyCode { case 36, 76: // Return / keypad Enter + guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false } parent.onSubmit() return true case 53: // Escape diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index ed27c263..e0131b9f 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -7,6 +7,13 @@ import WebKit /// the first responder. final class CmuxWebView: WKWebView { override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not + // route it through app/menu key equivalents, which can trigger unintended actions. + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 { + return false + } + // Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc). if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { return true diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 98f6914a..07d399c6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -53,6 +53,18 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { XCTAssertTrue(spy.invoked) } + func testCmdReturnBypassesMenuRoutingWhenWebViewIsFirstResponder() { + let spy = ActionSpy() + installMenu(spy: spy, key: "\r", modifiers: [.command]) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let event = makeKeyDownEvent(key: "\r", modifiers: [.command], keyCode: 36) // kVK_Return + XCTAssertNotNil(event) + + XCTAssertFalse(webView.performKeyEquivalent(with: event!)) + XCTAssertFalse(spy.invoked) + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { let mainMenu = NSMenu() @@ -192,6 +204,20 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { } } +final class BrowserOmnibarReturnSubmitPolicyTests: XCTestCase { + func testReturnSubmitAllowsPlainAndShiftOnly() { + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift])) + } + + func testReturnSubmitRejectsCommandControlAndOption() { + XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command])) + XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.control])) + XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.option])) + XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .shift])) + } +} + final class SidebarCommandHintPolicyTests: XCTestCase { func testCommandHintRequiresCommandOnlyModifier() { XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command])) From 70716d23bf4a151638fd604ad48c7aa6280136eb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:50:54 +0000 Subject: [PATCH 19/26] Add community section to README Co-Authored-By: Claude Opus 4.6 --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 51688431..760fbf76 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,14 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed. +## Community + +- [Discord](https://discord.com/invite/QRxkhZgY) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) + ## License This project is licensed under the GNU Affero General Public License v3.0 or later (`AGPL-3.0-or-later`). From 1f3f366294290603025999ece5ea5bd0524711ee Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 20 Feb 2026 15:55:31 -0800 Subject: [PATCH 20/26] Fix #205: Add setting to disable workspace auto-reorder on notification (#215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(claude-opus-4-6): From HN feedback: https://news.ycombinator.com/item?id=47... * Centralize workspace auto-reorder into addNotification Move moveTabToTop into TerminalNotificationStore.addNotification so all notification paths (Ghostty actions, v2 API, control socket) respect the reorder-on-notification setting, not just the two Ghostty action sites. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- Sources/GhosttyTerminalView.swift | 2 - Sources/TabManager.swift | 12 ++++++ Sources/TerminalNotificationStore.swift | 4 ++ Sources/cmuxApp.swift | 13 +++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 37 +++++++++++++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7bc8194f..5f24ffe3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -679,7 +679,6 @@ class GhosttyApp { let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody let surfaceId = tabManager.focusedSurfaceId(for: tabId) - tabManager.moveTabToTop(tabId) TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, @@ -883,7 +882,6 @@ class GhosttyApp { let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody - AppDelegate.shared?.tabManager?.moveTabToTop(tabId) TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 5dd3cfc3..9e2c2be3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -39,6 +39,18 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { } } +enum WorkspaceAutoReorderSettings { + static let key = "workspaceAutoReorderOnNotification" + static let defaultValue = true + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: key) == nil { + return defaultValue + } + return defaults.bool(forKey: key) + } +} + enum WorkspacePlacementSettings { static let placementKey = "newWorkspacePlacement" static let defaultPlacement: NewWorkspacePlacement = .afterCurrent diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index cafe7230..35060ebe 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -154,6 +154,10 @@ final class TerminalNotificationStore: ObservableObject { return } + if WorkspaceAutoReorderSettings.isEnabled() { + AppDelegate.shared?.tabManager?.moveTabToTop(tabId) + } + let notification = TerminalNotification( id: UUID(), tabId: tabId, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index fd7d23b0..4179903c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2309,6 +2309,7 @@ struct SettingsView: View { @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -2380,6 +2381,17 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Reorder on Notification", + subtitle: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions." + ) { + Toggle("", isOn: $workspaceAutoReorder) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Dock Badge", subtitle: "Show unread count on app icon (Dock and Cmd+Tab)." @@ -2710,6 +2722,7 @@ struct SettingsView: View { browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue KeyboardShortcutSettings.resetAll() shortcutResetToken = UUID() } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index d17a5f97..e3f7475e 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -433,6 +433,43 @@ final class WorkspacePlacementSettingsTests: XCTestCase { } } +final class WorkspaceAutoReorderSettingsTests: XCTestCase { + func testDefaultIsEnabled() { + let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) + } + + func testDisabledWhenSetToFalse() { + let suiteName = "WorkspaceAutoReorderSettingsTests.Disabled.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: WorkspaceAutoReorderSettings.key) + XCTAssertFalse(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) + } + + func testEnabledWhenSetToTrue() { + let suiteName = "WorkspaceAutoReorderSettingsTests.Enabled.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: WorkspaceAutoReorderSettings.key) + XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) + } +} + final class AppearanceSettingsTests: XCTestCase { func testResolvedModeDefaultsToSystemWhenUnset() { let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" From 99529551840d6e8018dcfea70507f2dc47376381 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:08:20 -0800 Subject: [PATCH 21/26] Fix setup.sh cache: always rebuild on miss, add stale lock timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove local xcframework seeding on cache miss — the local build output isn't tied to the current submodule SHA and can produce ABI mismatches. Always run zig build when cache misses. - Add 300s timeout on lock acquisition. If a prior setup was killed uncleanly (SIGKILL, host crash), the lock dir persists forever and blocks all future runs. Now auto-removes stale locks. --- scripts/setup.sh | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index 2f4f6d2a..f225000e 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -27,7 +27,14 @@ mkdir -p "$CACHE_ROOT" echo "==> Ghostty submodule commit: $GHOSTTY_SHA" +LOCK_TIMEOUT=300 +LOCK_START=$SECONDS while ! mkdir "$LOCK_DIR" 2>/dev/null; do + if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then + echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..." + rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR" + continue + fi echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_SHA..." sleep 1 done @@ -36,25 +43,20 @@ trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT if [ -d "$CACHE_XCFRAMEWORK" ]; then echo "==> Reusing cached GhosttyKit.xcframework" else - if [ -d "$LOCAL_XCFRAMEWORK" ]; then - echo "==> Seeding cache from existing local GhosttyKit.xcframework" - else - echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." - ( - cd ghostty - zig build -Demit-xcframework=true -Doptimize=ReleaseFast - ) - fi + echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." + ( + cd ghostty + zig build -Demit-xcframework=true -Doptimize=ReleaseFast + ) - SRC_XCFRAMEWORK="$LOCAL_XCFRAMEWORK" - if [ ! -d "$SRC_XCFRAMEWORK" ]; then - echo "Error: GhosttyKit.xcframework not found at $SRC_XCFRAMEWORK" + if [ ! -d "$LOCAL_XCFRAMEWORK" ]; then + echo "Error: GhosttyKit.xcframework not found at $LOCAL_XCFRAMEWORK" exit 1 fi TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")" mkdir -p "$CACHE_DIR" - cp -R "$SRC_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework" + cp -R "$LOCAL_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework" rm -rf "$CACHE_XCFRAMEWORK" mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK" rmdir "$TMP_DIR" From b163c2bbf35808a2ba808da0144999b48ff2e5ed Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:22:33 -0800 Subject: [PATCH 22/26] Expose production read-screen capture APIs Implements capture-pane parity item from https://github.com/manaflow-ai/cmux/issues/153 by shipping production read-screen support from https://github.com/manaflow-ai/cmux/issues/152. --- CLI/cmux.swift | 54 ++++ Sources/TerminalController.swift | 273 +++++++++++++----- tests_v2/cmux.py | 12 + .../test_read_screen_capture_pane_parity.py | 122 ++++++++ 4 files changed, 381 insertions(+), 80 deletions(-) create mode 100644 tests_v2/test_read_screen_capture_pane_parity.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bdb617b4..a6d1ae32 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -868,6 +868,43 @@ struct CMUXCLI { print(response) } + case "read-screen": + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (sfArg, rem1) = parseOption(rem0, name: "--surface") + let (linesArg, rem2) = parseOption(rem1, name: "--lines") + let trailing = rem2.filter { $0 != "--scrollback" } + if !trailing.isEmpty { + throw CLIError(message: "read-screen: unexpected arguments: \(trailing.joined(separator: " "))") + } + + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + + let includeScrollback = rem2.contains("--scrollback") + if includeScrollback { + params["scrollback"] = true + } + if let linesArg { + guard let lineCount = Int(linesArg), lineCount > 0 else { + throw CLIError(message: "--lines must be greater than 0") + } + params["lines"] = lineCount + params["scrollback"] = true + } + + let payload = try client.sendV2(method: "surface.read_text", params: params) + if jsonOutput { + print(jsonString(payload)) + } else { + print((payload["text"] as? String) ?? "") + } + case "send": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (sfArg, rem1) = parseOption(rem0, name: "--surface") @@ -2803,6 +2840,22 @@ struct CMUXCLI { cmux select-workspace --workspace workspace:2 cmux select-workspace --workspace 0 """ + case "read-screen": + return """ + Usage: cmux read-screen [flags] + + Read terminal text from a surface as plain text. + + Flags: + --workspace Target workspace (default: $CMUX_WORKSPACE_ID) + --surface Target surface (default: $CMUX_SURFACE_ID) + --scrollback Include scrollback (not just visible viewport) + --lines Limit to the last n lines (implies --scrollback) + + Example: + cmux read-screen + cmux read-screen --surface surface:2 --scrollback --lines 200 + """ case "send": return """ Usage: cmux send [flags] [--] @@ -3463,6 +3516,7 @@ struct CMUXCLI { close-workspace --workspace select-workspace --workspace current-workspace + read-screen [--workspace ] [--surface ] [--scrollback] [--lines ] send [--workspace ] [--surface ] send-key [--workspace ] [--surface ] send-panel --panel [--workspace ] diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 96166d03..fe9ca6db 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -461,6 +461,9 @@ class TerminalController { case "reset_sidebar": return resetSidebar(args) + case "read_screen": + return readScreenText(args) + #if DEBUG case "set_shortcut": @@ -496,9 +499,6 @@ class TerminalController { case "read_terminal_text": return readTerminalText(args) - case "read_screen": - return readScreen(args) - case "render_stats": return renderStats(args) @@ -896,6 +896,8 @@ class TerminalController { return v2Result(id: id, self.v2BrowserInputKeyboard(params: params)) case "browser.input_touch": return v2Result(id: id, self.v2BrowserInputTouch(params: params)) + case "surface.read_text": + return v2Result(id: id, self.v2SurfaceReadText(params: params)) #if DEBUG @@ -973,6 +975,7 @@ class TerminalController { "surface.health", "surface.send_text", "surface.send_key", + "surface.read_text", "surface.trigger_flash", "pane.list", "pane.focus", @@ -2387,6 +2390,36 @@ class TerminalController { return result } + private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult { + var includeScrollback = v2Bool(params, "scrollback") ?? false + let lineLimit = v2Int(params, "lines") + if let lineLimit, lineLimit <= 0 { + return .err(code: "invalid_params", message: "lines must be greater than 0", data: nil) + } + if lineLimit != nil { + includeScrollback = true + } + + let response = readTerminalTextBase64( + surfaceArg: v2String(params, "surface_id") ?? "", + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + guard response.hasPrefix("OK ") else { + return .err(code: "internal_error", message: response, data: nil) + } + let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + let decoded = Data(base64Encoded: base64).flatMap { String(data: $0, encoding: .utf8) } + guard let text = decoded ?? (base64.isEmpty ? "" : nil) else { + return .err(code: "internal_error", message: "Failed to decode terminal text", data: nil) + } + + return .ok([ + "text": text, + "base64": base64 + ]) + } + private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -6193,6 +6226,161 @@ class TerminalController { } #endif + private struct ReadScreenOptions { + let surfaceArg: String + let includeScrollback: Bool + let lineLimit: Int? + } + + private struct ReadScreenParseError: Error { + let message: String + } + + private func parseReadScreenArgs(_ args: String) -> Result { + let tokens = args + .split(whereSeparator: { $0.isWhitespace }) + .map(String.init) + var surfaceArg: String? + var includeScrollback = false + var lineLimit: Int? + var idx = 0 + + while idx < tokens.count { + let token = tokens[idx] + switch token { + case "--scrollback": + includeScrollback = true + idx += 1 + case "--lines": + guard idx + 1 < tokens.count, let parsed = Int(tokens[idx + 1]), parsed > 0 else { + return .failure(ReadScreenParseError(message: "ERROR: --lines must be greater than 0")) + } + lineLimit = parsed + includeScrollback = true + idx += 2 + default: + guard surfaceArg == nil else { + return .failure(ReadScreenParseError(message: "ERROR: Usage: read_screen [id|idx] [--scrollback] [--lines ]")) + } + surfaceArg = token + idx += 1 + } + } + + return .success( + ReadScreenOptions( + surfaceArg: surfaceArg ?? "", + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + ) + } + + private func tailTerminalLines(_ text: String, maxLines: Int) -> String { + guard maxLines > 0 else { return "" } + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count > maxLines else { return text } + return lines.suffix(maxLines).joined(separator: "\n") + } + + private func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + + let trimmedSurfaceArg = surfaceArg.trimmingCharacters(in: .whitespacesAndNewlines) + var result = "ERROR: No tab selected" + DispatchQueue.main.sync { + guard let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + return + } + + let panelId: UUID? + if trimmedSurfaceArg.isEmpty { + panelId = tab.focusedPanelId + } else { + panelId = resolveSurfaceId(from: trimmedSurfaceArg, tab: tab) + } + + guard let panelId, + let terminalPanel = tab.terminalPanel(for: panelId), + let surface = terminalPanel.surface.surface else { + result = "ERROR: Terminal surface not found" + return + } + + let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT + let topLeft = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ) + let bottomRight = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ) + var selection = ghostty_selection_s( + top_left: topLeft, + bottom_right: bottomRight, + rectangle: true + ) + var text = ghostty_text_s() + + guard ghostty_surface_read_text(surface, selection, &text) else { + result = "ERROR: Failed to read terminal text" + return + } + defer { + ghostty_surface_free_text(surface, &text) + } + + let rawData: Data + if let ptr = text.text, text.text_len > 0 { + rawData = Data(bytes: ptr, count: Int(text.text_len)) + } else { + rawData = Data() + } + + var output = String(decoding: rawData, as: UTF8.self) + if let lineLimit { + output = tailTerminalLines(output, maxLines: lineLimit) + } + + let base64 = output.data(using: .utf8)?.base64EncodedString() ?? "" + result = "OK \(base64)" + } + return result + } + + private func readScreenText(_ args: String) -> String { + let options: ReadScreenOptions + switch parseReadScreenArgs(args) { + case .success(let parsed): + options = parsed + case .failure(let error): + return error.message + } + + let response = readTerminalTextBase64( + surfaceArg: options.surfaceArg, + includeScrollback: options.includeScrollback, + lineLimit: options.lineLimit + ) + guard response.hasPrefix("OK ") else { return response } + + let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + if payload.isEmpty { + return "" + } + + guard let data = Data(base64Encoded: payload) else { + return "ERROR: Failed to decode terminal text" + } + return String(decoding: data, as: UTF8.self) + } + private func helpText() -> String { var text = """ Hierarchy: Workspace (sidebar tab) > Pane (split region) > Surface (nested tab) > Panel (terminal/browser) @@ -6225,6 +6413,7 @@ class TerminalController { send_key - Send special key (ctrl-c, ctrl-d, enter, tab, escape) send_surface - Send text to a specific terminal send_key_surface - Send special key to a specific terminal + read_screen [id|idx] [--scrollback] [--lines N] - Read terminal text (plain text) Notification commands: notify |<subtitle>|<body> - Notify focused panel @@ -6282,7 +6471,6 @@ class TerminalController { activate_app - Bring app + main window to front (test-only) is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only) read_terminal_text [id|idx] - Read visible terminal text (base64, test-only) - read_screen [id|idx] - Read visible terminal text (plain text, legacy test-only) render_stats [id|idx] - Read terminal render stats (draw counters, test-only) layout_debug - Dump bonsplit layout + selected panel bounds (test-only) bonsplit_underflow_count - Count bonsplit arranged-subview underflow events (test-only) @@ -6638,82 +6826,7 @@ class TerminalController { } private func readTerminalText(_ args: String) -> String { - guard let tabManager = tabManager else { return "ERROR: TabManager not available" } - - let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines) - - var result = "ERROR: No tab selected" - DispatchQueue.main.sync { - guard let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - return - } - - let panelId: UUID? - if panelArg.isEmpty { - panelId = tab.focusedPanelId - } else { - panelId = resolveSurfaceId(from: panelArg, tab: tab) - } - - guard let panelId, - let terminalPanel = tab.terminalPanel(for: panelId), - let surface = terminalPanel.surface.surface else { - result = "ERROR: Terminal surface not found" - return - } - - var selection = ghostty_selection_s( - top_left: ghostty_point_s( - tag: GHOSTTY_POINT_VIEWPORT, - coord: GHOSTTY_POINT_COORD_TOP_LEFT, - x: 0, - y: 0 - ), - bottom_right: ghostty_point_s( - tag: GHOSTTY_POINT_VIEWPORT, - coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, - x: 0, - y: 0 - ), - rectangle: true - ) - var text = ghostty_text_s() - - guard ghostty_surface_read_text(surface, selection, &text) else { - result = "ERROR: Failed to read terminal text" - return - } - defer { - ghostty_surface_free_text(surface, &text) - } - - let b64: String - if let ptr = text.text, text.text_len > 0 { - b64 = Data(bytes: ptr, count: Int(text.text_len)).base64EncodedString() - } else { - b64 = "" - } - - result = "OK \(b64)" - } - return result - } - - private func readScreen(_ args: String) -> String { - let response = readTerminalText(args) - guard response.hasPrefix("OK ") else { return response } - - let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - if payload.isEmpty { - return "" - } - - guard let data = Data(base64Encoded: payload), - let text = String(data: data, encoding: .utf8) else { - return "ERROR: Failed to decode terminal text" - } - return text + readTerminalTextBase64(surfaceArg: args) } private struct RenderStatsResponse: Codable { diff --git a/tests_v2/cmux.py b/tests_v2/cmux.py index bc10f568..f4d03d09 100755 --- a/tests_v2/cmux.py +++ b/tests_v2/cmux.py @@ -830,6 +830,18 @@ class cmux: if panel is not None: sid = self._resolve_surface_id(panel) params["surface_id"] = sid + try: + res = self._call("surface.read_text", params) or {} + if "text" in res: + return str(res.get("text") or "") + b64 = str(res.get("base64") or "") + raw = base64.b64decode(b64) if b64 else b"" + return raw.decode("utf-8", errors="replace") + except cmuxError as exc: + # Back-compat for older builds that only expose the debug method. + if "method_not_found" not in str(exc): + raise + res = self._call("debug.terminal.read_text", params) or {} b64 = str(res.get("base64") or "") raw = base64.b64decode(b64) if b64 else b"" diff --git a/tests_v2/test_read_screen_capture_pane_parity.py b/tests_v2/test_read_screen_capture_pane_parity.py new file mode 100644 index 00000000..c26b854e --- /dev/null +++ b/tests_v2/test_read_screen_capture_pane_parity.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Regression: capture-pane parity via production read-screen APIs.""" + +import glob +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Callable, List + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for(pred: Callable[[], bool], timeout_s: float = 5.0, step_s: float = 0.05) -> None: + start = time.time() + while time.time() - start < timeout_s: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: List[str]) -> str: + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout + + +def main() -> int: + cli = _find_cli_binary() + + with cmux(SOCKET_PATH) as c: + caps = c.capabilities() or {} + methods = set(caps.get("methods") or []) + _must("surface.read_text" in methods, f"Missing surface.read_text in capabilities: {sorted(methods)[:20]}") + + created = c._call("workspace.create") or {} + ws_id = str(created.get("workspace_id") or "") + _must(bool(ws_id), f"workspace.create returned no workspace_id: {created}") + c._call("workspace.select", {"workspace_id": ws_id}) + + surfaces_payload = c._call("surface.list", {"workspace_id": ws_id}) or {} + surfaces = surfaces_payload.get("surfaces") or [] + _must(bool(surfaces), f"Expected at least one surface in workspace: {surfaces_payload}") + surface_id = str(surfaces[0].get("id") or "") + _must(bool(surface_id), f"surface.list returned surface without id: {surfaces_payload}") + + token = f"CMUX_READ_SCREEN_{int(time.time() * 1000)}" + c._call("surface.send_text", { + "workspace_id": ws_id, + "surface_id": surface_id, + "text": f"echo {token}\n", + }) + + def has_token() -> bool: + payload = c._call("surface.read_text", {"workspace_id": ws_id, "surface_id": surface_id}) or {} + return token in str(payload.get("text") or "") + + _wait_for(has_token, timeout_s=5.0) + + read_payload = c._call("surface.read_text", {"workspace_id": ws_id, "surface_id": surface_id}) or {} + text = str(read_payload.get("text") or "") + _must(token in text, f"surface.read_text missing token {token!r}: {read_payload}") + + cli_text = _run_cli(cli, ["read-screen", "--workspace", ws_id, "--surface", surface_id]) + _must(token in cli_text, f"cmux read-screen output missing token {token!r}: {cli_text!r}") + + cli_text_scrollback = _run_cli(cli, ["read-screen", "--workspace", ws_id, "--surface", surface_id, "--scrollback", "--lines", "80"]) + _must(token in cli_text_scrollback, f"cmux read-screen --scrollback output missing token {token!r}: {cli_text_scrollback!r}") + + cli_json = _run_cli(cli, ["--json", "read-screen", "--workspace", ws_id, "--surface", surface_id]) + payload = json.loads(cli_json or "{}") + _must(token in str(payload.get("text") or ""), f"cmux --json read-screen missing token {token!r}: {payload}") + + invalid = subprocess.run( + [cli, "--socket", SOCKET_PATH, "read-screen", "--workspace", ws_id, "--surface", surface_id, "--lines", "0"], + capture_output=True, + text=True, + check=False, + ) + invalid_output = f"{invalid.stdout}\n{invalid.stderr}" + _must(invalid.returncode != 0, "Expected read-screen --lines 0 to fail") + _must("--lines must be greater than 0" in invalid_output, f"Unexpected error for --lines 0: {invalid_output!r}") + + print("PASS: production read-screen APIs expose capture-pane behavior") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 7dbd7811df7ed00ea3e6fa1dcefec1f939038f3e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:31:48 -0800 Subject: [PATCH 23/26] Fix workspace routing for surface.read_text Addresses review feedback from https://github.com/manaflow-ai/cmux/pull/219 by resolving read-screen targets against requested workspace/surface instead of the selected workspace. --- Sources/TerminalController.swift | 156 +++++++++++------- .../test_read_screen_capture_pane_parity.py | 44 +++-- 2 files changed, 127 insertions(+), 73 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index fe9ca6db..0393b02e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2391,6 +2391,10 @@ class TerminalController { } private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + var includeScrollback = v2Bool(params, "scrollback") ?? false let lineLimit = v2Int(params, "lines") if let lineLimit, lineLimit <= 0 { @@ -2400,24 +2404,98 @@ class TerminalController { includeScrollback = true } - let response = readTerminalTextBase64( - surfaceArg: v2String(params, "surface_id") ?? "", - includeScrollback: includeScrollback, - lineLimit: lineLimit - ) - guard response.hasPrefix("OK ") else { - return .err(code: "internal_error", message: response, data: nil) + var result: V2CallResult = .err(code: "internal_error", message: "Failed to read terminal text", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let surfaceId else { + result = .err(code: "not_found", message: "No focused surface", data: nil) + return + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) + return + } + + let response = readTerminalTextBase64( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + guard response.hasPrefix("OK ") else { + result = .err(code: "internal_error", message: response, data: nil) + return + } + let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + let decoded = Data(base64Encoded: base64).flatMap { String(data: $0, encoding: .utf8) } + guard let text = decoded ?? (base64.isEmpty ? "" : nil) else { + result = .err(code: "internal_error", message: "Failed to decode terminal text", data: nil) + return + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "text": text, + "base64": base64, + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) } - let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - let decoded = Data(base64Encoded: base64).flatMap { String(data: $0, encoding: .utf8) } - guard let text = decoded ?? (base64.isEmpty ? "" : nil) else { - return .err(code: "internal_error", message: "Failed to decode terminal text", data: nil) + return result + } + + private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { + guard let surface = terminalPanel.surface.surface else { return "ERROR: Terminal surface not found" } + + let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT + let topLeft = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ) + let bottomRight = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ) + let selection = ghostty_selection_s( + top_left: topLeft, + bottom_right: bottomRight, + rectangle: true + ) + var text = ghostty_text_s() + + guard ghostty_surface_read_text(surface, selection, &text) else { + return "ERROR: Failed to read terminal text" + } + defer { + ghostty_surface_free_text(surface, &text) } - return .ok([ - "text": text, - "base64": base64 - ]) + let rawData: Data + if let ptr = text.text, text.text_len > 0 { + rawData = Data(bytes: ptr, count: Int(text.text_len)) + } else { + rawData = Data() + } + + var output = String(decoding: rawData, as: UTF8.self) + if let lineLimit { + output = tailTerminalLines(output, maxLines: lineLimit) + } + + let base64 = output.data(using: .utf8)?.base64EncodedString() ?? "" + return "OK \(base64)" } private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { @@ -6302,54 +6380,16 @@ class TerminalController { } guard let panelId, - let terminalPanel = tab.terminalPanel(for: panelId), - let surface = terminalPanel.surface.surface else { + let terminalPanel = tab.terminalPanel(for: panelId) else { result = "ERROR: Terminal surface not found" return } - let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT - let topLeft = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_TOP_LEFT, - x: 0, - y: 0 + result = readTerminalTextBase64( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback, + lineLimit: lineLimit ) - let bottomRight = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, - x: 0, - y: 0 - ) - var selection = ghostty_selection_s( - top_left: topLeft, - bottom_right: bottomRight, - rectangle: true - ) - var text = ghostty_text_s() - - guard ghostty_surface_read_text(surface, selection, &text) else { - result = "ERROR: Failed to read terminal text" - return - } - defer { - ghostty_surface_free_text(surface, &text) - } - - let rawData: Data - if let ptr = text.text, text.text_len > 0 { - rawData = Data(bytes: ptr, count: Int(text.text_len)) - } else { - rawData = Data() - } - - var output = String(decoding: rawData, as: UTF8.self) - if let lineLimit { - output = tailTerminalLines(output, maxLines: lineLimit) - } - - let base64 = output.data(using: .utf8)?.base64EncodedString() ?? "" - result = "OK \(base64)" } return result } diff --git a/tests_v2/test_read_screen_capture_pane_parity.py b/tests_v2/test_read_screen_capture_pane_parity.py index c26b854e..a416e8c2 100644 --- a/tests_v2/test_read_screen_capture_pane_parity.py +++ b/tests_v2/test_read_screen_capture_pane_parity.py @@ -66,46 +66,60 @@ def main() -> int: methods = set(caps.get("methods") or []) _must("surface.read_text" in methods, f"Missing surface.read_text in capabilities: {sorted(methods)[:20]}") - created = c._call("workspace.create") or {} - ws_id = str(created.get("workspace_id") or "") - _must(bool(ws_id), f"workspace.create returned no workspace_id: {created}") - c._call("workspace.select", {"workspace_id": ws_id}) + created_target = c._call("workspace.create") or {} + ws_target = str(created_target.get("workspace_id") or "") + _must(bool(ws_target), f"workspace.create returned no workspace_id: {created_target}") + c._call("workspace.select", {"workspace_id": ws_target}) - surfaces_payload = c._call("surface.list", {"workspace_id": ws_id}) or {} + surfaces_payload = c._call("surface.list", {"workspace_id": ws_target}) or {} surfaces = surfaces_payload.get("surfaces") or [] _must(bool(surfaces), f"Expected at least one surface in workspace: {surfaces_payload}") - surface_id = str(surfaces[0].get("id") or "") - _must(bool(surface_id), f"surface.list returned surface without id: {surfaces_payload}") + surface_target = str(surfaces[0].get("id") or "") + _must(bool(surface_target), f"surface.list returned surface without id: {surfaces_payload}") + + created_other = c._call("workspace.create") or {} + ws_other = str(created_other.get("workspace_id") or "") + _must(bool(ws_other), f"workspace.create returned no workspace_id: {created_other}") + c._call("workspace.select", {"workspace_id": ws_other}) + + selected = c._call("workspace.current") or {} + _must(str(selected.get("workspace_id") or "") == ws_other, f"Expected selected workspace {ws_other}, got: {selected}") token = f"CMUX_READ_SCREEN_{int(time.time() * 1000)}" c._call("surface.send_text", { - "workspace_id": ws_id, - "surface_id": surface_id, + "workspace_id": ws_target, + "surface_id": surface_target, "text": f"echo {token}\n", }) def has_token() -> bool: - payload = c._call("surface.read_text", {"workspace_id": ws_id, "surface_id": surface_id}) or {} + payload = c._call("surface.read_text", {"workspace_id": ws_target, "surface_id": surface_target}) or {} return token in str(payload.get("text") or "") _wait_for(has_token, timeout_s=5.0) - read_payload = c._call("surface.read_text", {"workspace_id": ws_id, "surface_id": surface_id}) or {} + read_payload = c._call("surface.read_text", {"workspace_id": ws_target, "surface_id": surface_target}) or {} text = str(read_payload.get("text") or "") _must(token in text, f"surface.read_text missing token {token!r}: {read_payload}") - cli_text = _run_cli(cli, ["read-screen", "--workspace", ws_id, "--surface", surface_id]) + ws_only_payload = c._call("surface.read_text", {"workspace_id": ws_target}) or {} + _must(token in str(ws_only_payload.get("text") or ""), f"surface.read_text workspace-only call missing token {token!r}: {ws_only_payload}") + + cli_text = _run_cli(cli, ["read-screen", "--workspace", ws_target, "--surface", surface_target]) _must(token in cli_text, f"cmux read-screen output missing token {token!r}: {cli_text!r}") - cli_text_scrollback = _run_cli(cli, ["read-screen", "--workspace", ws_id, "--surface", surface_id, "--scrollback", "--lines", "80"]) + cli_ws_only = _run_cli(cli, ["read-screen", "--workspace", ws_target]) + _must(token in cli_ws_only, f"cmux read-screen --workspace output missing token {token!r}: {cli_ws_only!r}") + + cli_text_scrollback = _run_cli(cli, ["read-screen", "--workspace", ws_target, "--surface", surface_target, "--scrollback", "--lines", "80"]) _must(token in cli_text_scrollback, f"cmux read-screen --scrollback output missing token {token!r}: {cli_text_scrollback!r}") - cli_json = _run_cli(cli, ["--json", "read-screen", "--workspace", ws_id, "--surface", surface_id]) + cli_json = _run_cli(cli, ["--json", "read-screen", "--workspace", ws_target, "--surface", surface_target]) payload = json.loads(cli_json or "{}") _must(token in str(payload.get("text") or ""), f"cmux --json read-screen missing token {token!r}: {payload}") invalid = subprocess.run( - [cli, "--socket", SOCKET_PATH, "read-screen", "--workspace", ws_id, "--surface", surface_id, "--lines", "0"], + [cli, "--socket", SOCKET_PATH, "read-screen", "--workspace", ws_target, "--surface", surface_target, "--lines", "0"], capture_output=True, text=True, check=False, From b4bb51c509bf89ec70be643ce62e74adc353f325 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:42:35 -0800 Subject: [PATCH 24/26] Use SHA stamp to safely reuse local xcframework on cache miss Instead of always rebuilding, write a .ghostty_sha stamp file inside the xcframework after each build. On cache miss, only seed from local if the stamp matches the current submodule SHA. This avoids both unnecessary rebuilds and stale ABI mismatches. --- scripts/setup.sh | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index f225000e..bcfeb818 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -21,6 +21,7 @@ CACHE_ROOT="${CMUX_GHOSTTYKIT_CACHE_DIR:-$HOME/.cache/cmux/ghosttykit}" CACHE_DIR="$CACHE_ROOT/$GHOSTTY_SHA" CACHE_XCFRAMEWORK="$CACHE_DIR/GhosttyKit.xcframework" LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework" +LOCAL_SHA_STAMP="$LOCAL_XCFRAMEWORK/.ghostty_sha" LOCK_DIR="$CACHE_ROOT/$GHOSTTY_SHA.lock" mkdir -p "$CACHE_ROOT" @@ -43,11 +44,25 @@ trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT if [ -d "$CACHE_XCFRAMEWORK" ]; then echo "==> Reusing cached GhosttyKit.xcframework" else - echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." - ( - cd ghostty - zig build -Demit-xcframework=true -Doptimize=ReleaseFast - ) + # Only reuse local xcframework if its SHA stamp matches the current ghostty commit. + # Without this check, a stale build from a previous commit could be cached under + # the wrong SHA, producing ABI mismatches. + LOCAL_SHA="" + if [ -f "$LOCAL_SHA_STAMP" ]; then + LOCAL_SHA="$(cat "$LOCAL_SHA_STAMP")" + fi + + if [ -d "$LOCAL_XCFRAMEWORK" ] && [ "$LOCAL_SHA" = "$GHOSTTY_SHA" ]; then + echo "==> Seeding cache from existing local GhosttyKit.xcframework (SHA matches)" + else + echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." + ( + cd ghostty + zig build -Demit-xcframework=true -Doptimize=ReleaseFast + ) + # Stamp the build output with the SHA it was built from + echo "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP" + fi if [ ! -d "$LOCAL_XCFRAMEWORK" ]; then echo "Error: GhosttyKit.xcframework not found at $LOCAL_XCFRAMEWORK" From 6cb282bf096483f222a3b71a55dadf3d8331fa55 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:22:26 -0800 Subject: [PATCH 25/26] tmux compat: implement issue-153 command set with matrix tests (#221) * Add tmux rename-window workspace compatibility Implement workspace.rename in the v2 API and wire CLI commands rename-workspace/rename-window with help text. Add a regression test that validates API and CLI rename parity plus error handling. Refs: https://github.com/manaflow-ai/cmux/issues/153 * Add full tmux compatibility command matrix and regression coverage --- CLI/cmux.swift | 548 ++++++++++++++++++ Sources/TerminalController.swift | 444 ++++++++++++++ tests_v2/cmux.py | 98 ++++ .../test_rename_window_workspace_parity.py | 118 ++++ tests_v2/test_tmux_compat_matrix.py | 232 ++++++++ 5 files changed, 1440 insertions(+) create mode 100644 tests_v2/test_rename_window_workspace_parity.py create mode 100644 tests_v2/test_tmux_compat_matrix.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a6d1ae32..b0740d9f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -860,6 +860,19 @@ struct CMUXCLI { let payload = try client.sendV2(method: "workspace.select", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + case "rename-workspace", "rename-window": + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let titleArgs = rem0.dropFirst(rem0.first == "--" ? 1 : 0) + let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw CLIError(message: "\(command) requires a title") + } + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let params: [String: Any] = ["title": title, "workspace_id": wsId] + let payload = try client.sendV2(method: "workspace.rename", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + case "current-workspace": let response = try client.send(command: "current_workspace") if jsonOutput { @@ -1025,6 +1038,38 @@ struct CMUXCLI { let response = try client.send(command: "simulate_app_active") print(response) + case "capture-pane", + "resize-pane", + "pipe-pane", + "wait-for", + "swap-pane", + "break-pane", + "join-pane", + "last-window", + "last-pane", + "next-window", + "previous-window", + "find-window", + "clear-history", + "set-hook", + "popup", + "bind-key", + "unbind-key", + "copy-mode", + "set-buffer", + "paste-buffer", + "list-buffers", + "respawn-pane", + "display-message": + try runTmuxCompatCommand( + command: command, + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowId + ) + case "help": print(usage()) @@ -2840,6 +2885,54 @@ struct CMUXCLI { cmux select-workspace --workspace workspace:2 cmux select-workspace --workspace 0 """ + case "rename-workspace", "rename-window": + return """ + Usage: cmux rename-workspace [--workspace <id|ref>] [--] <title> + + Rename a workspace. Defaults to the current workspace. + tmux-compatible alias: rename-window + + Flags: + --workspace <id|ref> Workspace to rename (default: current workspace) + + Example: + cmux rename-workspace "backend logs" + cmux rename-window --workspace workspace:2 "agent run" + """ + case "capture-pane": + return """ + Usage: cmux capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] + + tmux-compatible alias for reading terminal text from a pane. + + Example: + cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200 + """ + case "resize-pane": + return """ + Usage: cmux resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>] + + tmux-compatible pane resize command. + Note: currently returns not_supported until programmable divider resize is implemented. + """ + case "pipe-pane": + return """ + Usage: cmux pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>] + + Capture pane text and pipe it to a shell command via stdin. + """ + case "wait-for": + return """ + Usage: cmux wait-for [-S|--signal] <name> [--timeout <seconds>] + + Wait for or signal a named synchronization token. + """ + case "swap-pane", "break-pane", "join-pane", "next-window", "previous-window", "last-window", "last-pane", "find-window", "clear-history", "set-hook", "popup", "bind-key", "unbind-key", "copy-mode", "set-buffer", "paste-buffer", "list-buffers", "respawn-pane", "display-message": + return """ + Usage: cmux \(command) --help + + tmux compatibility command. See `cmux --help` for exact syntax. + """ case "read-screen": return """ Usage: cmux read-screen [flags] @@ -3078,6 +3171,438 @@ struct CMUXCLI { return output } + private struct TmuxCompatStore: Codable { + var buffers: [String: String] = [:] + var hooks: [String: String] = [:] + } + + private func tmuxCompatStoreURL() -> URL { + let root = NSString(string: "~/.cmuxterm").expandingTildeInPath + return URL(fileURLWithPath: root).appendingPathComponent("tmux-compat-store.json") + } + + private func loadTmuxCompatStore() -> TmuxCompatStore { + let url = tmuxCompatStoreURL() + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(TmuxCompatStore.self, from: data) else { + return TmuxCompatStore() + } + return decoded + } + + private func saveTmuxCompatStore(_ store: TmuxCompatStore) throws { + let url = tmuxCompatStoreURL() + let parent = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true, attributes: nil) + let data = try JSONEncoder().encode(store) + try data.write(to: url, options: .atomic) + } + + private func runShellCommand(_ command: String, stdinText: String) throws -> (status: Int32, stdout: String, stderr: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", command] + + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + if let data = stdinText.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + stdinPipe.fileHandleForWriting.closeFile() + process.waitUntilExit() + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, stdout, stderr) + } + + private func tmuxWaitForSignalURL(name: String) -> URL { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let sanitized = name.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } + return URL(fileURLWithPath: "/tmp/cmux-wait-for-\(String(sanitized)).sig") + } + + private func runTmuxCompatCommand( + command: String, + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + switch command { + case "capture-pane": + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (sfArg, rem1) = parseOption(rem0, name: "--surface") + let (linesArg, rem2) = parseOption(rem1, name: "--lines") + let workspaceArg = wsArg ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = sfArg ?? (wsArg == nil && windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + + let includeScrollback = rem2.contains("--scrollback") + if includeScrollback { + params["scrollback"] = true + } + if let linesArg { + guard let lineCount = Int(linesArg), lineCount > 0 else { + throw CLIError(message: "--lines must be greater than 0") + } + params["lines"] = lineCount + params["scrollback"] = true + } + + let payload = try client.sendV2(method: "surface.read_text", params: params) + if jsonOutput { + print(jsonString(payload)) + } else { + print((payload["text"] as? String) ?? "") + } + + case "resize-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let paneArg = optionValue(commandArgs, name: "--pane") + let amountArg = optionValue(commandArgs, name: "--amount") + let amount = Int(amountArg ?? "1") ?? 1 + if amount <= 0 { + throw CLIError(message: "--amount must be greater than 0") + } + + let direction: String = { + if commandArgs.contains("-L") { return "left" } + if commandArgs.contains("-R") { return "right" } + if commandArgs.contains("-U") { return "up" } + if commandArgs.contains("-D") { return "down" } + return "right" + }() + + var params: [String: Any] = ["direction": direction, "amount": amount] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let paneId { params["pane_id"] = paneId } + let payload = try client.sendV2(method: "pane.resize", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"])) + + case "pipe-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + let (cmdOpt, rem0) = parseOption(commandArgs, name: "--command") + let commandText: String = { + if let cmdOpt { return cmdOpt } + let trimmed = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed + }() + guard !commandText.isEmpty else { + throw CLIError(message: "pipe-pane requires --command <shell-command>") + } + + var params: [String: Any] = ["scrollback": true] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.read_text", params: params) + let text = (payload["text"] as? String) ?? "" + let shell = try runShellCommand(commandText, stdinText: text) + if shell.status != 0 { + throw CLIError(message: "pipe-pane command failed (\(shell.status)): \(shell.stderr)") + } + if jsonOutput { + print(jsonString([ + "ok": true, + "status": shell.status, + "stdout": shell.stdout, + "stderr": shell.stderr + ])) + } else { + if !shell.stdout.isEmpty { + print(shell.stdout, terminator: "") + } + print("OK") + } + + case "wait-for": + let signal = commandArgs.contains("-S") || commandArgs.contains("--signal") + let timeoutRaw = optionValue(commandArgs, name: "--timeout") + let timeout = timeoutRaw.flatMap { Double($0) } ?? 30.0 + let name = commandArgs.first(where: { !$0.hasPrefix("-") }) ?? "" + guard !name.isEmpty else { + throw CLIError(message: "wait-for requires a name") + } + let signalURL = tmuxWaitForSignalURL(name: name) + if signal { + FileManager.default.createFile(atPath: signalURL.path, contents: Data()) + print("OK") + return + } + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: signalURL.path) { + try? FileManager.default.removeItem(at: signalURL) + print("OK") + return + } + Thread.sleep(forTimeInterval: 0.05) + } + throw CLIError(message: "wait-for timed out waiting for '\(name)'") + + case "swap-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + guard let sourcePaneRaw = optionValue(commandArgs, name: "--pane") else { + throw CLIError(message: "swap-pane requires --pane") + } + guard let targetPaneRaw = optionValue(commandArgs, name: "--target-pane") else { + throw CLIError(message: "swap-pane requires --target-pane") + } + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sourcePane = try normalizePaneHandle(sourcePaneRaw, client: client, workspaceHandle: wsId) + let targetPane = try normalizePaneHandle(targetPaneRaw, client: client, workspaceHandle: wsId) + if let sourcePane { params["pane_id"] = sourcePane } + if let targetPane { params["target_pane_id"] = targetPane } + let payload = try client.sendV2(method: "pane.swap", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "break-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let paneArg = optionValue(commandArgs, name: "--pane") + let surfaceArg = optionValue(commandArgs, name: "--surface") + var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId) + if let paneId { params["pane_id"] = paneId } + let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let surfaceId { params["surface_id"] = surfaceId } + let payload = try client.sendV2(method: "pane.break", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "join-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let sourcePaneArg = optionValue(commandArgs, name: "--pane") + let surfaceArg = optionValue(commandArgs, name: "--surface") + guard let targetPaneArg = optionValue(commandArgs, name: "--target-pane") else { + throw CLIError(message: "join-pane requires --target-pane") + } + var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sourcePaneId = try normalizePaneHandle(sourcePaneArg, client: client, workspaceHandle: wsId) + if let sourcePaneId { params["pane_id"] = sourcePaneId } + let targetPaneId = try normalizePaneHandle(targetPaneArg, client: client, workspaceHandle: wsId) + if let targetPaneId { params["target_pane_id"] = targetPaneId } + let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let surfaceId { params["surface_id"] = surfaceId } + let payload = try client.sendV2(method: "pane.join", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "last-window": + let payload = try client.sendV2(method: "workspace.last") + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + + case "next-window": + let payload = try client.sendV2(method: "workspace.next") + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + + case "previous-window": + let payload = try client.sendV2(method: "workspace.previous") + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + + case "last-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let payload = try client.sendV2(method: "pane.last", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"])) + + case "find-window": + let includeContent = commandArgs.contains("--content") + let shouldSelect = commandArgs.contains("--select") + let query = commandArgs + .filter { !$0.hasPrefix("-") } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + let listPayload = try client.sendV2(method: "workspace.list") + let workspaces = listPayload["workspaces"] as? [[String: Any]] ?? [] + + var matches: [[String: Any]] = [] + for ws in workspaces { + let title = (ws["title"] as? String) ?? "" + let titleMatch = query.isEmpty || title.localizedCaseInsensitiveContains(query) + var contentMatch = false + if includeContent && !query.isEmpty, let wsId = ws["id"] as? String { + let textPayload = try? client.sendV2(method: "surface.read_text", params: ["workspace_id": wsId]) + let text = (textPayload?["text"] as? String) ?? "" + contentMatch = text.localizedCaseInsensitiveContains(query) + } + if titleMatch || contentMatch { + matches.append(ws) + } + } + + if shouldSelect, let first = matches.first, let wsId = first["id"] as? String { + _ = try client.sendV2(method: "workspace.select", params: ["workspace_id": wsId]) + } + + if jsonOutput { + let formatted = formatIDs(["matches": matches], mode: idFormat) as? [String: Any] + print(jsonString(["matches": formatted?["matches"] ?? []])) + } else if matches.isEmpty { + print("No matches") + } else { + for item in matches { + let handle = textHandle(item, idFormat: idFormat) + let title = (item["title"] as? String) ?? "" + print("\(handle) \"\(title)\"") + } + } + + case "clear-history": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.clear_history", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) + + case "set-hook": + var store = loadTmuxCompatStore() + if commandArgs.contains("--list") { + if jsonOutput { + print(jsonString(["hooks": store.hooks])) + } else if store.hooks.isEmpty { + print("No hooks configured") + } else { + for (event, hookCmd) in store.hooks.sorted(by: { $0.key < $1.key }) { + print("\(event) -> \(hookCmd)") + } + } + return + } + if commandArgs.contains("--unset") { + guard let event = commandArgs.last else { + throw CLIError(message: "set-hook --unset requires an event name") + } + store.hooks.removeValue(forKey: event) + try saveTmuxCompatStore(store) + print("OK") + return + } + guard let event = commandArgs.first(where: { !$0.hasPrefix("-") }) else { + throw CLIError(message: "set-hook requires <event> <command>") + } + let commandText = commandArgs.drop(while: { $0 != event }).dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !commandText.isEmpty else { + throw CLIError(message: "set-hook requires <event> <command>") + } + store.hooks[event] = commandText + try saveTmuxCompatStore(store) + print("OK") + + case "popup": + throw CLIError(message: "popup is not supported yet in cmux CLI parity mode") + + case "bind-key", "unbind-key", "copy-mode": + throw CLIError(message: "\(command) is not supported yet in cmux CLI parity mode") + + case "set-buffer": + let (nameArg, rem0) = parseOption(commandArgs, name: "--name") + let name = (nameArg?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? nameArg! : "default" + let content = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { + throw CLIError(message: "set-buffer requires text") + } + var store = loadTmuxCompatStore() + store.buffers[name] = content + try saveTmuxCompatStore(store) + print("OK") + + case "list-buffers": + let store = loadTmuxCompatStore() + if jsonOutput { + let payload = store.buffers.map { key, value in ["name": key, "size": value.count] } + print(jsonString(["buffers": payload.sorted { ($0["name"] as? String ?? "") < ($1["name"] as? String ?? "") }])) + } else if store.buffers.isEmpty { + print("No buffers") + } else { + for key in store.buffers.keys.sorted() { + let size = store.buffers[key]?.count ?? 0 + print("\(key)\t\(size)") + } + } + + case "paste-buffer": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + let name = optionValue(commandArgs, name: "--name") ?? "default" + let store = loadTmuxCompatStore() + guard let buffer = store.buffers[name] else { + throw CLIError(message: "Buffer not found: \(name)") + } + var params: [String: Any] = ["text": buffer] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.send_text", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "respawn-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") + let commandText = (commandOpt ?? rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines) + let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText + var params: [String: Any] = ["text": finalCommand + "\n"] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.send_text", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "display-message": + let printOnly = commandArgs.contains("-p") || commandArgs.contains("--print") + let message = commandArgs + .filter { !$0.hasPrefix("-") } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { + throw CLIError(message: "display-message requires text") + } + if printOnly { + print(message) + return + } + let payload = try client.sendV2(method: "notification.create", params: ["title": "cmux", "body": message]) + if jsonOutput { + print(jsonString(payload)) + } else { + print(message) + } + + default: + throw CLIError(message: "Unsupported tmux compatibility command: \(command)") + } + } + private func runClaudeHook(commandArgs: [String], client: SocketClient) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) @@ -3515,6 +4040,8 @@ struct CMUXCLI { focus-panel --panel <id|ref> [--workspace <id|ref>] close-workspace --workspace <id|ref> select-workspace --workspace <id|ref> + rename-workspace [--workspace <id|ref>] <title> + rename-window [--workspace <id|ref>] <title> current-workspace read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] send [--workspace <id|ref>] [--surface <id|ref>] <text> @@ -3528,6 +4055,27 @@ struct CMUXCLI { set-app-focus <active|inactive|clear> simulate-app-active + # tmux compatibility commands + capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] + resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>] + pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>] + wait-for [-S|--signal] <name> [--timeout <seconds>] + swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] + break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + next-window | previous-window | last-window + last-pane [--workspace <id|ref>] + find-window [--content] [--select] <query> + clear-history [--workspace <id|ref>] [--surface <id|ref>] + set-hook [--list] [--unset <event>] | <event> <command> + popup + bind-key | unbind-key | copy-mode + set-buffer [--name <name>] <text> + list-buffers + paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>] + respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd>] + display-message [-p|--print] <text> + browser [--surface <id|ref|index> | <surface>] <subcommand> ... browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate) browser open-split [url] diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0393b02e..7e4d4480 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -667,6 +667,14 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceMoveToWindow(params: params)) case "workspace.reorder": return v2Result(id: id, self.v2WorkspaceReorder(params: params)) + case "workspace.rename": + return v2Result(id: id, self.v2WorkspaceRename(params: params)) + case "workspace.next": + return v2Result(id: id, self.v2WorkspaceNext(params: params)) + case "workspace.previous": + return v2Result(id: id, self.v2WorkspacePrevious(params: params)) + case "workspace.last": + return v2Result(id: id, self.v2WorkspaceLast(params: params)) // Surfaces / input @@ -696,6 +704,8 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceSendText(params: params)) case "surface.send_key": return v2Result(id: id, self.v2SurfaceSendKey(params: params)) + case "surface.clear_history": + return v2Result(id: id, self.v2SurfaceClearHistory(params: params)) case "surface.trigger_flash": return v2Result(id: id, self.v2SurfaceTriggerFlash(params: params)) @@ -708,6 +718,16 @@ class TerminalController { return v2Result(id: id, self.v2PaneSurfaces(params: params)) case "pane.create": return v2Result(id: id, self.v2PaneCreate(params: params)) + case "pane.resize": + return v2Result(id: id, self.v2PaneResize(params: params)) + case "pane.swap": + return v2Result(id: id, self.v2PaneSwap(params: params)) + case "pane.break": + return v2Result(id: id, self.v2PaneBreak(params: params)) + case "pane.join": + return v2Result(id: id, self.v2PaneJoin(params: params)) + case "pane.last": + return v2Result(id: id, self.v2PaneLast(params: params)) // Notifications case "notification.create": @@ -962,6 +982,10 @@ class TerminalController { "workspace.close", "workspace.move_to_window", "workspace.reorder", + "workspace.rename", + "workspace.next", + "workspace.previous", + "workspace.last", "surface.list", "surface.current", "surface.focus", @@ -976,11 +1000,17 @@ class TerminalController { "surface.send_text", "surface.send_key", "surface.read_text", + "surface.clear_history", "surface.trigger_flash", "pane.list", "pane.focus", "pane.surfaces", "pane.create", + "pane.resize", + "pane.swap", + "pane.break", + "pane.join", + "pane.last", "notification.create", "notification.create_for_surface", "notification.create_for_target", @@ -1686,6 +1716,116 @@ class TerminalController { "index": v2OrNull(newIndex) ]) } + private func v2WorkspaceRename(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let titleRaw = v2String(params, "title"), + !titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .err(code: "invalid_params", message: "Missing or invalid title", data: nil) + } + + let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines) + var renamed = false + v2MainSync { + guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return } + tabManager.setCustomTitle(tabId: workspaceId, title: title) + renamed = true + } + + guard renamed else { + return .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) + ]) + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "title": title + ]) + } + private func v2WorkspaceNext(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) + v2MainSync { + guard tabManager.selectedTabId != nil else { return } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectNextTab() + guard let workspaceId = tabManager.selectedTabId else { return } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } + + private func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) + v2MainSync { + guard tabManager.selectedTabId != nil else { return } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectPreviousTab() + guard let workspaceId = tabManager.selectedTabId else { return } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } + + private func v2WorkspaceLast(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) + v2MainSync { + guard let before = tabManager.selectedTabId else { return } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.navigateBack() + guard let after = tabManager.selectedTabId, after != before else { return } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": after.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: after), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } // MARK: - V2 Surface Methods @@ -2390,6 +2530,47 @@ class TerminalController { return result } + private func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear history", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let surfaceId else { + result = .err(code: "not_found", message: "No focused surface", data: nil) + return + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) + return + } + + guard terminalPanel.performBindingAction("clear_screen") else { + result = .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil) + return + } + + terminalPanel.surface.forceRefresh() + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + + return result + } + private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -2725,6 +2906,269 @@ class TerminalController { return result } + private func v2PaneResize(params: [String: Any]) -> V2CallResult { + let direction = (v2String(params, "direction") ?? "").lowercased() + let amount = v2Int(params, "amount") ?? 1 + guard ["left", "right", "up", "down"].contains(direction), amount > 0 else { + return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil) + } + return .err( + code: "not_supported", + message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API", + data: [ + "direction": direction, + "amount": amount + ] + ) + } + + private func v2PaneSwap(params: [String: Any]) -> V2CallResult { + guard let sourcePaneUUID = v2UUID(params, "pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil) + } + guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil) + } + if sourcePaneUUID == targetPaneUUID { + return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) + } + let focus = v2Bool(params, "focus") ?? true + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) + v2MainSync { + guard let located = v2LocatePane(sourcePaneUUID) else { + result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString]) + return + } + guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else { + result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString]) + return + } + let workspace = located.workspace + let sourcePane = located.paneId + + guard let selectedSourceTab = workspace.bonsplitController.selectedTab(inPane: sourcePane), + let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane), + let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id), + let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else { + result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil) + return + } + + // Keep pane identities stable during swap when one side has a single surface. + var sourcePlaceholder: UUID? + var targetPlaceholder: UUID? + if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 { + sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id + if sourcePlaceholder == nil { + result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil) + return + } + } + if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 { + targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id + if targetPlaceholder == nil { + result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil) + return + } + } + + guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else { + result = .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil) + return + } + guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else { + result = .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil) + return + } + + if let sourcePlaceholder { + _ = workspace.closePanel(sourcePlaceholder, force: true) + } + if let targetPlaceholder { + _ = workspace.closePanel(targetPlaceholder, force: true) + } + + if focus { + workspace.bonsplitController.focusPane(targetPane) + } + let windowId = located.windowId + result = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "pane_id": sourcePane.id.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id), + "target_pane_id": targetPane.id.uuidString, + "target_pane_ref": v2Ref(kind: .pane, uuid: targetPane.id), + "source_surface_id": sourceSurfaceId.uuidString, + "source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId), + "target_surface_id": targetSurfaceId.uuidString, + "target_surface_ref": v2Ref(kind: .surface, uuid: targetSurfaceId) + ]) + } + return result + } + + private func v2PaneBreak(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let focus = v2Bool(params, "focus") ?? true + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) + v2MainSync { + guard let sourceWorkspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let sourcePaneUUID = v2UUID(params, "pane_id") + let sourcePane: PaneID? = { + if let sourcePaneUUID { + return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == sourcePaneUUID }) + } + return sourceWorkspace.bonsplitController.focusedPaneId + }() + + let surfaceId: UUID? = { + if let explicitSurface = v2UUID(params, "surface_id") { return explicitSurface } + if let sourcePane, + let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) { + return sourceWorkspace.panelIdFromSurfaceId(selected.id) + } + return sourceWorkspace.focusedPanelId + }() + guard let surfaceId else { + result = .err(code: "not_found", message: "No source surface to break", data: nil) + return + } + guard sourceWorkspace.panels[surfaceId] != nil else { + result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) + return + } + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId) + let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId) + + guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else { + result = .err(code: "internal_error", message: "Failed to detach source surface", data: nil) + return + } + + let destinationWorkspace = tabManager.addWorkspace() + guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first else { + if let sourcePaneForRollback { + _ = sourceWorkspace.attachDetachedSurface( + detached, + inPane: sourcePaneForRollback, + atIndex: sourceIndex, + focus: true + ) + } + result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) + return + } + + guard destinationWorkspace.attachDetachedSurface(detached, inPane: destinationPane, focus: focus) != nil else { + if let sourcePaneForRollback { + _ = sourceWorkspace.attachDetachedSurface( + detached, + inPane: sourcePaneForRollback, + atIndex: sourceIndex, + focus: true + ) + } + result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) + return + } + + if !focus { + tabManager.selectWorkspace(sourceWorkspace) + } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": destinationWorkspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: destinationWorkspace.id), + "pane_id": destinationPane.id.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId) + ]) + } + return result + } + + private func v2PaneJoin(params: [String: Any]) -> V2CallResult { + guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil) + } + + var surfaceId = v2UUID(params, "surface_id") + if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") { + guard let sourceLocated = v2LocatePane(sourcePaneUUID), + let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId), + let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else { + return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [ + "pane_id": sourcePaneUUID.uuidString + ]) + } + surfaceId = selectedSurface + } + guard let surfaceId else { + return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil) + } + + var moveParams: [String: Any] = [ + "surface_id": surfaceId.uuidString, + "pane_id": targetPaneUUID.uuidString + ] + if let focus = v2Bool(params, "focus") { + moveParams["focus"] = focus + } + return v2SurfaceMove(params: moveParams) + } + + private func v2PaneLast(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No alternate pane available", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + guard let focused = ws.bonsplitController.focusedPaneId else { + result = .err(code: "not_found", message: "No focused pane", data: nil) + return + } + guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else { + result = .err(code: "not_found", message: "No alternate pane available", data: nil) + return + } + + ws.bonsplitController.focusPane(target) + let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": target.id.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: target.id), + "surface_id": v2OrNull(selectedSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceId) + ]) + } + return result + } + // MARK: - V2 Notification Methods private func v2NotificationCreate(params: [String: Any]) -> V2CallResult { diff --git a/tests_v2/cmux.py b/tests_v2/cmux.py index f4d03d09..cf94aae2 100755 --- a/tests_v2/cmux.py +++ b/tests_v2/cmux.py @@ -398,12 +398,43 @@ class cmux: wsid = self._resolve_workspace_id(workspace) self._call("workspace.select", {"workspace_id": wsid}) + def rename_workspace(self, title: str, workspace: Union[str, int, None] = None) -> None: + renamed = str(title).strip() + if not renamed: + raise cmuxError("rename_workspace requires a non-empty title") + wsid = self._resolve_workspace_id(workspace) + params: Dict[str, Any] = {"title": renamed} + if wsid: + params["workspace_id"] = wsid + self._call("workspace.rename", params) + def current_workspace(self) -> str: wsid = self._resolve_workspace_id(None) if not wsid: raise cmuxError("No current workspace") return wsid + def next_workspace(self) -> str: + res = self._call("workspace.next") or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"workspace.next returned no workspace_id: {res}") + return str(wsid) + + def previous_workspace(self) -> str: + res = self._call("workspace.previous") or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"workspace.previous returned no workspace_id: {res}") + return str(wsid) + + def last_workspace(self) -> str: + res = self._call("workspace.last") or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"workspace.last returned no workspace_id: {res}") + return str(wsid) + def move_workspace_to_window(self, workspace: Union[str, int], window_id: str, focus: bool = True) -> None: wsid = self._resolve_workspace_id(workspace) self._call( @@ -639,6 +670,18 @@ class cmux: res = self._call("surface.health", params) or {} return list(res.get("surfaces") or []) + def clear_history(self, surface: Union[str, int, None] = None, workspace: Union[str, int, None] = None) -> None: + params: Dict[str, Any] = {} + if workspace is not None: + wsid = self._resolve_workspace_id(workspace) + params["workspace_id"] = wsid + if surface is not None: + sid = self._resolve_surface_id(surface, workspace_id=params.get("workspace_id")) + if not sid: + raise cmuxError(f"Invalid surface: {surface!r}") + params["surface_id"] = sid + self._call("surface.clear_history", params) + # --------------------------------------------------------------------- # Pane commands # --------------------------------------------------------------------- @@ -677,6 +720,61 @@ class cmux: )) return out + def swap_pane(self, pane: Union[str, int], target_pane: Union[str, int], focus: bool = True) -> None: + source = self._resolve_pane_id(pane) + target = self._resolve_pane_id(target_pane) + if not source or not target: + raise cmuxError(f"Invalid panes: pane={pane!r}, target_pane={target_pane!r}") + self._call("pane.swap", {"pane_id": source, "target_pane_id": target, "focus": bool(focus)}) + + def break_pane(self, pane: Union[str, int, None] = None, surface: Union[str, int, None] = None, focus: bool = True) -> str: + params: Dict[str, Any] = {"focus": bool(focus)} + if pane is not None: + pid = self._resolve_pane_id(pane) + if not pid: + raise cmuxError(f"Invalid pane: {pane!r}") + params["pane_id"] = pid + if surface is not None: + sid = self._resolve_surface_id(surface) + if not sid: + raise cmuxError(f"Invalid surface: {surface!r}") + params["surface_id"] = sid + res = self._call("pane.break", params) or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"pane.break returned no workspace_id: {res}") + return str(wsid) + + def join_pane( + self, + target_pane: Union[str, int], + pane: Union[str, int, None] = None, + surface: Union[str, int, None] = None, + focus: bool = True, + ) -> None: + target = self._resolve_pane_id(target_pane) + if not target: + raise cmuxError(f"Invalid target_pane: {target_pane!r}") + params: Dict[str, Any] = {"target_pane_id": target, "focus": bool(focus)} + if pane is not None: + source = self._resolve_pane_id(pane) + if not source: + raise cmuxError(f"Invalid pane: {pane!r}") + params["pane_id"] = source + if surface is not None: + sid = self._resolve_surface_id(surface) + if not sid: + raise cmuxError(f"Invalid surface: {surface!r}") + params["surface_id"] = sid + self._call("pane.join", params) + + def last_pane(self) -> str: + res = self._call("pane.last") or {} + pid = res.get("pane_id") + if not pid: + raise cmuxError(f"pane.last returned no pane_id: {res}") + return str(pid) + # --------------------------------------------------------------------- # Input # --------------------------------------------------------------------- diff --git a/tests_v2/test_rename_window_workspace_parity.py b/tests_v2/test_rename_window_workspace_parity.py new file mode 100644 index 00000000..13e564c1 --- /dev/null +++ b/tests_v2/test_rename_window_workspace_parity.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Regression: tmux rename-window parity via workspace.rename + CLI aliases.""" + +import glob +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import List + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: List[str]) -> str: + env = dict(os.environ) + # Keep this test deterministic when running from inside another cmux shell. + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout + + +def _workspace_title(c: cmux, workspace_id: str) -> str: + payload = c._call("workspace.list") or {} + for row in payload.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id: + return str(row.get("title") or "") + raise cmuxError(f"workspace.list missing workspace {workspace_id}: {payload}") + + +def main() -> int: + cli = _find_cli_binary() + stamp = int(time.time() * 1000) + + with cmux(SOCKET_PATH) as c: + caps = c.capabilities() or {} + methods = set(caps.get("methods") or []) + _must("workspace.rename" in methods, f"Missing workspace.rename in capabilities: {sorted(methods)[:30]}") + + created = c._call("workspace.create") or {} + ws_id = str(created.get("workspace_id") or "") + _must(bool(ws_id), f"workspace.create returned no workspace_id: {created}") + c._call("workspace.select", {"workspace_id": ws_id}) + + api_title = f"tmux-api-{stamp}" + c.rename_workspace(api_title, workspace=ws_id) + _must(_workspace_title(c, ws_id) == api_title, "workspace.rename API did not update workspace title") + + cli_title = f"tmux cli {stamp}" + _run_cli(cli, ["rename-workspace", "--workspace", ws_id, cli_title]) + _must(_workspace_title(c, ws_id) == cli_title, "cmux rename-workspace did not update workspace title") + + alias_title = f"tmux alias {stamp}" + _run_cli(cli, ["rename-window", "--workspace", ws_id, alias_title]) + _must(_workspace_title(c, ws_id) == alias_title, "cmux rename-window did not update workspace title") + + current_title = f"tmux current {stamp}" + _run_cli(cli, ["rename-window", current_title]) + _must( + _workspace_title(c, ws_id) == current_title, + "cmux rename-window without --workspace should target current workspace", + ) + + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + invalid = subprocess.run( + [cli, "--socket", SOCKET_PATH, "rename-window", "--workspace", ws_id], + capture_output=True, + text=True, + check=False, + env=env, + ) + invalid_output = f"{invalid.stdout}\n{invalid.stderr}" + _must(invalid.returncode != 0, "Expected rename-window without title to fail") + _must( + "rename-window requires a title" in invalid_output, + f"Unexpected error for rename-window without title: {invalid_output!r}", + ) + + print("PASS: tmux rename-window parity works via workspace.rename and CLI aliases") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_tmux_compat_matrix.py b/tests_v2/test_tmux_compat_matrix.py new file mode 100644 index 00000000..876e4130 --- /dev/null +++ b/tests_v2/test_tmux_compat_matrix.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Regression: tmux compatibility command matrix (implemented + explicit not-supported).""" + +import glob +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Callable, List, Tuple + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for(pred: Callable[[], bool], timeout_s: float = 5.0, step_s: float = 0.05) -> None: + start = time.time() + while time.time() - start < timeout_s: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: List[str], *, expect_ok: bool = True) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if expect_ok and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc + + +def _pane_selected_surface(c: cmux, pane_id: str) -> str: + rows = c.list_pane_surfaces(pane_id) + for _idx, sid, _title, selected in rows: + if selected: + return sid + if rows: + return rows[0][1] + raise cmuxError(f"pane {pane_id} has no surfaces") + + +def _pane_surface_ids(c: cmux, pane_id: str) -> List[str]: + rows = c.list_pane_surfaces(pane_id) + return [sid for _idx, sid, _title, _selected in rows] + + +def _surface_has(c: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + payload = c._call("surface.read_text", {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}) or {} + return token in str(payload.get("text") or "") + + +def main() -> int: + cli = _find_cli_binary() + stamp = int(time.time() * 1000) + + with cmux(SOCKET_PATH) as c: + caps = c.capabilities() or {} + methods = set(caps.get("methods") or []) + for method in [ + "workspace.next", + "workspace.previous", + "workspace.last", + "pane.swap", + "pane.break", + "pane.join", + "pane.last", + "surface.clear_history", + ]: + _must(method in methods, f"Missing capability {method!r}") + + ws = c.new_workspace() + c.select_workspace(ws) + _ = c.new_split("right") + time.sleep(0.2) + + panes = [pid for _pidx, pid, _count, _focused in c.list_panes()] + _must(len(panes) >= 2, f"Expected >=2 panes, got {panes}") + p1, p2 = panes[0], panes[1] + + s1 = _pane_selected_surface(c, p1) + s2 = _pane_selected_surface(c, p2) + + capture_token = f"TMUX_CAPTURE_{stamp}" + c.send_surface(s1, f"echo {capture_token}\n") + _wait_for(lambda: _surface_has(c, ws, s1, capture_token)) + + cap = _run_cli(cli, ["capture-pane", "--workspace", ws, "--surface", s1, "--scrollback"]) + _must(capture_token in cap.stdout, f"capture-pane missing token: {cap.stdout!r}") + + pipe_file = Path(tempfile.gettempdir()) / f"cmux_pipe_pane_{stamp}.log" + _run_cli(cli, ["pipe-pane", "--workspace", ws, "--surface", s1, "--command", f"cat > {pipe_file}"]) + piped = pipe_file.read_text() if pipe_file.exists() else "" + _must(capture_token in piped, f"pipe-pane output missing token: {piped!r}") + + wait_name = f"tmux_wait_{stamp}" + waiter = _run_cli(cli, ["wait-for", wait_name, "--timeout", "5"], expect_ok=False) + _must(waiter.returncode != 0, "wait-for without signal should time out when run synchronously in test") + signaler = subprocess.Popen( + [cli, "--socket", SOCKET_PATH, "wait-for", wait_name, "--timeout", "5"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID"}}, + ) + time.sleep(0.2) + _run_cli(cli, ["wait-for", "-S", wait_name]) + out, err = signaler.communicate(timeout=5) + _must(signaler.returncode == 0, f"wait-for signal/wait failed: out={out!r} err={err!r}") + + title = f"tmux-title-{stamp}" + _run_cli(cli, ["rename-window", "--workspace", ws, title]) + find = _run_cli(cli, ["find-window", title]) + _must(title in find.stdout, f"find-window title search failed: {find.stdout!r}") + + ws2 = c.new_workspace() + ws3 = c.new_workspace() + c.select_workspace(ws) + c.select_workspace(ws2) + _run_cli(cli, ["last-window"]) + _must(c.current_workspace() == ws, f"last-window should navigate history back to ws={ws}") + _run_cli(cli, ["next-window"]) + _must(c.current_workspace() == ws2, f"next-window should move to ws2={ws2}") + _run_cli(cli, ["previous-window"]) + _must(c.current_workspace() == ws, f"previous-window should move back to ws={ws}") + c.select_workspace(ws) + + pre_p1 = _pane_selected_surface(c, p1) + pre_p2 = _pane_selected_surface(c, p2) + _run_cli(cli, ["swap-pane", "--workspace", ws, "--pane", p1, "--target-pane", p2]) + post_p1_ids = set(_pane_surface_ids(c, p1)) + post_p2_ids = set(_pane_surface_ids(c, p2)) + _must(pre_p2 in post_p1_ids, f"swap-pane should move target surface into source pane (p1={post_p1_ids}, pre_p2={pre_p2})") + _must(pre_p1 in post_p2_ids, f"swap-pane should move source surface into target pane (p2={post_p2_ids}, pre_p1={pre_p1})") + + s_break = _pane_selected_surface(c, p1) + br = _run_cli(cli, ["--json", "--id-format", "both", "break-pane", "--workspace", ws, "--surface", s_break]) + br_payload = json.loads(br.stdout or "{}") + ws_break = str(br_payload.get("workspace_id") or "") + _must(bool(ws_break), f"break-pane returned invalid payload: {br_payload}") + _must(ws_break in [wid for _idx, wid, _title, _sel in c.list_workspaces()], "break-pane workspace missing from list") + _run_cli(cli, ["join-pane", "--workspace", ws, "--surface", s_break, "--target-pane", p2]) + _must(s_break in _pane_surface_ids(c, p2), f"join-pane should move broken surface into target pane {p2}") + + current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()] + if len(current_panes) < 2: + _ = c.new_split("right") + time.sleep(0.2) + current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()] + _must(len(current_panes) >= 2, f"Expected >=2 panes after break/join, got {current_panes}") + lp_source, lp_target = current_panes[0], current_panes[1] + + c.focus_pane(lp_source) + c.focus_pane(lp_target) + _run_cli(cli, ["last-pane", "--workspace", ws]) + ident = c.identify() + focused = ident.get("focused") or {} + _must( + str(focused.get("pane_id") or "") == lp_source, + f"last-pane should focus previous pane {lp_source}, focused={focused}", + ) + + _run_cli(cli, ["clear-history", "--workspace", ws, "--surface", s1]) + + _run_cli(cli, ["set-hook", "workspace-created", "echo created"]) + hooks = _run_cli(cli, ["set-hook", "--list"]) + _must("workspace-created" in hooks.stdout, f"set-hook --list missing stored hook: {hooks.stdout!r}") + _run_cli(cli, ["set-hook", "--unset", "workspace-created"]) + hooks2 = _run_cli(cli, ["set-hook", "--list"]) + _must("workspace-created" not in hooks2.stdout, f"set-hook --unset failed: {hooks2.stdout!r}") + + for cmd in (["popup"], ["bind-key", "C-b", "split-window"], ["unbind-key", "C-b"], ["copy-mode"]): + proc = _run_cli(cli, cmd, expect_ok=False) + merged = f"{proc.stdout}\n{proc.stderr}".lower() + _must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}") + + resize = _run_cli(cli, ["resize-pane", "--pane", lp_source, "-L", "--amount", "5"], expect_ok=False) + _must(resize.returncode != 0, "Expected resize-pane to return not_supported until backend support is added") + + buffer_token = f"TMUX_BUFFER_{stamp}" + _run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"]) + buffers = _run_cli(cli, ["list-buffers"]) + _must("tmuxbuf" in buffers.stdout, f"list-buffers missing tmuxbuf: {buffers.stdout!r}") + _run_cli(cli, ["paste-buffer", "--name", "tmuxbuf", "--workspace", ws, "--surface", s1]) + _wait_for(lambda: _surface_has(c, ws, s1, buffer_token)) + + respawn_token = f"TMUX_RESPAWN_{stamp}" + _run_cli(cli, ["respawn-pane", "--workspace", ws, "--surface", s1, "--command", f"echo {respawn_token}"]) + _wait_for(lambda: _surface_has(c, ws, s1, respawn_token)) + + msg = f"tmux-message-{stamp}" + shown = _run_cli(cli, ["display-message", "-p", msg]) + _must(msg in shown.stdout, f"display-message -p should print message: {shown.stdout!r}") + + print("PASS: tmux compatibility matrix commands are wired and tested") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 04431751ceeaec7d5e6fe443fdd05902e7629161 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Fri, 20 Feb 2026 18:26:23 -0800 Subject: [PATCH 26/26] Fix file drag-and-drop and file input in browser panel (#214) * Fix file drag-and-drop and file input in browser panel (#194) Two fixes for the browser panel: 1. File drag-and-drop from Finder: CmuxWebView previously suppressed ALL drag type registration as a no-op to prevent bonsplit tab drags from being intercepted. Now it selectively filters out only the text-based types that conflict with bonsplit (public.text, public.utf8-plain-text, public.plain-text) and the custom tab transfer types, while allowing file URL types through so Finder drops work. 2. File <input> elements: Added the WKUIDelegate runOpenPanelWith method to BrowserUIDelegate so clicking a file input opens the native macOS file picker (NSOpenPanel), with support for multiple selection and directory picking as specified by the HTML element. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore(claude-opus-4-6): take a look at https://github.com/manaflow-ai/cmux/issues... * ok * wok --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- Sources/ContentView.swift | 66 ++++++++++++++++++++++++++++--- Sources/Panels/BrowserPanel.swift | 16 ++++++++ Sources/Panels/CmuxWebView.swift | 17 ++++++-- 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e5b5bea3..7e77f333 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3,6 +3,7 @@ import Bonsplit import SwiftUI import ObjectiveC import UniformTypeIdentifiers +import WebKit struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 @@ -172,6 +173,9 @@ final class FileDropOverlayView: NSView { /// Fallback handler when no terminal is found under the drop point. var onDrop: (([URL]) -> Bool)? private var isForwardingMouseEvent = false + /// The WKWebView currently receiving forwarded drag events, so we can + /// synthesize draggingExited/draggingEntered as the cursor moves. + private weak var activeDragWebView: WKWebView? override var acceptsFirstResponder: Bool { false } @@ -248,30 +252,82 @@ final class FileDropOverlayView: NSView { override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } override func scrollWheel(with event: NSEvent) { forwardEvent(event) } - // MARK: NSDraggingDestination – only accept file drops over terminal views. + // MARK: NSDraggingDestination – accept file drops over terminal and browser views. + // + // AppKit sends draggingEntered once when the drag enters this overlay, then + // draggingUpdated as the cursor moves within it. We track which WKWebView (if + // any) is under the cursor and synthesize enter/exit calls so the browser's + // HTML5 drag events (dragenter, dragleave, drop) fire correctly. override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - return dragOperationForSender(sender) + return updateDragTarget(sender) } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - return dragOperationForSender(sender) + return updateDragTarget(sender) + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + if let prev = activeDragWebView { + prev.draggingExited(sender) + activeDragWebView = nil + } } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let webView = activeDragWebView + activeDragWebView = nil + if let webView { + return webView.performDragOperation(sender) + } guard let terminal = terminalUnderPoint(sender.draggingLocation) else { return false } return terminal.performDragOperation(sender) } - private func dragOperationForSender(_ sender: any NSDraggingInfo) -> NSDragOperation { + private func updateDragTarget(_ sender: any NSDraggingInfo) -> NSDragOperation { + let loc = sender.draggingLocation + let webView = webViewUnderPoint(loc) + + // Cursor moved away from the previous web view. + if let prev = activeDragWebView, prev !== webView { + prev.draggingExited(sender) + activeDragWebView = nil + } + + if let webView { + if activeDragWebView !== webView { + // Cursor entered a (new) web view — send draggingEntered. + activeDragWebView = webView + return webView.draggingEntered(sender) + } + return webView.draggingUpdated(sender) + } + + // Over a terminal (or nothing). guard let types = sender.draggingPasteboard.types, types.contains(.fileURL), - terminalUnderPoint(sender.draggingLocation) != nil else { + terminalUnderPoint(loc) != nil else { return [] } return .copy } + /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. + private func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let webView = view as? WKWebView { return webView } + current = view.superview + } + return nil + } + /// Hit-tests the window to find the GhosttyNSView under the cursor. func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { if let window, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 59c23491..f2b86d0c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2183,4 +2183,20 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } return nil } + + /// Handle <input type="file"> elements by presenting the native file picker. + func webView( + _ webView: WKWebView, + runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void + ) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = parameters.allowsMultipleSelection + panel.canChooseDirectories = parameters.allowsDirectories + panel.canChooseFiles = true + panel.begin { result in + completionHandler(result == .OK ? panel.urls : nil) + } + } } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index e0131b9f..08843c0f 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -117,10 +117,21 @@ final class CmuxWebView: WKWebView { // 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. + // Fix: filter out text-based types that conflict with bonsplit tab drags, but keep + // file URL types so Finder file drops and HTML drag-and-drop work. + private static let blockedDragTypes: Set<NSPasteboard.PasteboardType> = [ + .string, // public.utf8-plain-text — matches bonsplit's NSString tab drags + NSPasteboard.PasteboardType("public.text"), + NSPasteboard.PasteboardType("public.plain-text"), + NSPasteboard.PasteboardType("com.splittabbar.tabtransfer"), + NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"), + ] + override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) { - // No-op: suppress WKWebView's automatic drag type registration. + let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) } + if !filtered.isEmpty { + super.registerForDraggedTypes(filtered) + } } override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {