Fix browser devtools persistence and Safari shortcut wiring

This commit is contained in:
Lawrence Chen 2026-02-19 20:20:42 -08:00
parent 031b0fcb30
commit 743cfcdc6d
11 changed files with 538 additions and 45 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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<Void, Never>?
@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,

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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…`

View file

@ -5,8 +5,8 @@ usage() {
cat <<'USAGE'
Usage: debug_windows_snapshot.sh [--domain <defaults-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 <<PAYLOAD
# Defaults domain
$domain
@ -166,6 +169,10 @@ menubarDebugMultiDigitYOffset=$menubarDebugMultiDigitYOffset
menubarDebugSingleDigitXAdjust=$menubarDebugSingleDigitXAdjust
menubarDebugMultiDigitXAdjust=$menubarDebugMultiDigitXAdjust
menubarDebugTextRectWidthAdjust=$menubarDebugTextRectWidthAdjust
# Browser DevTools Button
browserDevToolsIconName=$browserDevToolsIconName
browserDevToolsIconColor=$browserDevToolsIconColor
PAYLOAD
)"

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 748d9c0fe12edebd5448b946ce2c23d7549cd073
Subproject commit 2bd60ba40dc3350bd3c774b5f2de9f9b9c1b39fb