From 99dd6a3d92ace9e60e29ba81187f22c70fa107eb Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 2 Apr 2026 03:19:47 -0700 Subject: [PATCH 01/89] Support chorded keyboard shortcuts --- Resources/Localizable.xcstrings | 4 +- Sources/AppDelegate.swift | 210 +++++++++++----- Sources/ContentView.swift | 6 +- Sources/KeyboardShortcutSettings.swift | 228 ++++++++++++++++-- Sources/cmuxApp.swift | 25 +- .../AppDelegateShortcutRoutingTests.swift | 129 ++++++++++ cmuxTests/WorkspaceUnitTests.swift | 58 +++++ 7 files changed, 571 insertions(+), 89 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8aa15aff0005..2bf400a330eb 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -63298,13 +63298,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Click a shortcut value to record a new shortcut." + "value": "Click a shortcut value to record. Press a second key for a chord, or click again to keep the first key." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ショートカット値をクリックして新しいショートカットを記録します。" + "value": "ショートカット値をクリックして記録します。コードにするには続けて2つ目のキーを押し、1キーのままにするにはもう一度クリックします。" } }, "zh-Hans": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 53f511e2615e..a6b5ef66cad0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2193,6 +2193,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var shortcutDefaultsObserver: NSObjectProtocol? private var menuBarVisibilityObserver: NSObjectProtocol? private var splitButtonTooltipRefreshScheduled = false + private var pendingConfiguredShortcutChordStart: ShortcutStroke? + private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? @@ -9406,6 +9408,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let isControlOnly = hasControl && !hasCommand && !hasOption let controlDChar = chars == "d" || event.characters == "\u{04}" let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) + activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChordStart + pendingConfiguredShortcutChordStart = nil + defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } #if DEBUG if isControlD { writeChildExitKeyboardProbe( @@ -9754,7 +9759,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Fast path for normal typing and terminal navigation keys (for example Up-arrow // history): after command-palette/notification handling and browser omnibar // arrow navigation above, plain key events have no app-level shortcut behavior. - if normalizedFlags.isEmpty { + if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil { return false } @@ -9765,12 +9770,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Primary UI shortcuts - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) { + if matchConfiguredShortcut(event: event, action: .toggleSidebar) { _ = toggleSidebarInActiveMainWindow() return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) { + if matchConfiguredShortcut(event: event, action: .newTab) { #if DEBUG dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") #endif @@ -9807,7 +9812,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Handled here instead of relying on SwiftUI's CommandGroup menu item because // after a browser panel has been shown, SwiftUI's menu dispatch can silently // consume the key equivalent without firing the action closure. - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newWindow)) { + if matchConfiguredShortcut(event: event, action: .newWindow) { openNewMainWindow(nil) return true } @@ -9815,18 +9820,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Open Folder: Cmd+O // Handled here to prevent AppKit's default NSDocumentController from opening // the Documents folder when SwiftUI menu dispatch fails due to focus bugs. - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openFolder)) { + if matchConfiguredShortcut(event: event, action: .openFolder) { showOpenFolderPanel() return true } // Check Show Notifications shortcut - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showNotifications)) { + if matchConfiguredShortcut(event: event, action: .showNotifications) { toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .sendFeedback)) { + if matchConfiguredShortcut(event: event, action: .sendFeedback) { guard let targetContext = preferredMainWindowContextForShortcuts(event: event), let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { return false @@ -9838,7 +9843,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Check Jump to Unread shortcut - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .jumpToUnread)) { + if matchConfiguredShortcut(event: event, action: .jumpToUnread) { #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { writeJumpUnreadTestData(["jumpUnreadShortcutHandled": "1"]) @@ -9849,22 +9854,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Flash the currently focused panel so the user can visually confirm focus. - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .triggerFlash)) { + if matchConfiguredShortcut(event: event, action: .triggerFlash) { tabManager?.triggerFocusFlash() return true } // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSurface)) { + if matchConfiguredShortcut(event: event, action: .nextSurface) { tabManager?.selectNextSurface() return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSurface)) { + if matchConfiguredShortcut(event: event, action: .prevSurface) { tabManager?.selectPreviousSurface() return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleTerminalCopyMode)) { + if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false #if DEBUG dlog( @@ -9878,7 +9883,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) { + if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { #if DEBUG let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( @@ -9889,7 +9894,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSidebarTab)) { + if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { #if DEBUG let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( @@ -9900,7 +9905,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameWorkspace)) { + if matchConfiguredShortcut(event: event, action: .renameWorkspace) { return requestRenameWorkspaceViaCommandPalette( preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow ) @@ -9967,12 +9972,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWorkspace)) { + if matchConfiguredShortcut(event: event, action: .closeWorkspace) { tabManager?.closeCurrentWorkspaceWithConfirmation() return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWindow)) { + if matchConfiguredShortcut(event: event, action: .closeWindow) { guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { NSSound.beep() return true @@ -9981,7 +9986,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .renameTab)) { + if matchConfiguredShortcut(event: event, action: .renameTab) { // Keep Cmd+R browser reload behavior when a browser panel is focused. if tabManager?.focusedBrowserPanel != nil { return false @@ -9994,10 +9999,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Numeric shortcuts for specific workspaces (9 = last workspace) // Always consume the event when the digit matches to prevent Ghostty's // goto_tab fallback from creating a new window when the index is out of bounds. - if let digit = numberedShortcutDigit( - event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) - ) { + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectWorkspaceByNumber) { if let manager = tabManager, let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { #if DEBUG @@ -10011,10 +10013,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Numeric shortcuts for surfaces within the focused pane (9 = last) - if let digit = numberedShortcutDigit( - event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .selectSurfaceByNumber) - ) { + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectSurfaceByNumber) { if digit == 9 { tabManager?.selectLastSurface() } else { @@ -10024,9 +10023,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). - if matchDirectionalShortcut( + if matchConfiguredDirectionalShortcut( event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .focusLeft), + action: .focusLeft, arrowGlyph: "←", arrowKeyCode: 123 ) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) { @@ -10036,9 +10035,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return true } - if matchDirectionalShortcut( + if matchConfiguredDirectionalShortcut( event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .focusRight), + action: .focusRight, arrowGlyph: "→", arrowKeyCode: 124 ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { @@ -10048,9 +10047,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return true } - if matchDirectionalShortcut( + if matchConfiguredDirectionalShortcut( event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .focusUp), + action: .focusUp, arrowGlyph: "↑", arrowKeyCode: 126 ) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) { @@ -10060,9 +10059,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return true } - if matchDirectionalShortcut( + if matchConfiguredDirectionalShortcut( event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .focusDown), + action: .focusDown, arrowGlyph: "↓", arrowKeyCode: 125 ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { @@ -10073,7 +10072,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSplitZoom)) { + if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { _ = tabManager?.toggleFocusedSplitZoom() #if DEBUG recordGotoSplitZoomIfNeeded() @@ -10082,7 +10081,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Split actions: Cmd+D / Cmd+Shift+D - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) { + if matchConfiguredShortcut(event: event, action: .splitRight) { #if DEBUG dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") #endif @@ -10096,7 +10095,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) { + if matchConfiguredShortcut(event: event, action: .splitDown) { #if DEBUG dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") #endif @@ -10110,7 +10109,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) { + if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { #if DEBUG dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") #endif @@ -10118,7 +10117,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) { + if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { #if DEBUG dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") #endif @@ -10137,13 +10136,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // New surface: Cmd+T - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newSurface)) { + if matchConfiguredShortcut(event: event, action: .newSurface) { tabManager?.newSurface() return true } // Open browser: Cmd+Shift+L - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openBrowser)) { + if matchConfiguredShortcut(event: event, action: .openBrowser) { _ = openBrowserAndFocusAddressBar(insertAtEnd: true) return true } @@ -10151,7 +10150,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Safari defaults: // - Option+Command+I => Show/Toggle Web Inspector // - Option+Command+C => Show JavaScript Console - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + if matchConfiguredShortcut(event: event, action: .toggleBrowserDeveloperTools) { #if DEBUG logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) #endif @@ -10166,7 +10165,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + if matchConfiguredShortcut(event: event, action: .showBrowserJavaScriptConsole) { #if DEBUG logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) #endif @@ -10248,6 +10247,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + if armConfiguredShortcutChordIfNeeded(event: event) { + return true + } + return false } @@ -10961,15 +10964,78 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - /// Match a shortcut against an event, handling normal keys. - private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent, + let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix { + return matchShortcutStroke(event: event, stroke: secondStroke) + } + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } + + private func numberedConfiguredShortcutDigit( + event: NSEvent, + action: KeyboardShortcutSettings.Action + ) -> Int? { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent, + let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix { + return numberedShortcutDigit(event: event, stroke: secondStroke) + } + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) + } + + private func matchConfiguredDirectionalShortcut( + event: NSEvent, + action: KeyboardShortcutSettings.Action, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent, + let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix { + return matchDirectionalShortcut( + event: event, + stroke: secondStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) + } + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) + } + + private func armConfiguredShortcutChordIfNeeded(event: NSEvent) -> Bool { + for action in KeyboardShortcutSettings.Action.allCases { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + guard shortcut.hasChord else { continue } + if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { + pendingConfiguredShortcutChordStart = shortcut.firstStroke + return true + } + } + return false + } + + /// Match a shortcut stroke against an event, handling normal keys. + private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { // Some keys can include extra flags (e.g. .function) depending on the responder chain. // Strip those for consistent matching across first responders (terminal, WebKit, etc). let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) - guard flags == shortcut.modifierFlags else { return false } + guard flags == stroke.modifierFlags else { return false } - let shortcutKey = shortcut.key.lowercased() + let shortcutKey = stroke.key.lowercased() if shortcutKey == "\r" { return event.keyCode == 36 || event.keyCode == 76 } @@ -11037,10 +11103,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { + private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } + + private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) - guard flags == shortcut.modifierFlags else { return nil } + guard flags == stroke.modifierFlags else { return nil } if let digit = numberedShortcutDigit( eventCharacter: event.charactersIgnoringModifiers, @@ -11062,6 +11133,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return digitForNumberKeyCode(event.keyCode) } + private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) + } + private func numberedShortcutDigit( eventCharacter: String?, applyShiftSymbolNormalization: Bool, @@ -11214,30 +11290,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Match arrow key shortcuts using keyCode /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. - private func matchArrowShortcut(event: NSEvent, shortcut: StoredShortcut, keyCode: UInt16) -> Bool { + private func matchArrowShortcut(event: NSEvent, stroke: ShortcutStroke, keyCode: UInt16) -> Bool { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function]) - return event.keyCode == keyCode && flags == shortcut.modifierFlags + return event.keyCode == keyCode && flags == stroke.modifierFlags } /// Match tab key shortcuts using keyCode 48 - private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + private func matchTabShortcut(event: NSEvent, stroke: ShortcutStroke) -> Bool { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - return event.keyCode == 48 && flags == shortcut.modifierFlags + return event.keyCode == 48 && flags == stroke.modifierFlags + } + + private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchTabShortcut(event: event, stroke: shortcut.firstStroke) } /// Directional shortcuts default to arrow keys, but the shortcut recorder only supports letter/number keys. /// Support both so users can customize pane navigation (e.g. Cmd+Ctrl+H/J/K/L). private func matchDirectionalShortcut( event: NSEvent, - shortcut: StoredShortcut, + stroke: ShortcutStroke, arrowGlyph: String, arrowKeyCode: UInt16 ) -> Bool { - if shortcut.key == arrowGlyph { - return matchArrowShortcut(event: event, shortcut: shortcut, keyCode: arrowKeyCode) + if stroke.key == arrowGlyph { + return matchArrowShortcut(event: event, stroke: stroke, keyCode: arrowKeyCode) } - return matchShortcut(event: event, shortcut: shortcut) + return matchShortcutStroke(event: event, stroke: stroke) + } + + private func matchDirectionalShortcut( + event: NSEvent, + shortcut: StoredShortcut, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode + ) } func validateMenuItem(_ item: NSMenuItem) -> Bool { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e5ee951dce07..af06d738224a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8776,7 +8776,7 @@ struct VerticalTabsSidebar: View { at: index, workspaceCount: workspaceCount ), - workspaceShortcutModifierSymbol: workspaceNumberShortcut.modifierDisplayString, + workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, canCloseWorkspace: canCloseWorkspace, accessibilityWorkspaceCount: workspaceCount, unreadCount: notificationStore.unreadCount(forTabId: tab.id), @@ -8925,9 +8925,11 @@ enum ShortcutHintModifierPolicy { for modifierFlags: NSEvent.ModifierFlags, defaults: UserDefaults = .standard ) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) + guard !shortcut.hasChord else { return false } let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) - guard normalized == KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber).modifierFlags else { + guard normalized == shortcut.modifierFlags else { return false } return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 601ed71cb28c..c8708834ae2d 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -214,23 +214,24 @@ enum KeyboardShortcutSettings { func displayedShortcutString(for shortcut: StoredShortcut) -> String { if usesNumberedDigitMatching { - return shortcut.modifierDisplayString + "1…9" + return shortcut.numberedDisplayString } return shortcut.displayString } func normalizedRecordedShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { guard usesNumberedDigitMatching else { return shortcut } - guard let digit = Int(shortcut.key), (1...9).contains(digit) else { + let digitSource = shortcut.secondStroke ?? shortcut.firstStroke + guard let digit = Int(digitSource.key), (1...9).contains(digit) else { return nil } - return StoredShortcut( - key: "1", - command: shortcut.command, - shift: shortcut.shift, - option: shortcut.option, - control: shortcut.control - ) + var normalized = shortcut + if shortcut.hasChord { + normalized.chordKey = "1" + } else { + normalized.key = "1" + } + return normalized } } @@ -330,8 +331,7 @@ enum KeyboardShortcutSettings { static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) } } -/// A keyboard shortcut that can be stored in UserDefaults -struct StoredShortcut: Codable, Equatable { +struct ShortcutStroke: Equatable { var key: String var command: Bool var shift: Bool @@ -434,14 +434,14 @@ struct StoredShortcut: Codable, Equatable { } } - static func from(event: NSEvent) -> StoredShortcut? { + static func from(event: NSEvent, requireModifier: Bool = true) -> ShortcutStroke? { guard let key = storedKey(from: event) else { return nil } // Some keys include extra flags depending on the responder chain. let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function]) - let shortcut = StoredShortcut( + let stroke = ShortcutStroke( key: key, command: flags.contains(.command), shift: flags.contains(.shift), @@ -449,11 +449,11 @@ struct StoredShortcut: Codable, Equatable { control: flags.contains(.control) ) - // Avoid recording plain typing; require at least one modifier. - if !shortcut.command && !shortcut.shift && !shortcut.option && !shortcut.control { + if requireModifier, + !stroke.command && !stroke.shift && !stroke.option && !stroke.control { return nil } - return shortcut + return stroke } private static func storedKey(from event: NSEvent) -> String? { @@ -485,7 +485,6 @@ struct StoredShortcut: Codable, Equatable { return nil } - // Allow letters/numbers; everything else should be handled by keyCode mapping above. if char.isLetter || char.isNumber { return String(char) } @@ -493,6 +492,165 @@ struct StoredShortcut: Codable, Equatable { } } +/// A keyboard shortcut that can be stored in UserDefaults +struct StoredShortcut: Codable, Equatable { + var key: String + var command: Bool + var shift: Bool + var option: Bool + var control: Bool + var chordKey: String? + var chordCommand: Bool + var chordShift: Bool + var chordOption: Bool + var chordControl: Bool + + init( + key: String, + command: Bool, + shift: Bool, + option: Bool, + control: Bool, + chordKey: String? = nil, + chordCommand: Bool = false, + chordShift: Bool = false, + chordOption: Bool = false, + chordControl: Bool = false + ) { + self.key = key + self.command = command + self.shift = shift + self.option = option + self.control = control + self.chordKey = chordKey?.isEmpty == true ? nil : chordKey + self.chordCommand = chordCommand + self.chordShift = chordShift + self.chordOption = chordOption + self.chordControl = chordControl + } + + init(first: ShortcutStroke, second: ShortcutStroke? = nil) { + self.init( + key: first.key, + command: first.command, + shift: first.shift, + option: first.option, + control: first.control, + chordKey: second?.key, + chordCommand: second?.command ?? false, + chordShift: second?.shift ?? false, + chordOption: second?.option ?? false, + chordControl: second?.control ?? false + ) + } + + private enum CodingKeys: String, CodingKey { + case key + case command + case shift + case option + case control + case chordKey + case chordCommand + case chordShift + case chordOption + case chordControl + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + key: try container.decode(String.self, forKey: .key), + command: try container.decode(Bool.self, forKey: .command), + shift: try container.decode(Bool.self, forKey: .shift), + option: try container.decode(Bool.self, forKey: .option), + control: try container.decode(Bool.self, forKey: .control), + chordKey: try container.decodeIfPresent(String.self, forKey: .chordKey), + chordCommand: try container.decodeIfPresent(Bool.self, forKey: .chordCommand) ?? false, + chordShift: try container.decodeIfPresent(Bool.self, forKey: .chordShift) ?? false, + chordOption: try container.decodeIfPresent(Bool.self, forKey: .chordOption) ?? false, + chordControl: try container.decodeIfPresent(Bool.self, forKey: .chordControl) ?? false + ) + } + + var firstStroke: ShortcutStroke { + ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + ) + } + + var secondStroke: ShortcutStroke? { + guard let chordKey else { return nil } + return ShortcutStroke( + key: chordKey, + command: chordCommand, + shift: chordShift, + option: chordOption, + control: chordControl + ) + } + + var hasChord: Bool { + secondStroke != nil + } + + var displayString: String { + if let secondStroke { + return "\(firstStroke.displayString) \(secondStroke.displayString)" + } + return firstStroke.displayString + } + + var numberedDisplayString: String { + if hasChord { + return numberedDigitHintPrefix + "1…9" + } + return firstStroke.modifierDisplayString + "1…9" + } + + var numberedDigitHintPrefix: String { + if let secondStroke { + return "\(firstStroke.displayString) \(secondStroke.modifierDisplayString)" + } + return firstStroke.modifierDisplayString + } + + var modifierDisplayString: String { + firstStroke.modifierDisplayString + } + + var keyDisplayString: String { + firstStroke.keyDisplayString + } + + var modifierFlags: NSEvent.ModifierFlags { + firstStroke.modifierFlags + } + + var keyEquivalent: KeyEquivalent? { + guard !hasChord else { return nil } + return firstStroke.keyEquivalent + } + + var eventModifiers: EventModifiers { + firstStroke.eventModifiers + } + + var menuItemKeyEquivalent: String? { + guard !hasChord else { return nil } + return firstStroke.menuItemKeyEquivalent + } + + static func from(event: NSEvent) -> StoredShortcut? { + guard let stroke = ShortcutStroke.from(event: event) else { return nil } + return StoredShortcut(first: stroke) + } +} + /// View for recording a keyboard shortcut struct KeyboardShortcutRecorder: View { let label: String @@ -513,7 +671,7 @@ struct KeyboardShortcutRecorder: View { displayString: displayString, transformRecordedShortcut: transformRecordedShortcut ) - .frame(width: 120) + .frame(width: 160) } } } @@ -555,6 +713,7 @@ private class ShortcutRecorderNSButton: NSButton { var onRecordingChanged: ((Bool) -> Void)? private var isRecording = false private var eventMonitor: Any? + private var pendingChordStart: ShortcutStroke? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -576,7 +735,11 @@ private class ShortcutRecorderNSButton: NSButton { func updateTitle() { if isRecording { - title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") + if let pendingChordStart { + title = "\(pendingChordStart.displayString) …" + } else { + title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") + } } else { title = displayString(shortcut) } @@ -584,6 +747,15 @@ private class ShortcutRecorderNSButton: NSButton { @objc private func buttonClicked() { if isRecording { + if let pendingChordStart { + let storedShortcut = StoredShortcut(first: pendingChordStart) + guard let transformedShortcut = transformRecordedShortcut(storedShortcut) else { + NSSound.beep() + return + } + shortcut = transformedShortcut + onShortcutRecorded?(transformedShortcut) + } stopRecording() } else { startRecording() @@ -592,6 +764,7 @@ private class ShortcutRecorderNSButton: NSButton { private func startRecording() { isRecording = true + pendingChordStart = nil onRecordingChanged?(true) updateTitle() @@ -603,7 +776,21 @@ private class ShortcutRecorderNSButton: NSButton { return nil } - if let newShortcut = StoredShortcut.from(event: event) { + if self.pendingChordStart == nil { + guard let firstStroke = ShortcutStroke.from(event: event, requireModifier: true) else { + return nil + } + self.pendingChordStart = firstStroke + self.updateTitle() + return nil + } + + guard let pendingChordStart = self.pendingChordStart else { + return nil + } + + if let secondStroke = ShortcutStroke.from(event: event, requireModifier: false) { + let newShortcut = StoredShortcut(first: pendingChordStart, second: secondStroke) guard let transformedShortcut = self.transformRecordedShortcut(newShortcut) else { NSSound.beep() return nil @@ -629,6 +816,7 @@ private class ShortcutRecorderNSButton: NSButton { private func stopRecording() { isRecording = false + pendingChordStart = nil onRecordingChanged?(false) updateTitle() diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ee9b9664c69b..ead9f06b155c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -798,16 +798,25 @@ struct cmuxApp: App { // Numbered workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in - Button(String(localized: "menu.view.workspace", defaultValue: "Workspace \(number)")) { - let manager = activeTabManager - if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: number, workspaceCount: manager.tabs.count) { - manager.selectTab(at: targetIndex) + if selectWorkspaceByNumberMenuShortcut.hasChord { + Button(String(localized: "menu.view.workspace", defaultValue: "Workspace \(number)")) { + let manager = activeTabManager + if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: number, workspaceCount: manager.tabs.count) { + manager.selectTab(at: targetIndex) + } + } + } else { + Button(String(localized: "menu.view.workspace", defaultValue: "Workspace \(number)")) { + let manager = activeTabManager + if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: number, workspaceCount: manager.tabs.count) { + manager.selectTab(at: targetIndex) + } } + .keyboardShortcut( + KeyEquivalent(Character("\(number)")), + modifiers: selectWorkspaceByNumberMenuShortcut.eventModifiers + ) } - .keyboardShortcut( - KeyEquivalent(Character("\(number)")), - modifiers: selectWorkspaceByNumberMenuShortcut.eventModifiers - ) } Divider() diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index db922a601ea7..b9d28c5bdd1b 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -123,6 +123,135 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should add workspace to the event's window") } + func testChordedNewWorkspaceShortcutConsumesPrefixAndTriggersOnSecondKey() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and manager") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "n" + ) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertEqual(manager.tabs.count, initialCount, "Chord prefix must not fire the action early") + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount + 1, "Chord second key should dispatch the configured shortcut") + } + + func testChordedShortcutMismatchDoesNotConsumeSecondKey() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window and workspace") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialPanelCount = workspace.panels.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "d" + ) + + withTemporaryShortcut(action: .splitRight, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let mismatchEvent = makeKeyDownEvent( + key: "x", + modifiers: [], + keyCode: 7, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct mismatch event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: mismatchEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(workspace.panels.count, initialPanelCount, "Unmatched chord suffix must not trigger the action") + } + func testCreateMainWindowDoesNotDisallowFullScreenTilingByDefault() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 8a9687af6943..7f5067a282f0 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -194,6 +194,64 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { let keys = KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey) XCTAssertEqual(Set(keys).count, keys.count) } + + func testChordedShortcutDisplayDisablesMenuKeyEquivalent() { + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "d" + ) + + XCTAssertEqual(shortcut.displayString, "⌃B D") + XCTAssertNil(shortcut.keyEquivalent) + XCTAssertNil(shortcut.menuItemKeyEquivalent) + } + + func testNumberedChordDisplayUsesChordSuffix() { + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "7" + ) + + XCTAssertEqual( + KeyboardShortcutSettings.Action.selectWorkspaceByNumber.displayedShortcutString(for: shortcut), + "⌃B 1…9" + ) + } + + func testNumberedChordNormalizationTargetsSecondStroke() { + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "7" + ) + + let normalized = KeyboardShortcutSettings.Action.selectWorkspaceByNumber.normalizedRecordedShortcut(shortcut) + XCTAssertEqual(normalized?.key, "b") + XCTAssertEqual(normalized?.chordKey, "1") + } + + func testStoredShortcutDecodesLegacySingleStrokePayload() throws { + let data = """ + {"key":"d","command":true,"shift":false,"option":false,"control":false} + """.data(using: .utf8)! + + let shortcut = try JSONDecoder().decode(StoredShortcut.self, from: data) + + XCTAssertEqual(shortcut.key, "d") + XCTAssertFalse(shortcut.hasChord) + XCTAssertNil(shortcut.chordKey) + } } From c7a0ade36626984db340890592e1bccc4686d081 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 2 Apr 2026 03:28:05 -0700 Subject: [PATCH 02/89] Fix escape handling in shortcut recorder --- Sources/KeyboardShortcutSettings.swift | 20 ++++++++++++++++++-- cmuxTests/WorkspaceUnitTests.swift | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index c8708834ae2d..c2ceddbb8b18 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -434,8 +434,24 @@ struct ShortcutStroke: Equatable { } } + static func isEscapeCancelEvent(_ event: NSEvent) -> Bool { + if event.keyCode == 53 { + return true + } + + let escapeScalar = UnicodeScalar(0x1B)! + if event.characters?.unicodeScalars.contains(escapeScalar) == true { + return true + } + if event.charactersIgnoringModifiers?.unicodeScalars.contains(escapeScalar) == true { + return true + } + return false + } + static func from(event: NSEvent, requireModifier: Bool = true) -> ShortcutStroke? { - guard let key = storedKey(from: event) else { return nil } + guard !isEscapeCancelEvent(event), + let key = storedKey(from: event) else { return nil } // Some keys include extra flags depending on the responder chain. let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) @@ -771,7 +787,7 @@ private class ShortcutRecorderNSButton: NSButton { eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self = self else { return event } - if event.keyCode == 53 { // Escape + if ShortcutStroke.isEscapeCancelEvent(event) { self.stopRecording() return nil } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 7f5067a282f0..ecdaea95cc5e 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -252,6 +252,27 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTAssertFalse(shortcut.hasChord) XCTAssertNil(shortcut.chordKey) } + + func testEscapeCancelDetectionTreatsEscapeCharacterAsCancelEvenWithUnexpectedKeyCode() { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 36 + ) else { + XCTFail("Failed to construct escape-like event") + return + } + + XCTAssertTrue(ShortcutStroke.isEscapeCancelEvent(event)) + XCTAssertNil(ShortcutStroke.from(event: event, requireModifier: false)) + } } From 11be60d1bf9fd5d739e8495e660e4a6e5f206f72 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 2 Apr 2026 04:18:55 -0700 Subject: [PATCH 03/89] Add settings.json shortcut overrides --- CLAUDE.md | 1 + GhosttyTabs.xcodeproj/project.pbxproj | 4 + Resources/Localizable.xcstrings | 17 + Sources/AppDelegate.swift | 181 ++++---- Sources/ContentView.swift | 32 +- Sources/KeyboardShortcutSettings.swift | 172 +++++-- .../KeyboardShortcutSettingsFileStore.swift | 421 ++++++++++++++++++ Sources/NotificationsPage.swift | 24 +- Sources/Panels/BrowserPanelView.swift | 26 +- Sources/Update/UpdateTitlebarAccessory.swift | 16 +- Sources/WorkspaceContentView.swift | 17 +- Sources/cmuxApp.swift | 265 +++-------- .../AppDelegateShortcutRoutingTests.swift | 75 ++++ cmuxTests/BrowserConfigTests.swift | 9 + cmuxTests/WorkspaceUnitTests.swift | 140 ++++++ web/app/[locale]/docs/configuration/page.tsx | 20 + web/app/[locale]/keyboard-shortcuts.tsx | 210 +++++---- web/messages/en.json | 14 +- web/messages/ja.json | 14 +- 19 files changed, 1123 insertions(+), 535 deletions(-) create mode 100644 Sources/KeyboardShortcutSettingsFileStore.swift diff --git a/CLAUDE.md b/CLAUDE.md index 0c2d8156115b..7da0cc108c00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,7 @@ The app has a **Debug** menu in the macOS menu bar (only in DEBUG builds). Use i - **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn. - **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd && git merge-base --is-ancestor HEAD origin/main`. - **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc. +- **Shortcut policy:** Every new cmux-owned keyboard shortcut must be added to `KeyboardShortcutSettings`, visible/editable in Settings, supported in `~/.config/cmux/settings.json`, and documented in the keyboard shortcut and configuration docs. ## Test quality policy diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 9b663742da74..f01b83e72b29 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; + A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */; }; A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; }; A5001521 /* PostHogAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001520 /* PostHogAnalytics.swift */; }; A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; }; @@ -248,6 +249,7 @@ A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = ""; }; A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = ""; }; + A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsFileStore.swift; sourceTree = ""; }; A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = ""; }; A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = ""; }; @@ -456,6 +458,7 @@ B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, A50012F2 /* KeyboardShortcutSettings.swift */, + A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */, A50012F4 /* KeyboardLayout.swift */, A5001013 /* TabManager.swift */, A5001511 /* UITestRecorder.swift */, @@ -779,6 +782,7 @@ B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, + A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, A5001003 /* TabManager.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2bf400a330eb..d57abbfa5fd6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -63411,6 +63411,23 @@ } } }, + "settings.shortcuts.managedByFile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Managed in settings.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.json で管理" + } + } + } + }, "settings.shortcuts.showHints": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a6b5ef66cad0..5031dba93c5f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9633,24 +9633,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Scope the omnibar check to the shortcut's routed window context so a // focused omnibar in another window does not suppress Cmd+P here. let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil - let isCommandShiftP = matchShortcut( - event: event, - shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) - ) - if isCommandShiftP { + if matchConfiguredShortcut(event: event, action: .commandPalette) { let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP") + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") return true } - let isCommandP = !hasFocusedAddressBarInShortcutContext - && matchShortcut( - event: event, - shortcut: StoredShortcut(key: "p", command: true, shift: false, option: false, control: false) - ) - if isCommandP { + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.cmdP") + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") return true } @@ -9663,25 +9655,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) - ) { + if matchConfiguredShortcut(event: event, action: .quit) { return handleQuitShortcutWarning() } - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) - ) { - GhosttyApp.shared.reloadConfiguration(source: "shortcut.cmd_shift_comma") + if matchConfiguredShortcut(event: event, action: .openSettings) { + openPreferencesWindow(debugSource: "shortcut.openSettings") + return true + } + if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") return true } - if shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: event.modifierFlags, - chars: chars, - keyCode: event.keyCode - ) { + if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { guard let targetWindow = mainWindowForShortcutEvent(event) else { return false } @@ -9911,10 +9897,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "t", command: true, shift: false, option: true, control: false) - ) { + if matchConfiguredShortcut(event: event, action: .closeOtherTabsInPane) { if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) @@ -9931,10 +9914,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Cmd+W must close the focused panel even if first-responder momentarily lags on a // browser NSTextView during split focus transitions. - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) - ) { + if matchConfiguredShortcut(event: event, action: .closeTab) { // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. // This AppDelegate path is a fallback for cases where AppKit routes the // event through the global shortcut handler first. @@ -10147,6 +10127,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { + if let focusedPanel = tabManager?.focusedBrowserPanel { + focusBrowserAddressBar(in: focusedPanel) + return true + } + + if let browserAddressBarFocusedPanelId, + focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { + return true + } + + if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { + return true + } + } + + if matchConfiguredShortcut(event: event, action: .browserBack) { + tabManager?.focusedBrowserPanel?.goBack() + return true + } + + if matchConfiguredShortcut(event: event, action: .browserForward) { + tabManager?.focusedBrowserPanel?.goForward() + return true + } + + if matchConfiguredShortcut(event: event, action: .browserReload) { + tabManager?.focusedBrowserPanel?.reload() + return true + } + // Safari defaults: // - Option+Command+I => Show/Toggle Web Inspector // - Option+Command+C => Show JavaScript Console @@ -10180,72 +10191,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Focus browser address bar: Cmd+L - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "l", command: true, shift: false, option: false, control: false) - ) { - if let focusedPanel = tabManager?.focusedBrowserPanel { - focusBrowserAddressBar(in: focusedPanel) + if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { + guard let tabManager, tabManager.focusedBrowserPanel != nil else { + NSSound.beep() return true } + tabManager.toggleReactGrabFocusedBrowser() + return true + } - if let browserAddressBarFocusedPanelId, - focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { - return true - } + if matchConfiguredShortcut(event: event, action: .browserZoomIn) { + return tabManager?.zoomInFocusedBrowser() ?? false + } - if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { - return true - } + if matchConfiguredShortcut(event: event, action: .browserZoomOut) { + return tabManager?.zoomOutFocusedBrowser() ?? false } - #if DEBUG - logBrowserZoomShortcutTrace(stage: "probe", event: event, flags: flags, chars: chars) - #endif - let zoomAction = browserZoomShortcutAction( - flags: flags, - chars: chars, - keyCode: event.keyCode, - literalChars: event.characters - ) - #if DEBUG - logBrowserZoomShortcutTrace(stage: "match", event: event, flags: flags, chars: chars, action: zoomAction) - #endif - if let action = zoomAction, let manager = tabManager { - let handled: Bool - switch action { - case .zoomIn: - handled = manager.zoomInFocusedBrowser() - case .zoomOut: - handled = manager.zoomOutFocusedBrowser() - case .reset: - handled = manager.resetZoomFocusedBrowser() - } - #if DEBUG - logBrowserZoomShortcutTrace( - stage: "dispatch", - event: event, - flags: flags, - chars: chars, - action: action, - handled: handled - ) - #endif - return handled + if matchConfiguredShortcut(event: event, action: .browserZoomReset) { + return tabManager?.resetZoomFocusedBrowser() ?? false } - #if DEBUG - if zoomAction != nil, tabManager == nil { - logBrowserZoomShortcutTrace( - stage: "dispatch.noManager", - event: event, - flags: flags, - chars: chars, - action: zoomAction, - handled: false - ) + + if matchConfiguredShortcut(event: event, action: .find) { + tabManager?.startSearch() + return true + } + + if matchConfiguredShortcut(event: event, action: .findNext) { + tabManager?.findNext() + return true + } + + if matchConfiguredShortcut(event: event, action: .findPrevious) { + tabManager?.findPrevious() + return true + } + + if matchConfiguredShortcut(event: event, action: .hideFind) { + tabManager?.hideFind() + return true + } + + if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { + tabManager?.searchSelection() + return true + } + + if matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { + _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() + return true } - #endif if armConfiguredShortcutChordIfNeeded(event: event) { return true diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index af06d738224a..0a9b1ae252b0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8689,12 +8689,11 @@ struct VerticalTabsSidebar: View { @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var draggedTabId: UUID? @State private var dropIndicator: SidebarDropIndicator? @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue - @AppStorage(KeyboardShortcutSettings.Action.selectWorkspaceByNumber.defaultsKey) - private var selectWorkspaceByNumberShortcutData = Data() /// Space at top of sidebar for traffic light buttons private let trafficLightPadding: CGFloat = 28 @@ -8710,18 +8709,8 @@ struct VerticalTabsSidebar: View { } private var workspaceNumberShortcut: StoredShortcut { - decodeShortcut( - from: selectWorkspaceByNumberShortcutData, - fallback: KeyboardShortcutSettings.Action.selectWorkspaceByNumber.defaultShortcut - ) - } - - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) } var body: some View { @@ -10526,17 +10515,15 @@ private struct SidebarHelpMenuButton: View { private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") private let buttonSize: CGFloat = 22 private let iconSize: CGFloat = 11 - @AppStorage(KeyboardShortcutSettings.Action.sendFeedback.defaultsKey) private var sendFeedbackShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared let onSendFeedback: () -> Void @State private var isPopoverPresented = false private var sendFeedbackShortcutHint: String { - decodeShortcut( - from: sendFeedbackShortcutData, - fallback: KeyboardShortcutSettings.Action.sendFeedback.defaultShortcut - ).displayString + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString } var body: some View { @@ -10747,13 +10734,6 @@ private struct SidebarHelpMenuButton: View { } } - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut - } } private struct ArrowlessPopoverAnchor: NSViewRepresentable { diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index c2ceddbb8b18..158813c4c01d 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -5,14 +5,24 @@ import SwiftUI enum KeyboardShortcutSettings { static let didChangeNotification = Notification.Name("cmux.keyboardShortcutSettingsDidChange") static let actionUserInfoKey = "action" + static let settingsFileDisplayPath = "~/.config/cmux/settings.json" + static var settingsFileStore: KeyboardShortcutSettingsFileStore = .shared enum Action: String, CaseIterable, Identifiable { + // App / window + case openSettings + case reloadConfiguration + case newWindow + case closeWindow + case toggleFullScreen + case quit + // Titlebar / primary UI case toggleSidebar case newTab - case newWindow - case closeWindow case openFolder + case goToWorkspace + case commandPalette case sendFeedback case showNotifications case jumpToUnread @@ -27,7 +37,10 @@ enum KeyboardShortcutSettings { case selectWorkspaceByNumber case renameTab case renameWorkspace + case closeTab + case closeOtherTabsInPane case closeWorkspace + case reopenClosedBrowserPanel case newSurface case toggleTerminalCopyMode @@ -44,6 +57,18 @@ enum KeyboardShortcutSettings { // Panels case openBrowser + case focusBrowserAddressBar + case browserBack + case browserForward + case browserReload + case browserZoomIn + case browserZoomOut + case browserZoomReset + case find + case findNext + case findPrevious + case hideFind + case useSelectionForFind case toggleBrowserDeveloperTools case showBrowserJavaScriptConsole case toggleReactGrab @@ -52,11 +77,17 @@ enum KeyboardShortcutSettings { var label: String { switch self { - case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar") - case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace") + case .openSettings: return String(localized: "menu.app.settings", defaultValue: "Settings…") + case .reloadConfiguration: return String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration") case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window") case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window") + case .toggleFullScreen: return String(localized: "command.toggleFullScreen.title", defaultValue: "Toggle Full Screen") + case .quit: return String(localized: "menu.quitCmux", defaultValue: "Quit cmux") + case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar") + case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace") case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder") + case .goToWorkspace: return String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…") + case .commandPalette: return String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…") case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback") case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications") case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread") @@ -69,7 +100,10 @@ enum KeyboardShortcutSettings { case .selectWorkspaceByNumber: return String(localized: "shortcut.selectWorkspaceByNumber.label", defaultValue: "Select Workspace 1…9") case .renameTab: return String(localized: "shortcut.renameTab.label", defaultValue: "Rename Tab") case .renameWorkspace: return String(localized: "shortcut.renameWorkspace.label", defaultValue: "Rename Workspace") + case .closeTab: return String(localized: "menu.file.closeTab", defaultValue: "Close Tab") + case .closeOtherTabsInPane: return String(localized: "menu.file.closeOtherTabs", defaultValue: "Close Other Tabs in Pane") case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace") + case .reopenClosedBrowserPanel: return String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel") case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface") case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode") case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left") @@ -82,6 +116,18 @@ enum KeyboardShortcutSettings { case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right") case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down") case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser") + case .focusBrowserAddressBar: return String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar") + case .browserBack: return String(localized: "menu.view.back", defaultValue: "Back") + case .browserForward: return String(localized: "menu.view.forward", defaultValue: "Forward") + case .browserReload: return String(localized: "menu.view.reloadPage", defaultValue: "Reload Page") + case .browserZoomIn: return String(localized: "menu.view.zoomIn", defaultValue: "Zoom In") + case .browserZoomOut: return String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out") + case .browserZoomReset: return String(localized: "menu.view.actualSize", defaultValue: "Actual Size") + case .find: return String(localized: "menu.find.find", defaultValue: "Find…") + case .findNext: return String(localized: "menu.find.findNext", defaultValue: "Find Next") + case .findPrevious: return String(localized: "menu.find.findPrevious", defaultValue: "Find Previous") + case .hideFind: return String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar") + case .useSelectionForFind: return String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find") case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools") case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console") case .toggleReactGrab: return String(localized: "shortcut.toggleReactGrab.label", defaultValue: "Toggle React Grab") @@ -89,55 +135,33 @@ enum KeyboardShortcutSettings { } var defaultsKey: String { - switch self { - case .toggleSidebar: return "shortcut.toggleSidebar" - case .newTab: return "shortcut.newTab" - case .newWindow: return "shortcut.newWindow" - case .closeWindow: return "shortcut.closeWindow" - case .openFolder: return "shortcut.openFolder" - case .sendFeedback: return "shortcut.sendFeedback" - case .showNotifications: return "shortcut.showNotifications" - case .jumpToUnread: return "shortcut.jumpToUnread" - case .triggerFlash: return "shortcut.triggerFlash" - case .selectWorkspaceByNumber: return "shortcut.selectWorkspaceByNumber" - case .nextSidebarTab: return "shortcut.nextSidebarTab" - case .prevSidebarTab: return "shortcut.prevSidebarTab" - case .renameTab: return "shortcut.renameTab" - case .renameWorkspace: return "shortcut.renameWorkspace" - case .closeWorkspace: return "shortcut.closeWorkspace" - case .focusLeft: return "shortcut.focusLeft" - case .focusRight: return "shortcut.focusRight" - case .focusUp: return "shortcut.focusUp" - case .focusDown: return "shortcut.focusDown" - case .splitRight: return "shortcut.splitRight" - case .splitDown: return "shortcut.splitDown" - case .toggleSplitZoom: return "shortcut.toggleSplitZoom" - case .splitBrowserRight: return "shortcut.splitBrowserRight" - case .splitBrowserDown: return "shortcut.splitBrowserDown" - case .nextSurface: return "shortcut.nextSurface" - case .prevSurface: return "shortcut.prevSurface" - case .selectSurfaceByNumber: return "shortcut.selectSurfaceByNumber" - case .newSurface: return "shortcut.newSurface" - case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode" - case .openBrowser: return "shortcut.openBrowser" - case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" - case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" - case .toggleReactGrab: return "shortcut.toggleReactGrab" - } + "shortcut.\(rawValue)" } var defaultShortcut: StoredShortcut { switch self { - case .toggleSidebar: - return StoredShortcut(key: "b", command: true, shift: false, option: false, control: false) - case .newTab: - return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) + case .openSettings: + return StoredShortcut(key: ",", command: true, shift: false, option: false, control: false) + case .reloadConfiguration: + return StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) case .newWindow: return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false) case .closeWindow: return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true) + case .toggleFullScreen: + return StoredShortcut(key: "f", command: true, shift: false, option: false, control: true) + case .quit: + return StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) + case .toggleSidebar: + return StoredShortcut(key: "b", command: true, shift: false, option: false, control: false) + case .newTab: + return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) case .openFolder: return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false) + case .goToWorkspace: + return StoredShortcut(key: "p", command: true, shift: false, option: false, control: false) + case .commandPalette: + return StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) case .sendFeedback: return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false) case .showNotifications: @@ -154,8 +178,14 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false) case .renameWorkspace: return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) + case .closeTab: + return StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) + case .closeOtherTabsInPane: + return StoredShortcut(key: "t", command: true, shift: false, option: true, control: false) case .closeWorkspace: return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false) + case .reopenClosedBrowserPanel: + return StoredShortcut(key: "t", command: true, shift: true, option: false, control: false) case .focusLeft: return StoredShortcut(key: "←", command: true, shift: false, option: true, control: false) case .focusRight: @@ -188,6 +218,30 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "1", command: true, shift: false, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) + case .focusBrowserAddressBar: + return StoredShortcut(key: "l", command: true, shift: false, option: false, control: false) + case .browserBack: + return StoredShortcut(key: "[", command: true, shift: false, option: false, control: false) + case .browserForward: + return StoredShortcut(key: "]", command: true, shift: false, option: false, control: false) + case .browserReload: + return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false) + case .browserZoomIn: + return StoredShortcut(key: "=", command: true, shift: false, option: false, control: false) + case .browserZoomOut: + return StoredShortcut(key: "-", command: true, shift: false, option: false, control: false) + case .browserZoomReset: + return StoredShortcut(key: "0", command: true, shift: false, option: false, control: false) + case .find: + return StoredShortcut(key: "f", command: true, shift: false, option: false, control: false) + case .findNext: + return StoredShortcut(key: "g", command: true, shift: false, option: false, control: false) + case .findPrevious: + return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false) + case .hideFind: + return StoredShortcut(key: "f", command: true, shift: true, option: false, control: false) + case .useSelectionForFind: + return StoredShortcut(key: "e", command: true, shift: false, option: false, control: false) case .toggleBrowserDeveloperTools: // Safari default: Show Web Inspector. return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false) @@ -195,7 +249,7 @@ enum KeyboardShortcutSettings { // Safari default: Show JavaScript Console. return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) case .toggleReactGrab: - return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false) + return StoredShortcut(key: "g", command: true, shift: false, option: true, control: false) } } @@ -236,6 +290,9 @@ enum KeyboardShortcutSettings { } static func shortcut(for action: Action) -> StoredShortcut { + if let managedShortcut = settingsFileStore.override(for: action) { + return managedShortcut + } guard let data = UserDefaults.standard.data(forKey: action.defaultsKey), let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { return action.defaultShortcut @@ -243,6 +300,15 @@ enum KeyboardShortcutSettings { return shortcut } + static func isManagedBySettingsFile(_ action: Action) -> Bool { + settingsFileStore.isManagedByFile(action) + } + + static func settingsFileManagedSubtitle(for action: Action) -> String? { + guard isManagedBySettingsFile(action) else { return nil } + return String(localized: "settings.shortcuts.managedByFile", defaultValue: "Managed in settings.json") + } + static func setShortcut(_ shortcut: StoredShortcut, for action: Action) { let storedShortcut: StoredShortcut if let normalizedShortcut = action.normalizedRecordedShortcut(shortcut) { @@ -259,6 +325,10 @@ enum KeyboardShortcutSettings { postDidChangeNotification(action: action) } + static func notifySettingsFileDidChange() { + postDidChangeNotification() + } + static func resetShortcut(for action: Action) { UserDefaults.standard.removeObject(forKey: action.defaultsKey) postDidChangeNotification(action: action) @@ -670,14 +740,23 @@ struct StoredShortcut: Codable, Equatable { /// View for recording a keyboard shortcut struct KeyboardShortcutRecorder: View { let label: String + var subtitle: String? = nil @Binding var shortcut: StoredShortcut var displayString: (StoredShortcut) -> String = { $0.displayString } var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 } + var isDisabled: Bool = false @State private var isRecording = false var body: some View { - HStack { - Text(label) + HStack(alignment: subtitle == nil ? .center : .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(label) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } Spacer() @@ -688,6 +767,7 @@ struct KeyboardShortcutRecorder: View { transformRecordedShortcut: transformRecordedShortcut ) .frame(width: 160) + .disabled(isDisabled) } } } diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift new file mode 100644 index 000000000000..d757a2df689b --- /dev/null +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -0,0 +1,421 @@ +import Combine +import Foundation + +@MainActor +final class KeyboardShortcutSettingsObserver: ObservableObject { + static let shared = KeyboardShortcutSettingsObserver() + + @Published private(set) var revision: UInt64 = 0 + + private var cancellable: AnyCancellable? + + private init(notificationCenter: NotificationCenter = .default) { + cancellable = notificationCenter.publisher(for: KeyboardShortcutSettings.didChangeNotification) + .sink { [weak self] _ in + self?.revision &+= 1 + } + } +} + +final class KeyboardShortcutSettingsFileStore { + static let shared = KeyboardShortcutSettingsFileStore() + + private static let releaseBundleIdentifier = "com.cmuxterm.app" + + static var defaultPrimaryPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return (home as NSString).appendingPathComponent(".config/cmux/settings.json") + } + + static var defaultFallbackPath: String? { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + return nil + } + return appSupport + .appendingPathComponent(releaseBundleIdentifier, isDirectory: true) + .appendingPathComponent("settings.json", isDirectory: false) + .path + } + + private let primaryPath: String + private let fallbackPath: String? + private let fileManager: FileManager + private let stateLock = NSLock() + private var primaryWatcher: ShortcutSettingsFileWatcher? + private var fallbackWatcher: ShortcutSettingsFileWatcher? + + private var shortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + private(set) var activeSourcePath: String? + + init( + primaryPath: String = KeyboardShortcutSettingsFileStore.defaultPrimaryPath, + fallbackPath: String? = KeyboardShortcutSettingsFileStore.defaultFallbackPath, + fileManager: FileManager = .default, + startWatching: Bool = true + ) { + self.primaryPath = primaryPath + self.fallbackPath = fallbackPath + self.fileManager = fileManager + reload() + guard startWatching else { return } + + primaryWatcher = ShortcutSettingsFileWatcher(path: primaryPath, fileManager: fileManager) { [weak self] in + DispatchQueue.main.async { + self?.reload() + } + } + if let fallbackPath { + fallbackWatcher = ShortcutSettingsFileWatcher(path: fallbackPath, fileManager: fileManager) { [weak self] in + DispatchQueue.main.async { + self?.reload() + } + } + } + } + + deinit { + primaryWatcher?.stop() + fallbackWatcher?.stop() + } + + func reload() { + let previousShortcuts = synchronized { shortcutsByAction } + let previousActiveSourcePath = synchronized { activeSourcePath } + let resolved = resolveShortcuts() + synchronized { + shortcutsByAction = resolved.shortcuts + activeSourcePath = resolved.path + } + + if previousShortcuts != resolved.shortcuts || previousActiveSourcePath != resolved.path { + KeyboardShortcutSettings.notifySettingsFileDidChange() + } + } + + func override(for action: KeyboardShortcutSettings.Action) -> StoredShortcut? { + synchronized { shortcutsByAction[action] } + } + + func isManagedByFile(_ action: KeyboardShortcutSettings.Action) -> Bool { + synchronized { shortcutsByAction[action] != nil } + } + + private func synchronized(_ body: () -> T) -> T { + stateLock.lock() + defer { stateLock.unlock() } + return body() + } + + private func resolveShortcuts() -> (path: String?, shortcuts: [KeyboardShortcutSettings.Action: StoredShortcut]) { + switch loadShortcuts(at: primaryPath) { + case .parsed(let shortcuts): + return (primaryPath, shortcuts) + case .invalid: + return (primaryPath, [:]) + case .missing: + break + } + + guard let fallbackPath else { + return (nil, [:]) + } + + switch loadShortcuts(at: fallbackPath) { + case .parsed(let shortcuts): + return (fallbackPath, shortcuts) + case .invalid: + return (fallbackPath, [:]) + case .missing: + return (nil, [:]) + } + } + + private enum LoadResult { + case missing + case invalid + case parsed([KeyboardShortcutSettings.Action: StoredShortcut]) + } + + private func loadShortcuts(at path: String) -> LoadResult { + guard fileManager.fileExists(atPath: path) else { + return .missing + } + guard let data = fileManager.contents(atPath: path), + !data.isEmpty else { + return .invalid + } + + do { + let file = try JSONDecoder().decode(KeyboardShortcutSettingsFile.self, from: data) + return .parsed(parseShortcutBindings(file.shortcuts ?? [:], sourcePath: path)) + } catch { + NSLog("[KeyboardShortcutSettings] parse error at %@: %@", path, String(describing: error)) + return .invalid + } + } + + private func parseShortcutBindings( + _ bindings: [String: KeyboardShortcutBindingDefinition], + sourcePath: String + ) -> [KeyboardShortcutSettings.Action: StoredShortcut] { + var parsed: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + + for (rawAction, definition) in bindings { + guard let action = KeyboardShortcutSettings.Action(rawValue: rawAction) else { + NSLog("[KeyboardShortcutSettings] ignoring unknown shortcut action '%@' in %@", rawAction, sourcePath) + continue + } + guard let shortcut = parseShortcutBinding(definition, for: action) else { + NSLog( + "[KeyboardShortcutSettings] ignoring invalid shortcut binding for '%@' in %@", + rawAction, + sourcePath + ) + continue + } + parsed[action] = shortcut + } + + return parsed + } + + private func parseShortcutBinding( + _ definition: KeyboardShortcutBindingDefinition, + for action: KeyboardShortcutSettings.Action + ) -> StoredShortcut? { + let shortcut: StoredShortcut? + switch definition { + case .single(let strokeText): + shortcut = parseStoredShortcut(strokes: [strokeText]) + case .chord(let strokeTexts): + shortcut = parseStoredShortcut(strokes: strokeTexts) + } + + guard let shortcut else { return nil } + if let normalized = action.normalizedRecordedShortcut(shortcut) { + return normalized + } + return action.usesNumberedDigitMatching ? nil : shortcut + } + + private func parseStoredShortcut(strokes: [String]) -> StoredShortcut? { + guard !strokes.isEmpty, strokes.count <= 2 else { return nil } + let parsedStrokes = strokes.compactMap(parseStroke(_:)) + guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { + return nil + } + let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil + return StoredShortcut(first: firstStroke, second: secondStroke) + } + + private func parseStroke(_ rawValue: String) -> ShortcutStroke? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split(separator: "+", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !parts.isEmpty, let lastPart = parts.last, !lastPart.isEmpty else { + return nil + } + + var command = false + var shift = false + var option = false + var control = false + + for modifier in parts.dropLast() { + switch modifier.lowercased() { + case "cmd", "command", "⌘": + command = true + case "shift", "⇧": + shift = true + case "opt", "option", "alt", "⌥": + option = true + case "ctrl", "control", "ctl", "⌃": + control = true + default: + return nil + } + } + + guard let key = parseKeyToken(lastPart) else { return nil } + return ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + ) + } + + private func parseKeyToken(_ rawValue: String) -> String? { + let lowered = rawValue.lowercased() + switch lowered { + case "left", "arrowleft", "leftarrow", "←": + return "←" + case "right", "arrowright", "rightarrow", "→": + return "→" + case "up", "arrowup", "uparrow", "↑": + return "↑" + case "down", "arrowdown", "downarrow", "↓": + return "↓" + case "tab": + return "\t" + case "return", "enter", "↩": + return "\r" + case "space": + return " " + case "comma": + return "," + case "period", "dot": + return "." + case "slash": + return "/" + case "backslash": + return "\\" + case "semicolon": + return ";" + case "quote", "apostrophe": + return "'" + case "backtick", "grave": + return "`" + case "minus", "hyphen": + return "-" + case "plus", "equals": + return "=" + case "leftbracket", "openbracket": + return "[" + case "rightbracket", "closebracket": + return "]" + default: + guard lowered.count == 1 else { return nil } + return lowered + } + } +} + +private struct KeyboardShortcutSettingsFile: Decodable { + let shortcuts: [String: KeyboardShortcutBindingDefinition]? +} + +private enum KeyboardShortcutBindingDefinition: Decodable { + case single(String) + case chord([String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + self = .single(value) + return + } + if let values = try? container.decode([String].self) { + self = .chord(values) + return + } + throw DecodingError.typeMismatch( + KeyboardShortcutBindingDefinition.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected a shortcut string or a two-stroke shortcut array" + ) + ) + } +} + +private final class ShortcutSettingsFileWatcher { + private let path: String + private let fileManager: FileManager + private let onChange: () -> Void + private let watchQueue = DispatchQueue(label: "com.cmux.shortcut-settings-file-watch") + + private var source: DispatchSourceFileSystemObject? + + init(path: String, fileManager: FileManager = .default, onChange: @escaping () -> Void) { + self.path = path + self.fileManager = fileManager + self.onChange = onChange + start() + } + + func stop() { + source?.cancel() + source = nil + } + + private func start() { + stop() + + if fileManager.fileExists(atPath: path) { + startFileWatcher() + } else { + startDirectoryWatcher() + } + } + + private func startFileWatcher() { + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { + startDirectoryWatcher() + return + } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .delete, .rename, .extend], + queue: watchQueue + ) + + source.setEventHandler { [weak self] in + guard let self else { return } + let flags = source.data + if flags.contains(.delete) || flags.contains(.rename) { + self.start() + } + self.onChange() + } + + source.setCancelHandler { + Darwin.close(fd) + } + + source.resume() + self.source = source + } + + private func startDirectoryWatcher() { + let directoryPath = (path as NSString).deletingLastPathComponent + if !fileManager.fileExists(atPath: directoryPath) { + try? fileManager.createDirectory( + atPath: directoryPath, + withIntermediateDirectories: true, + attributes: nil + ) + } + + let fd = open(directoryPath, O_EVTONLY) + guard fd >= 0 else { return } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .link, .rename], + queue: watchQueue + ) + + source.setEventHandler { [weak self] in + guard let self else { return } + guard self.fileManager.fileExists(atPath: self.path) else { return } + self.start() + self.onChange() + } + + source.setCancelHandler { + Darwin.close(fd) + } + + source.resume() + self.source = source + } +} diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index 91f777933ea5..4ec4cc8843bd 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -6,7 +6,7 @@ struct NotificationsPage: View { @EnvironmentObject var tabManager: TabManager @Binding var selection: SidebarSelection @FocusState private var focusedNotificationId: UUID? - @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared var body: some View { VStack(spacing: 0) { @@ -132,27 +132,17 @@ struct NotificationsPage: View { } private var jumpToUnreadShortcut: StoredShortcut { - decodeShortcut( - from: jumpToUnreadShortcutData, - fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut - ) - } - - private var hasUnreadNotifications: Bool { - notificationStore.notifications.contains(where: { !$0.isRead }) - } - - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .jumpToUnread) } private func tabTitle(for tabId: UUID) -> String? { AppDelegate.shared?.tabTitle(for: tabId) ?? tabManager.tabs.first(where: { $0.id == tabId })?.title } + + private var hasUnreadNotifications: Bool { + notificationStore.notifications.contains(where: { !$0.isRead }) + } } struct ShortcutAnnotation: View { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index ead597d46b4a..1603ac1a3266 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -297,8 +297,7 @@ struct BrowserPanelView: View { @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed - @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) - private var toggleBrowserDeveloperToolsShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var suggestionTask: Task? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @@ -324,7 +323,6 @@ struct BrowserPanelView: View { for: .light, themeBackgroundColor: GhosttyBackgroundTheme.currentColor() ) - @State private var toggleBrowserDeveloperToolsShortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut // Keep this below half of the compact omnibar height so it reads as a squircle, // not a capsule. private let omnibarPillCornerRadius: CGFloat = 10 @@ -415,7 +413,8 @@ struct BrowserPanelView: View { private var developerToolsButtonHelp: String { let base = String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools") - return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))" + let _ = keyboardShortcutSettingsObserver.revision + return "\(base) (\(KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools).displayString))" } private var browserImportHintSummary: String { @@ -537,7 +536,6 @@ struct BrowserPanelView: View { BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue, ]) refreshBrowserChromeStyle() - refreshToggleBrowserDeveloperToolsShortcut() let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard) if browserThemeModeRaw != resolvedThemeMode.rawValue { browserThemeModeRaw = resolvedThemeMode.rawValue @@ -601,9 +599,6 @@ struct BrowserPanelView: View { refreshBrowserChromeStyle() panel.refreshAppearanceDrivenColors() } - .onChange(of: toggleBrowserDeveloperToolsShortcutData) { _ in - refreshToggleBrowserDeveloperToolsShortcut() - } .onChange(of: panel.pendingAddressBarFocusRequestId) { _ in applyPendingAddressBarFocusRequestIfNeeded() } @@ -1239,21 +1234,6 @@ struct BrowserPanelView: View { ) } - private func refreshToggleBrowserDeveloperToolsShortcut() { - toggleBrowserDeveloperToolsShortcut = decodeShortcut( - from: toggleBrowserDeveloperToolsShortcutData, - fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut - ) - } - - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut - } - private func syncWebViewResponderPolicyWithViewState( reason: String, isPanelFocusedOverride: Bool? = nil diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index fd424bdbc57a..bca164006791 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1024,7 +1024,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private struct NotificationsPopoverView: View { @ObservedObject var notificationStore: TerminalNotificationStore - @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared let onDismiss: () -> Void var body: some View { @@ -1098,24 +1098,14 @@ private struct NotificationsPopoverView: View { } private var jumpToUnreadShortcut: StoredShortcut { - decodeShortcut( - from: jumpToUnreadShortcutData, - fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut - ) + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .jumpToUnread) } private var hasUnreadNotifications: Bool { notificationStore.notifications.contains(where: { !$0.isRead }) } - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut - } - private func jumpToLatestUnread() { DispatchQueue.main.async { AppDelegate.shared?.jumpToLatestUnread() diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 69cc0399c75f..13b877817352 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -715,8 +715,7 @@ extension WorkspaceContentView { struct EmptyPanelView: View { @ObservedObject var workspace: Workspace let paneId: PaneID - @AppStorage(KeyboardShortcutSettings.Action.newSurface.defaultsKey) private var newSurfaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.openBrowser.defaultsKey) private var openBrowserShortcutData = Data() + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private struct ShortcutHint: View { let text: String @@ -752,19 +751,13 @@ struct EmptyPanelView: View { } private var newSurfaceShortcut: StoredShortcut { - decodeShortcut(from: newSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.newSurface.defaultShortcut) + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .newSurface) } private var openBrowserShortcut: StoredShortcut { - decodeShortcut(from: openBrowserShortcutData, fallback: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut) - } - - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .openBrowser) } @ViewBuilder diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ead9f06b155c..ffe49bc80742 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -140,6 +140,7 @@ struct cmuxApp: App { @StateObject private var sidebarState = SidebarState() @StateObject private var sidebarSelectionState = SidebarSelectionState() @StateObject private var cmuxConfigStore = CmuxConfigStore() + @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let primaryWindowId = UUID() @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @@ -147,30 +148,7 @@ struct cmuxApp: App { @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue - @AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.newWindow.defaultsKey) private var newWindowShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.showNotifications.defaultsKey) private var showNotificationsShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.nextSurface.defaultsKey) private var nextSurfaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.prevSurface.defaultsKey) private var prevSurfaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.nextSidebarTab.defaultsKey) private var nextWorkspaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.selectWorkspaceByNumber.defaultsKey) private var selectWorkspaceByNumberShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() @AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing - @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) - private var toggleBrowserDeveloperToolsShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey) - private var showBrowserJavaScriptConsoleShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.toggleReactGrab.defaultsKey) - private var toggleReactGrabShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.openFolder.defaultsKey) private var openFolderShortcutData = Data() - @AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate private var browserToolbarAccessorySpacing: Int { @@ -369,10 +347,9 @@ struct cmuxApp: App { .windowStyle(.hiddenTitleBar) .commands { CommandGroup(replacing: .appSettings) { - Button(String(localized: "menu.app.settings", defaultValue: "Settings…")) { + splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) { appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } - .keyboardShortcut(",", modifiers: .command) } CommandGroup(replacing: .appInfo) { @@ -382,10 +359,9 @@ struct cmuxApp: App { Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) { GhosttyApp.shared.openConfigurationInTextEdit() } - Button(String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration")) { + splitCommandButton(title: String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration"), shortcut: menuShortcut(for: .reloadConfiguration)) { GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration") } - .keyboardShortcut(",", modifiers: [.command, .shift]) Divider() Button(String(localized: "menu.app.checkForUpdates", defaultValue: "Check for Updates…")) { appDelegate.checkForUpdates(nil) @@ -393,6 +369,12 @@ struct cmuxApp: App { InstallUpdateMenuItem(model: appDelegate.updateViewModel) } + CommandGroup(replacing: .appTermination) { + splitCommandButton(title: String(localized: "menu.quitCmux", defaultValue: "Quit cmux"), shortcut: menuShortcut(for: .quit)) { + NSApp.terminate(nil) + } + } + #if DEBUG CommandMenu("Update Pill") { Button("Show Update Pill") { @@ -431,11 +413,11 @@ struct cmuxApp: App { Divider() } - splitCommandButton(title: String(localized: "menu.notifications.show", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) { + splitCommandButton(title: String(localized: "menu.notifications.show", defaultValue: "Show Notifications"), shortcut: menuShortcut(for: .showNotifications)) { showNotificationsPopover() } - splitCommandButton(title: String(localized: "menu.notifications.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: jumpToUnreadMenuShortcut) { + splitCommandButton(title: String(localized: "menu.notifications.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: menuShortcut(for: .jumpToUnread)) { appDelegate.jumpToLatestUnread() } .disabled(!snapshot.hasUnreadNotifications) @@ -566,11 +548,11 @@ struct cmuxApp: App { // New tab commands CommandGroup(replacing: .newItem) { - splitCommandButton(title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), shortcut: newWindowMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.newWindow", defaultValue: "New Window"), shortcut: menuShortcut(for: .newWindow)) { appDelegate.openNewMainWindow(nil) } - splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "New Workspace"), shortcut: newWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.newWorkspace", defaultValue: "New Workspace"), shortcut: menuShortcut(for: .newTab)) { if let appDelegate = AppDelegate.shared { if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil { #if DEBUG @@ -585,7 +567,7 @@ struct cmuxApp: App { } } - splitCommandButton(title: String(localized: "menu.file.openFolder", defaultValue: "Open Folder…"), shortcut: openFolderMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.openFolder", defaultValue: "Open Folder…"), shortcut: menuShortcut(for: .openFolder)) { AppDelegate.shared?.showOpenFolderPanel() } @@ -602,17 +584,15 @@ struct cmuxApp: App { // Close tab/workspace CommandGroup(after: .newItem) { - Button(String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…")) { + splitCommandButton(title: String(localized: "menu.file.goToWorkspace", defaultValue: "Go to Workspace…"), shortcut: menuShortcut(for: .goToWorkspace)) { let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) } - .keyboardShortcut("p", modifiers: [.command]) - Button(String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…")) { + splitCommandButton(title: String(localized: "menu.file.commandPalette", defaultValue: "Command Palette…"), shortcut: menuShortcut(for: .commandPalette)) { let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) } - .keyboardShortcut("p", modifiers: [.command, .shift]) Divider() @@ -621,20 +601,18 @@ struct cmuxApp: App { // default, closing the last surface also closes the workspace and the window // if it was also the last workspace. Users can opt into keeping the workspace // open instead. - Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) { + splitCommandButton(title: String(localized: "menu.file.closeTab", defaultValue: "Close Tab"), shortcut: menuShortcut(for: .closeTab)) { closePanelOrWindow() } - .keyboardShortcut("w", modifiers: .command) - Button(String(localized: "menu.file.closeOtherTabs", defaultValue: "Close Other Tabs in Pane")) { + splitCommandButton(title: String(localized: "menu.file.closeOtherTabs", defaultValue: "Close Other Tabs in Pane"), shortcut: menuShortcut(for: .closeOtherTabsInPane)) { closeOtherTabsInFocusedPane() } - .keyboardShortcut("t", modifiers: [.command, .option]) .disabled(!activeTabManager.canCloseOtherTabsInFocusedPane()) // Cmd+Shift+W closes the current workspace (with confirmation if needed). If this // is the last workspace, it closes the window. - splitCommandButton(title: String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace"), shortcut: closeWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace"), shortcut: menuShortcut(for: .closeWorkspace)) { closeTabOrWindow() } @@ -642,54 +620,48 @@ struct cmuxApp: App { workspaceCommandMenuContent(manager: activeTabManager) } - Button(String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel")) { + splitCommandButton(title: String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel"), shortcut: menuShortcut(for: .reopenClosedBrowserPanel)) { _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() } - .keyboardShortcut("t", modifiers: [.command, .shift]) } // Find CommandGroup(after: .textEditing) { Menu(String(localized: "menu.find.title", defaultValue: "Find")) { - Button(String(localized: "menu.find.find", defaultValue: "Find…")) { + splitCommandButton(title: String(localized: "menu.find.find", defaultValue: "Find…"), shortcut: menuShortcut(for: .find)) { #if DEBUG dlog("find.menu Cmd+F fired") #endif activeTabManager.startSearch() } - .keyboardShortcut("f", modifiers: .command) - Button(String(localized: "menu.find.findNext", defaultValue: "Find Next")) { + splitCommandButton(title: String(localized: "menu.find.findNext", defaultValue: "Find Next"), shortcut: menuShortcut(for: .findNext)) { activeTabManager.findNext() } - .keyboardShortcut("g", modifiers: .command) - Button(String(localized: "menu.find.findPrevious", defaultValue: "Find Previous")) { + splitCommandButton(title: String(localized: "menu.find.findPrevious", defaultValue: "Find Previous"), shortcut: menuShortcut(for: .findPrevious)) { activeTabManager.findPrevious() } - .keyboardShortcut("g", modifiers: [.command, .shift]) Divider() - Button(String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar")) { + splitCommandButton(title: String(localized: "menu.find.hideFindBar", defaultValue: "Hide Find Bar"), shortcut: menuShortcut(for: .hideFind)) { activeTabManager.hideFind() } - .keyboardShortcut("f", modifiers: [.command, .shift]) .disabled(!(activeTabManager.isFindVisible)) Divider() - Button(String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find")) { + splitCommandButton(title: String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find"), shortcut: menuShortcut(for: .useSelectionForFind)) { activeTabManager.searchSelection() } - .keyboardShortcut("e", modifiers: .command) .disabled(!(activeTabManager.canUseSelectionForFind)) } } // Tab navigation CommandGroup(after: .toolbar) { - splitCommandButton(title: String(localized: "menu.view.toggleSidebar", defaultValue: "Toggle Sidebar"), shortcut: toggleSidebarMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.toggleSidebar", defaultValue: "Toggle Sidebar"), shortcut: menuShortcut(for: .toggleSidebar)) { if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true { sidebarState.toggle() } @@ -697,61 +669,55 @@ struct cmuxApp: App { Divider() - splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: nextSurfaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: menuShortcut(for: .nextSurface)) { activeTabManager.selectNextSurface() } - splitCommandButton(title: String(localized: "menu.view.previousSurface", defaultValue: "Previous Surface"), shortcut: prevSurfaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.previousSurface", defaultValue: "Previous Surface"), shortcut: menuShortcut(for: .prevSurface)) { activeTabManager.selectPreviousSurface() } - Button(String(localized: "menu.view.back", defaultValue: "Back")) { + splitCommandButton(title: String(localized: "menu.view.back", defaultValue: "Back"), shortcut: menuShortcut(for: .browserBack)) { activeTabManager.focusedBrowserPanel?.goBack() } - .keyboardShortcut("[", modifiers: .command) - Button(String(localized: "menu.view.forward", defaultValue: "Forward")) { + splitCommandButton(title: String(localized: "menu.view.forward", defaultValue: "Forward"), shortcut: menuShortcut(for: .browserForward)) { activeTabManager.focusedBrowserPanel?.goForward() } - .keyboardShortcut("]", modifiers: .command) - Button(String(localized: "menu.view.reloadPage", defaultValue: "Reload Page")) { + splitCommandButton(title: String(localized: "menu.view.reloadPage", defaultValue: "Reload Page"), shortcut: menuShortcut(for: .browserReload)) { activeTabManager.focusedBrowserPanel?.reload() } - .keyboardShortcut("r", modifiers: .command) - splitCommandButton(title: String(localized: "menu.view.toggleDevTools", defaultValue: "Toggle Developer Tools"), shortcut: toggleBrowserDeveloperToolsMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.toggleDevTools", defaultValue: "Toggle Developer Tools"), shortcut: menuShortcut(for: .toggleBrowserDeveloperTools)) { let manager = activeTabManager if !manager.toggleDeveloperToolsFocusedBrowser() { NSSound.beep() } } - splitCommandButton(title: String(localized: "menu.view.showJSConsole", defaultValue: "Show JavaScript Console"), shortcut: showBrowserJavaScriptConsoleMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.showJSConsole", defaultValue: "Show JavaScript Console"), shortcut: menuShortcut(for: .showBrowserJavaScriptConsole)) { let manager = activeTabManager if !manager.showJavaScriptConsoleFocusedBrowser() { NSSound.beep() } } - splitCommandButton(title: String(localized: "menu.view.toggleReactGrab", defaultValue: "Toggle React Grab"), shortcut: toggleReactGrabMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.toggleReactGrab", defaultValue: "Toggle React Grab"), shortcut: menuShortcut(for: .toggleReactGrab)) { activeTabManager.toggleReactGrabFocusedBrowser() } - Button(String(localized: "menu.view.zoomIn", defaultValue: "Zoom In")) { + splitCommandButton(title: String(localized: "menu.view.zoomIn", defaultValue: "Zoom In"), shortcut: menuShortcut(for: .browserZoomIn)) { _ = activeTabManager.zoomInFocusedBrowser() } - .keyboardShortcut("=", modifiers: .command) - Button(String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out")) { + splitCommandButton(title: String(localized: "menu.view.zoomOut", defaultValue: "Zoom Out"), shortcut: menuShortcut(for: .browserZoomOut)) { _ = activeTabManager.zoomOutFocusedBrowser() } - .keyboardShortcut("-", modifiers: .command) - Button(String(localized: "menu.view.actualSize", defaultValue: "Actual Size")) { + splitCommandButton(title: String(localized: "menu.view.actualSize", defaultValue: "Actual Size"), shortcut: menuShortcut(for: .browserZoomReset)) { _ = activeTabManager.resetZoomFocusedBrowser() } - .keyboardShortcut("0", modifiers: .command) Button(String(localized: "menu.view.clearBrowserHistory", defaultValue: "Clear Browser History")) { BrowserHistoryStore.shared.clearHistory() @@ -764,33 +730,38 @@ struct cmuxApp: App { } } - splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: menuShortcut(for: .nextSidebarTab)) { activeTabManager.selectNextTab() } - splitCommandButton(title: String(localized: "menu.view.previousWorkspace", defaultValue: "Previous Workspace"), shortcut: prevWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.previousWorkspace", defaultValue: "Previous Workspace"), shortcut: menuShortcut(for: .prevSidebarTab)) { activeTabManager.selectPreviousTab() } - splitCommandButton(title: String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…"), shortcut: renameWorkspaceMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…"), shortcut: menuShortcut(for: .renameWorkspace)) { _ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette() } + splitCommandButton(title: String(localized: "command.toggleFullScreen.title", defaultValue: "Toggle Full Screen"), shortcut: menuShortcut(for: .toggleFullScreen)) { + guard let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow else { return } + targetWindow.toggleFullScreen(nil) + } + Divider() - splitCommandButton(title: String(localized: "menu.view.splitRight", defaultValue: "Split Right"), shortcut: splitRightMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitRight", defaultValue: "Split Right"), shortcut: menuShortcut(for: .splitRight)) { performSplitFromMenu(direction: .right) } - splitCommandButton(title: String(localized: "menu.view.splitDown", defaultValue: "Split Down"), shortcut: splitDownMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitDown", defaultValue: "Split Down"), shortcut: menuShortcut(for: .splitDown)) { performSplitFromMenu(direction: .down) } - splitCommandButton(title: String(localized: "menu.view.splitBrowserRight", defaultValue: "Split Browser Right"), shortcut: splitBrowserRightMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitBrowserRight", defaultValue: "Split Browser Right"), shortcut: menuShortcut(for: .splitBrowserRight)) { performBrowserSplitFromMenu(direction: .right) } - splitCommandButton(title: String(localized: "menu.view.splitBrowserDown", defaultValue: "Split Browser Down"), shortcut: splitBrowserDownMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.splitBrowserDown", defaultValue: "Split Browser Down"), shortcut: menuShortcut(for: .splitBrowserDown)) { performBrowserSplitFromMenu(direction: .down) } @@ -798,7 +769,8 @@ struct cmuxApp: App { // Numbered workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in - if selectWorkspaceByNumberMenuShortcut.hasChord { + let selectWorkspaceByNumberShortcut = menuShortcut(for: .selectWorkspaceByNumber) + if selectWorkspaceByNumberShortcut.hasChord { Button(String(localized: "menu.view.workspace", defaultValue: "Workspace \(number)")) { let manager = activeTabManager if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: number, workspaceCount: manager.tabs.count) { @@ -814,18 +786,18 @@ struct cmuxApp: App { } .keyboardShortcut( KeyEquivalent(Character("\(number)")), - modifiers: selectWorkspaceByNumberMenuShortcut.eventModifiers + modifiers: selectWorkspaceByNumberShortcut.eventModifiers ) } } Divider() - splitCommandButton(title: String(localized: "menu.view.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: jumpToUnreadMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.jumpToUnread", defaultValue: "Jump to Latest Unread"), shortcut: menuShortcut(for: .jumpToUnread)) { AppDelegate.shared?.jumpToLatestUnread() } - splitCommandButton(title: String(localized: "menu.view.showNotifications", defaultValue: "Show Notifications"), shortcut: showNotificationsMenuShortcut) { + splitCommandButton(title: String(localized: "menu.view.showNotifications", defaultValue: "Show Notifications"), shortcut: menuShortcut(for: .showNotifications)) { showNotificationsPopover() } } @@ -874,120 +846,9 @@ struct cmuxApp: App { SocketControlSettings.migrateMode(socketControlMode) } - private var splitRightMenuShortcut: StoredShortcut { - decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut) - } - - private var toggleSidebarMenuShortcut: StoredShortcut { - decodeShortcut(from: toggleSidebarShortcutData, fallback: KeyboardShortcutSettings.Action.toggleSidebar.defaultShortcut) - } - - private var newWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut(from: newWorkspaceShortcutData, fallback: KeyboardShortcutSettings.Action.newTab.defaultShortcut) - } - - private var newWindowMenuShortcut: StoredShortcut { - decodeShortcut(from: newWindowShortcutData, fallback: KeyboardShortcutSettings.Action.newWindow.defaultShortcut) - } - - private var openFolderMenuShortcut: StoredShortcut { - decodeShortcut(from: openFolderShortcutData, fallback: KeyboardShortcutSettings.Action.openFolder.defaultShortcut) - } - - private var showNotificationsMenuShortcut: StoredShortcut { - decodeShortcut( - from: showNotificationsShortcutData, - fallback: KeyboardShortcutSettings.Action.showNotifications.defaultShortcut - ) - } - - private var jumpToUnreadMenuShortcut: StoredShortcut { - decodeShortcut( - from: jumpToUnreadShortcutData, - fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut - ) - } - - private var nextSurfaceMenuShortcut: StoredShortcut { - decodeShortcut(from: nextSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.nextSurface.defaultShortcut) - } - - private var prevSurfaceMenuShortcut: StoredShortcut { - decodeShortcut(from: prevSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.prevSurface.defaultShortcut) - } - - private var nextWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut( - from: nextWorkspaceShortcutData, - fallback: KeyboardShortcutSettings.Action.nextSidebarTab.defaultShortcut - ) - } - - private var prevWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut( - from: prevWorkspaceShortcutData, - fallback: KeyboardShortcutSettings.Action.prevSidebarTab.defaultShortcut - ) - } - - private var selectWorkspaceByNumberMenuShortcut: StoredShortcut { - decodeShortcut( - from: selectWorkspaceByNumberShortcutData, - fallback: KeyboardShortcutSettings.Action.selectWorkspaceByNumber.defaultShortcut - ) - } - - private var splitDownMenuShortcut: StoredShortcut { - 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 toggleReactGrabMenuShortcut: StoredShortcut { - decodeShortcut( - from: toggleReactGrabShortcutData, - fallback: KeyboardShortcutSettings.Action.toggleReactGrab.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 renameWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut( - from: renameWorkspaceShortcutData, - fallback: KeyboardShortcutSettings.Action.renameWorkspace.defaultShortcut - ) - } - - private var closeWorkspaceMenuShortcut: StoredShortcut { - decodeShortcut( - from: closeWorkspaceShortcutData, - fallback: KeyboardShortcutSettings.Action.closeWorkspace.defaultShortcut - ) + private func menuShortcut(for action: KeyboardShortcutSettings.Action) -> StoredShortcut { + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: action) } private var notificationMenuSnapshot: NotificationMenuSnapshot { @@ -1000,14 +861,6 @@ struct cmuxApp: App { ) ?? tabManager } - private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { - guard !data.isEmpty, - let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { - return fallback - } - return shortcut - } - private func notificationMenuItemTitle(for notification: TerminalNotification) -> String { let tabTitle = appDelegate.tabTitle(for: notification.tabId) return MenuBarNotificationLineFormatter.menuTitle(notification: notification, tabTitle: tabTitle) @@ -6583,9 +6436,11 @@ private struct ShortcutSettingRow: View { var body: some View { KeyboardShortcutRecorder( label: action.label, + subtitle: KeyboardShortcutSettings.settingsFileManagedSubtitle(for: action), shortcut: $shortcut, displayString: { action.displayedShortcutString(for: $0) }, - transformRecordedShortcut: { action.normalizedRecordedShortcut($0) } + transformRecordedShortcut: { action.normalizedRecordedShortcut($0) }, + isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(action) ) .onChange(of: shortcut) { newValue in KeyboardShortcutSettings.setShortcut(newValue, for: action) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index b9d28c5bdd1b..2caf72689666 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -13,6 +13,7 @@ private final class FakeWKInspectorContainerView: NSView {} final class AppDelegateShortcutRoutingTests: XCTestCase { private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] private var actionsWithPersistedShortcut: Set = [] + private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! private func makeKeyEvent( modifierFlags: NSEvent.ModifierFlags, @@ -51,10 +52,12 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { (action, KeyboardShortcutSettings.shortcut(for: action)) } ) + originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore KeyboardShortcutSettings.resetAll() } override func tearDown() { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore AppDelegate.shared?.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) AppDelegate.shared?.debugCloseMainWindowConfirmationHandler = nil AppDelegate.shared?.debugCreateMainWindowSourceIsNativeFullScreenOverride = nil @@ -190,6 +193,78 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(manager.tabs.count, initialCount + 1, "Chord second key should dispatch the configured shortcut") } + func testSettingsFileChordDispatchesNewWorkspaceShortcut() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and tab manager") + return + } + + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try """ + { + "shortcuts": { + "newTab": ["ctrl+b", "n"] + } + } + """.write(to: settingsFileURL, atomically: true, encoding: .utf8) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertEqual(manager.tabs.count, initialCount, "Chord prefix must not fire the action early") + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount + 1, "settings.json chord should dispatch the configured shortcut") + } + func testChordedShortcutMismatchDoesNotConsumeSecondKey() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 11785cc1b6ec..03e79250e368 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -1213,6 +1213,15 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { XCTAssertFalse(shortcut.shift) XCTAssertFalse(shortcut.control) } + + func testDefaultShortcutForToggleReactGrabAvoidsFindPreviousCollision() { + let shortcut = KeyboardShortcutSettings.Action.toggleReactGrab.defaultShortcut + XCTAssertEqual(shortcut.key, "g") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index ecdaea95cc5e..8b1fa6bd7ee6 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -275,6 +275,146 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { } } +final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { + private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! + + override func setUp() { + super.setUp() + originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore + KeyboardShortcutSettings.resetAll() + } + + override func tearDown() { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + KeyboardShortcutSettings.resetAll() + super.tearDown() + } + + func testSettingsFileStoreParsesSingleStrokeChordAndNumberedChord() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "toggleSidebar": "cmd+b", + "newTab": ["ctrl+b", "c"], + "selectWorkspaceByNumber": ["ctrl+b", "7"] + } + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + store.override(for: .toggleSidebar), + StoredShortcut(key: "b", command: true, shift: false, option: false, control: false) + ) + XCTAssertEqual( + store.override(for: .newTab), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + ) + XCTAssertEqual( + store.override(for: .selectWorkspaceByNumber), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "1") + ) + XCTAssertEqual(store.activeSourcePath, settingsFileURL.path) + } + + func testSettingsFileStoreUsesFallbackOnlyWhenPrimaryIsMissing() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let primaryURL = directoryURL.appendingPathComponent("primary.json", isDirectory: false) + let fallbackURL = directoryURL.appendingPathComponent("fallback.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: fallbackURL + ) + + let fallbackStore = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + XCTAssertEqual( + fallbackStore.override(for: .showNotifications), + StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) + ) + XCTAssertEqual(fallbackStore.activeSourcePath, fallbackURL.path) + + try writeSettingsFile("{ not valid json", to: primaryURL) + + let invalidPrimaryStore = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + XCTAssertNil(invalidPrimaryStore.override(for: .showNotifications)) + XCTAssertEqual(invalidPrimaryStore.activeSourcePath, primaryURL.path) + } + + func testShortcutSettingsFileOverridesPersistedShortcutValues() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "newTab": ["ctrl+b", "c"] + } + } + """, + to: settingsFileURL + ) + + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "n", command: true, shift: false, option: false, control: false), + for: .newTab + ) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + KeyboardShortcutSettings.shortcut(for: .newTab), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + ) + XCTAssertTrue(KeyboardShortcutSettings.isManagedBySettingsFile(.newTab)) + XCTAssertNotNil(KeyboardShortcutSettings.settingsFileManagedSubtitle(for: .newTab)) + } + + private func makeTemporaryDirectory() throws -> URL { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL + } + + private func writeSettingsFile(_ contents: String, to url: URL) throws { + try contents.write(to: url, atomically: true, encoding: .utf8) + } +} + final class WorkspaceShortcutMapperTests: XCTestCase { func testCommandNineMapsToLastWorkspaceIndex() { diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 182ed116b4b2..4e9e21b4fa0f 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -105,6 +105,26 @@ working-directory = ~/Projects`} {t("automationCallout")} +

{t("settingsFile")}

+

{t("settingsFileDesc")}

+
    +
  1. + ~/.config/cmux/settings.json +
  2. +
  3. + ~/Library/Application Support/com.cmuxterm.app/settings.json +
  4. +
+ {t("settingsFilePrecedence")} + {`{ + "shortcuts": { + "toggleSidebar": "cmd+b", + "newTab": ["ctrl+b", "c"], + "selectWorkspaceByNumber": ["ctrl+b", "1"] + } +}`} +

{t("settingsFileChords")}

+

{t("browserLinkBehavior")}

{t("browserLinkDesc")}

    diff --git a/web/app/[locale]/keyboard-shortcuts.tsx b/web/app/[locale]/keyboard-shortcuts.tsx index c762510949f9..b1d0243976cf 100644 --- a/web/app/[locale]/keyboard-shortcuts.tsx +++ b/web/app/[locale]/keyboard-shortcuts.tsx @@ -1,12 +1,18 @@ "use client"; import { useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; + +type LocalizedText = { + en: string; + ja: string; +}; type Shortcut = { id: string; combos: string[][]; - note?: string; + description: LocalizedText; + note?: LocalizedText; }; type ShortcutCategory = { @@ -17,18 +23,40 @@ type ShortcutCategory = { }; const CATEGORIES: ShortcutCategory[] = [ + { + id: "app", + titleKey: "app", + blurbKey: "appBlurb", + shortcuts: [ + { id: "openSettings", combos: [["⌘", ","]], description: { en: "Settings", ja: "設定" } }, + { id: "reloadConfiguration", combos: [["⌘", "⇧", ","]], description: { en: "Reload configuration", ja: "構成を再読み込み" } }, + { id: "commandPalette", combos: [["⌘", "⇧", "P"]], description: { en: "Command palette", ja: "コマンドパレット" } }, + { id: "newWindow", combos: [["⌘", "⇧", "N"]], description: { en: "New window", ja: "新規ウインドウ" } }, + { id: "closeWindow", combos: [["⌃", "⌘", "W"]], description: { en: "Close window", ja: "ウインドウを閉じる" } }, + { id: "toggleFullScreen", combos: [["⌃", "⌘", "F"]], description: { en: "Toggle full screen", ja: "フルスクリーンを切り替え" } }, + { id: "sendFeedback", combos: [["⌥", "⌘", "F"]], description: { en: "Send feedback", ja: "フィードバックを送信" } }, + { id: "quit", combos: [["⌘", "Q"]], description: { en: "Quit cmux", ja: "cmuxを終了" } }, + ], + }, { id: "workspaces", titleKey: "workspaces", blurbKey: "workspacesBlurb", shortcuts: [ - { id: "ws-new", combos: [["⌘", "N"]] }, - { id: "ws-prev", combos: [["⌃", "⌘", "["]] }, - { id: "ws-next", combos: [["⌃", "⌘", "]"]] }, - { id: "ws-jump-1-8", combos: [["⌘", "1–8"]] }, - { id: "ws-jump-last", combos: [["⌘", "9"]] }, - { id: "ws-close", combos: [["⌘", "⇧", "W"]] }, - { id: "ws-rename", combos: [["⌘", "⇧", "R"]] }, + { id: "toggleSidebar", combos: [["⌘", "B"]], description: { en: "Toggle sidebar", ja: "サイドバーを切り替え" } }, + { id: "newTab", combos: [["⌘", "N"]], description: { en: "New workspace", ja: "新規ワークスペース" } }, + { id: "openFolder", combos: [["⌘", "O"]], description: { en: "Open folder", ja: "フォルダを開く" } }, + { + id: "goToWorkspace", + combos: [["⌘", "P"]], + description: { en: "Go to workspace", ja: "ワークスペースへ移動" }, + note: { en: "workspace switcher", ja: "ワークスペーススイッチャー" }, + }, + { id: "nextSidebarTab", combos: [["⌃", "⌘", "]"]], description: { en: "Next workspace", ja: "次のワークスペース" } }, + { id: "prevSidebarTab", combos: [["⌃", "⌘", "["]], description: { en: "Previous workspace", ja: "前のワークスペース" } }, + { id: "selectWorkspaceByNumber", combos: [["⌘", "1…9"]], description: { en: "Select workspace 1…9", ja: "ワークスペース1…9を選択" } }, + { id: "renameWorkspace", combos: [["⌘", "⇧", "R"]], description: { en: "Rename workspace", ja: "ワークスペース名を変更" } }, + { id: "closeWorkspace", combos: [["⌘", "⇧", "W"]], description: { en: "Close workspace", ja: "ワークスペースを閉じる" } }, ], }, { @@ -36,78 +64,85 @@ const CATEGORIES: ShortcutCategory[] = [ titleKey: "surfaces", blurbKey: "surfacesBlurb", shortcuts: [ - { id: "sf-new", combos: [["⌘", "T"]] }, - { id: "sf-prev-1", combos: [["⌘", "⇧", "["]] }, - { id: "sf-prev-2", combos: [["⌃", "⇧", "Tab"]] }, - { id: "sf-jump-1-8", combos: [["⌃", "1–8"]] }, - { id: "sf-jump-last", combos: [["⌃", "9"]] }, - { id: "sf-close", combos: [["⌘", "W"]] }, + { id: "newSurface", combos: [["⌘", "T"]], description: { en: "New surface", ja: "新規サーフェス" } }, + { id: "nextSurface", combos: [["⌘", "⇧", "]"]], description: { en: "Next surface", ja: "次のサーフェス" } }, + { id: "prevSurface", combos: [["⌘", "⇧", "["]], description: { en: "Previous surface", ja: "前のサーフェス" } }, + { id: "selectSurfaceByNumber", combos: [["⌃", "1…9"]], description: { en: "Select surface 1…9", ja: "サーフェス1…9を選択" } }, + { id: "renameTab", combos: [["⌘", "R"]], description: { en: "Rename tab", ja: "タブ名を変更" } }, + { id: "closeTab", combos: [["⌘", "W"]], description: { en: "Close tab", ja: "タブを閉じる" } }, + { id: "closeOtherTabsInPane", combos: [["⌥", "⌘", "T"]], description: { en: "Close other tabs in pane", ja: "ペイン内の他のタブを閉じる" } }, + { id: "reopenClosedBrowserPanel", combos: [["⌘", "⇧", "T"]], description: { en: "Reopen closed browser panel", ja: "閉じたブラウザパネルを再度開く" } }, + { id: "toggleTerminalCopyMode", combos: [["⌘", "⇧", "M"]], description: { en: "Toggle terminal copy mode", ja: "ターミナルコピーモードを切り替え" } }, ], }, { id: "split-panes", titleKey: "splitPanes", shortcuts: [ - { id: "sp-right", combos: [["⌘", "D"]] }, - { id: "sp-down", combos: [["⌘", "⇧", "D"]] }, - { id: "sp-focus", combos: [["⌥", "⌘", "←/→/↑/↓"]] }, - { id: "sp-browser-right", combos: [["⌥", "⌘", "D"]] }, - { id: "sp-browser-down", combos: [["⌥", "⌘", "⇧", "D"]] }, + { id: "focusLeft", combos: [["⌥", "⌘", "←"]], description: { en: "Focus pane left", ja: "左のペインにフォーカス" } }, + { id: "focusRight", combos: [["⌥", "⌘", "→"]], description: { en: "Focus pane right", ja: "右のペインにフォーカス" } }, + { id: "focusUp", combos: [["⌥", "⌘", "↑"]], description: { en: "Focus pane up", ja: "上のペインにフォーカス" } }, + { id: "focusDown", combos: [["⌥", "⌘", "↓"]], description: { en: "Focus pane down", ja: "下のペインにフォーカス" } }, + { id: "splitRight", combos: [["⌘", "D"]], description: { en: "Split right", ja: "右に分割" } }, + { id: "splitDown", combos: [["⌘", "⇧", "D"]], description: { en: "Split down", ja: "下に分割" } }, + { id: "splitBrowserRight", combos: [["⌥", "⌘", "D"]], description: { en: "Split browser right", ja: "右にブラウザ分割" } }, + { id: "splitBrowserDown", combos: [["⌥", "⌘", "⇧", "D"]], description: { en: "Split browser down", ja: "下にブラウザ分割" } }, + { id: "toggleSplitZoom", combos: [["⌘", "⇧", "↩"]], description: { en: "Toggle pane zoom", ja: "ペインズームを切り替え" } }, ], }, { id: "browser", titleKey: "browser", shortcuts: [ - { id: "br-open", combos: [["⌘", "⇧", "L"]] }, - { id: "br-addr", combos: [["⌘", "L"]] }, - { id: "br-forward", combos: [["⌘", "]"]] }, - { id: "br-reload", combos: [["⌘", "R"]] }, - { id: "br-devtools", combos: [["⌥", "⌘", "I"]] }, - ], - }, - { - id: "notifications", - titleKey: "notifications", - shortcuts: [ - { id: "nt-panel", combos: [["⌘", "⇧", "I"]] }, - { id: "nt-latest", combos: [["⌘", "⇧", "U"]] }, - { id: "nt-flash", combos: [["⌘", "⇧", "L"]] }, + { id: "openBrowser", combos: [["⌘", "⇧", "L"]], description: { en: "Open browser", ja: "ブラウザを開く" } }, + { id: "focusBrowserAddressBar", combos: [["⌘", "L"]], description: { en: "Focus address bar", ja: "アドレスバーにフォーカス" } }, + { id: "browserBack", combos: [["⌘", "["]], description: { en: "Back", ja: "戻る" } }, + { id: "browserForward", combos: [["⌘", "]"]], description: { en: "Forward", ja: "進む" } }, + { + id: "browserReload", + combos: [["⌘", "R"]], + description: { en: "Reload page", ja: "ページを再読み込み" }, + note: { en: "focused browser", ja: "フォーカス中のブラウザ" }, + }, + { id: "browserZoomIn", combos: [["⌘", "="]], description: { en: "Zoom in", ja: "拡大" } }, + { id: "browserZoomOut", combos: [["⌘", "-"]], description: { en: "Zoom out", ja: "縮小" } }, + { id: "browserZoomReset", combos: [["⌘", "0"]], description: { en: "Actual size", ja: "実寸表示" } }, + { id: "toggleBrowserDeveloperTools", combos: [["⌥", "⌘", "I"]], description: { en: "Toggle browser developer tools", ja: "ブラウザ開発者ツールを切り替え" } }, + { id: "showBrowserJavaScriptConsole", combos: [["⌥", "⌘", "C"]], description: { en: "Show browser JavaScript console", ja: "ブラウザJavaScriptコンソールを表示" } }, + { + id: "toggleReactGrab", + combos: [["⌥", "⌘", "G"]], + description: { en: "Toggle React Grab", ja: "React Grabを切り替え" }, + note: { en: "focused browser", ja: "フォーカス中のブラウザ" }, + }, ], }, { id: "find", titleKey: "find", shortcuts: [ - { id: "fd-find", combos: [["⌘", "F"]] }, - { id: "fd-next-prev", combos: [["⌘", "G"], ["⌘", "⇧", "G"]] }, - { id: "fd-hide", combos: [["⌘", "⇧", "F"]] }, - { id: "fd-selection", combos: [["⌘", "E"]] }, + { id: "find", combos: [["⌘", "F"]], description: { en: "Find", ja: "検索" } }, + { id: "findNext", combos: [["⌘", "G"]], description: { en: "Find next", ja: "次を検索" } }, + { id: "findPrevious", combos: [["⌘", "⇧", "G"]], description: { en: "Find previous", ja: "前を検索" } }, + { id: "hideFind", combos: [["⌘", "⇧", "F"]], description: { en: "Hide find bar", ja: "検索バーを隠す" } }, + { id: "useSelectionForFind", combos: [["⌘", "E"]], description: { en: "Use selection for find", ja: "選択範囲で検索" } }, ], }, { - id: "terminal", - titleKey: "terminal", - shortcuts: [ - { id: "tm-clear", combos: [["⌘", "K"]] }, - { id: "tm-copy", combos: [["⌘", "C"]] }, - { id: "tm-paste", combos: [["⌘", "V"]] }, - { id: "tm-font", combos: [["⌘", "+"], ["⌘", "-"]] }, - { id: "tm-reset", combos: [["⌘", "0"]] }, - ], - }, - { - id: "window", - titleKey: "window", + id: "notifications", + titleKey: "notifications", shortcuts: [ - { id: "wn-new", combos: [["⌘", "⇧", "N"]] }, - { id: "wn-settings", combos: [["⌘", ","]] }, - { id: "wn-reload", combos: [["⌘", "⇧", "R"]] }, - { id: "wn-quit", combos: [["⌘", "Q"]] }, + { id: "showNotifications", combos: [["⌘", "I"]], description: { en: "Show notifications", ja: "通知を表示" } }, + { id: "jumpToUnread", combos: [["⌘", "⇧", "U"]], description: { en: "Jump to latest unread", ja: "最新の未読へ移動" } }, + { id: "triggerFlash", combos: [["⌘", "⇧", "H"]], description: { en: "Flash focused panel", ja: "フォーカス中のパネルをフラッシュ" } }, ], }, ]; +function localizedText(text: LocalizedText, locale: string) { + return locale.startsWith("ja") ? text.ja : text.en; +} + function normalize(s: string) { return s.toLowerCase().replace(/\s+/g, " ").trim(); } @@ -123,7 +158,7 @@ function KeyCombo({ combo }: { combo: string[] }) { {k} {idx < combo.length - 1 && ( - + + )} @@ -133,27 +168,21 @@ function KeyCombo({ combo }: { combo: string[] }) { ); } -function ShortcutRow({ shortcut, description, note }: { shortcut: Shortcut; description: string; note?: string }) { +function ShortcutRow({ shortcut, locale }: { shortcut: Shortcut; locale: string }) { + const description = localizedText(shortcut.description, locale); + const note = shortcut.note ? localizedText(shortcut.note, locale) : undefined; + return ( -
    +
    - - {description} - - {note && ( - - {note} - - )} + {description} + {note && {note}}
    -
    +
    {shortcut.combos.map((combo, idx) => ( - + {idx > 0 && ( - + / )} @@ -167,6 +196,7 @@ function ShortcutRow({ shortcut, description, note }: { shortcut: Shortcut; desc export function KeyboardShortcuts() { const [query, setQuery] = useState(""); + const locale = useLocale(); const t = useTranslations("docs.keyboardShortcuts"); const trimmedQuery = query.trim(); @@ -176,18 +206,18 @@ export function KeyboardShortcuts() { if (!q) return CATEGORIES; return CATEGORIES.map((cat) => ({ ...cat, - shortcuts: cat.shortcuts.filter((s) => { + shortcuts: cat.shortcuts.filter((shortcut) => { const catTitle = t(`cat.${cat.titleKey}`); - const desc = t(`sc.${s.id}`); - const combos = s.combos.map(comboToText).join(" "); - return normalize(`${catTitle} ${combos} ${desc} ${s.note ?? ""}`).includes(q); + const description = localizedText(shortcut.description, locale); + const note = shortcut.note ? localizedText(shortcut.note, locale) : ""; + const combos = shortcut.combos.map(comboToText).join(" "); + return normalize(`${catTitle} ${combos} ${description} ${note}`).includes(q); }), })).filter((cat) => cat.shortcuts.length > 0); - }, [query, t]); + }, [locale, query, t]); return ( -
    - {/* Search */} +
    setQuery(e.target.value)} placeholder={t("searchPlaceholder")} - className="w-full pl-9 pr-3 py-1.5 rounded-lg border border-border bg-transparent text-[13px] placeholder:text-muted/40 focus:outline-none focus:border-foreground/20 transition-colors" + className="w-full rounded-lg border border-border bg-transparent py-1.5 pl-9 pr-3 text-[13px] transition-colors placeholder:text-muted/40 focus:border-foreground/20 focus:outline-none" aria-label={t("searchLabel")} />
    - {/* Category jump links */} {!trimmedQuery && ( -
    -
    +
    - {cat.shortcuts.map((s) => ( - + {cat.shortcuts.map((shortcut) => ( + ))}
    diff --git a/web/messages/en.json b/web/messages/en.json index 0e377b1b7e30..68921b99322c 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -316,8 +316,8 @@ "configuration": { "title": "Configuration", "metaTitle": "Configuration", - "metaDescription": "Configure cmux via Ghostty config files. Font, theme, colors, split pane styling, scrollback, and app settings for automation mode.", - "intro": "cmux reads configuration from Ghostty config files, giving you familiar options if you're coming from Ghostty.", + "metaDescription": "Configure cmux via Ghostty config files and ~/.config/cmux/settings.json. Fonts, themes, app-owned shortcuts, automation mode, and browser behavior.", + "intro": "cmux reads terminal configuration from Ghostty config files. App-owned shortcuts can also be managed from a cmux settings file.", "configLocations": "Config file locations", "configLocationsDesc": "cmux looks for configuration in these locations (in order):", "createConfig": "Create the config file if it doesn't exist:", @@ -344,6 +344,10 @@ "browserLinkDesc": "In Settings > Browser, cmux exposes two host lists with different purposes:", "browserHostsEmbed": "Hosts to Open in Embedded Browser: applies to links clicked from terminal output. Hosts in this list open in cmux; other hosts open in your default browser. Supports one host or wildcard per line (for example: example.com, *.internal.example).", "browserHostsHttp": "HTTP Hosts Allowed in Embedded Browser: applies only to HTTP (non-HTTPS) URLs. Hosts in this list can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.", + "settingsFile": "settings.json", + "settingsFileDesc": "cmux also reads a user settings file for cmux-owned shortcuts. Today the file supports the shortcuts section.", + "settingsFilePrecedence": "Shortcuts defined in settings.json override the in-app Settings value. Managed shortcuts stay visible in Settings but become read-only there.", + "settingsFileChords": "Use a string for a single shortcut or a two-item array for a chord. Example: [\"ctrl+b\", \"c\"] means press Ctrl+B, then C.", "exampleConfig": "Example config" }, "customCommands": { @@ -412,14 +416,16 @@ }, "keyboardShortcuts": { "title": "Keyboard Shortcuts", - "description": "All keyboard shortcuts available in cmux, grouped by category.", + "description": "Default cmux keyboard shortcuts. Every cmux-owned shortcut can be changed in Settings or ~/.config/cmux/settings.json, including two-step chords.", "metaTitle": "Keyboard Shortcuts", - "metaDescription": "All cmux keyboard shortcuts for workspaces, surfaces, split panes, browser, notifications, find, and window management on macOS.", + "metaDescription": "Default cmux keyboard shortcuts for workspaces, surfaces, split panes, browser, notifications, find, and app controls on macOS. All cmux-owned shortcuts are configurable in Settings or settings.json.", "searchPlaceholder": "Search shortcuts...", "searchLabel": "Search keyboard shortcuts", "noResults": "No shortcuts found", "noResultsHint": "Try a different search term", "cat": { + "app": "App", + "appBlurb": "Application, window, and top-level command shortcuts.", "workspaces": "Workspaces", "workspacesBlurb": "Workspaces live in the sidebar. Each workspace has its own set of panes and surfaces.", "surfaces": "Surfaces", diff --git a/web/messages/ja.json b/web/messages/ja.json index 8542d1b0b465..7ff18565294f 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -271,8 +271,8 @@ }, "configuration": { "title": "設定", - "metaDescription": "Ghostty設定ファイルによるcmuxの設定。フォント、テーマ、カラー、分割ペインのスタイル、スクロールバック、自動化モードのアプリ設定。", - "intro": "cmuxはGhosttyの設定ファイルから設定を読み込むため、Ghosttyから移行する場合は馴染みのあるオプションを使えます。", + "metaDescription": "Ghostty設定ファイルと ~/.config/cmux/settings.json によるcmuxの設定。フォント、テーマ、cmux管理ショートカット、自動化モード、ブラウザ動作。", + "intro": "cmuxはターミナル設定をGhosttyの設定ファイルから読み込みます。cmux管理のショートカットは専用の settings.json でも管理できます。", "configLocations": "設定ファイルの場所", "configLocationsDesc": "cmuxは以下の場所から設定を検索します(順番に):", "createConfig": "設定ファイルが存在しない場合は作成してください:", @@ -299,6 +299,10 @@ "browserLinkDesc": "設定 > ブラウザで、cmuxは異なる目的の2つのホストリストを提供します:", "browserHostsEmbed": "組み込みブラウザで開くホスト:ターミナル出力からクリックしたリンクに適用されます。このリストのホストはcmuxで開き、それ以外はデフォルトブラウザで開きます。1行に1つのホストまたはワイルドカードを指定できます(例:example.com、*.internal.example)。", "browserHostsHttp": "組み込みブラウザで許可するHTTPホスト:HTTP(非HTTPS)URLのみに適用されます。このリストのホストは警告プロンプトなしでcmuxで開けます。デフォルトはlocalhost、127.0.0.1、::1、0.0.0.0、*.localtest.meです。", + "settingsFile": "settings.json", + "settingsFileDesc": "cmuxはcmux管理ショートカット用のユーザー設定ファイルも読み込みます。現在このファイルで対応しているのは shortcuts セクションです。", + "settingsFilePrecedence": "settings.json で定義したショートカットはアプリ内設定より優先されます。管理対象のショートカットは設定画面に表示されたままですが、そこでの編集は無効になります。", + "settingsFileChords": "単発ショートカットは文字列、コードは2要素の配列で指定します。例: [\"ctrl+b\", \"c\"] は Ctrl+B の後に C を押します。", "exampleConfig": "設定例", "metaTitle": "設定" }, @@ -368,13 +372,15 @@ }, "keyboardShortcuts": { "title": "キーボードショートカット", - "description": "cmuxで使用可能なすべてのキーボードショートカット(カテゴリ別)。", - "metaDescription": "cmuxのmacOS向けキーボードショートカット一覧。ワークスペース、サーフェス、分割ペイン、ブラウザ、通知、検索、ウィンドウ管理。", + "description": "cmuxのデフォルトショートカット一覧です。cmux管理のショートカットはすべて設定画面または ~/.config/cmux/settings.json で変更でき、2段階のコードにも対応しています。", + "metaDescription": "cmuxのmacOS向けデフォルトキーボードショートカット一覧。ワークスペース、サーフェス、分割ペイン、ブラウザ、通知、検索、アプリ操作を収録し、すべて設定画面または settings.json で変更できます。", "searchPlaceholder": "ショートカットを検索...", "searchLabel": "キーボードショートカットを検索", "noResults": "ショートカットが見つかりません", "noResultsHint": "別の検索ワードをお試しください", "cat": { + "app": "アプリ", + "appBlurb": "アプリ全体、ウインドウ、上位コマンドのショートカットです。", "workspaces": "ワークスペース", "workspacesBlurb": "ワークスペースはサイドバーに表示されます。各ワークスペースには独自のペインとサーフェスがあります。", "surfaces": "サーフェス", From 7813d1ccaead92f1a3f8dafd1a850728193bdebc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 2 Apr 2026 04:33:05 -0700 Subject: [PATCH 04/89] Add regression test for chord reset on deactivate --- .../AppDelegateShortcutRoutingTests.swift | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 2caf72689666..98b4435417a1 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -265,6 +265,68 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(manager.tabs.count, initialCount + 1, "settings.json chord should dispatch the configured shortcut") } + func testConfiguredChordPrefixIsClearedWhenAppResignsActive() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and manager") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "n" + ) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + appDelegate.applicationWillResignActive(Notification(name: NSApplication.willResignActiveNotification)) + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount, "Chord suffix should not fire after the app resigns active") + } + func testChordedShortcutMismatchDoesNotConsumeSecondKey() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") From 9f6a61c4937200759e0a5ede12150517db53300d Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 2 Apr 2026 04:33:08 -0700 Subject: [PATCH 05/89] Fix shortcut chord cleanup edge cases --- Sources/AppDelegate.swift | 2 ++ Sources/KeyboardShortcutSettings.swift | 15 ++++++++++++- .../KeyboardShortcutSettingsFileStore.swift | 4 ++-- cmuxTests/WorkspaceUnitTests.swift | 22 +++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5031dba93c5f..0c98cbd4b4e2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2902,6 +2902,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillResignActive(_ notification: Notification) { guard !isTerminatingApp else { return } + pendingConfiguredShortcutChordStart = nil + activeConfiguredShortcutChordPrefixForCurrentEvent = nil _ = saveSessionSnapshot(includeScrollback: false) } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 158813c4c01d..ffe24f909d18 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -801,7 +801,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable { } } -private class ShortcutRecorderNSButton: NSButton { +final class ShortcutRecorderNSButton: NSButton { var shortcut: StoredShortcut = KeyboardShortcutSettings.showNotificationsDefault var displayString: (StoredShortcut) -> String = { $0.displayString } var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 } @@ -847,6 +847,7 @@ private class ShortcutRecorderNSButton: NSButton { let storedShortcut = StoredShortcut(first: pendingChordStart) guard let transformedShortcut = transformRecordedShortcut(storedShortcut) else { NSSound.beep() + stopRecording() return } shortcut = transformedShortcut @@ -928,6 +929,18 @@ private class ShortcutRecorderNSButton: NSButton { stopRecording() } +#if DEBUG + var debugIsRecording: Bool { + isRecording + } + + func debugSetPendingChordStart(_ stroke: ShortcutStroke?) { + isRecording = true + pendingChordStart = stroke + updateTitle() + } +#endif + deinit { stopRecording() } diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index d757a2df689b..edd1a6db813c 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -381,8 +381,8 @@ private final class ShortcutSettingsFileWatcher { Darwin.close(fd) } - source.resume() self.source = source + source.resume() } private func startDirectoryWatcher() { @@ -415,7 +415,7 @@ private final class ShortcutSettingsFileWatcher { Darwin.close(fd) } - source.resume() self.source = source + source.resume() } } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 8b1fa6bd7ee6..a4fb96c5ca0c 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -273,6 +273,28 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTAssertTrue(ShortcutStroke.isEscapeCancelEvent(event)) XCTAssertNil(ShortcutStroke.from(event: event, requireModifier: false)) } + + func testShortcutRecorderStopsRecordingWhenFirstStrokeConfirmationIsRejected() { +#if DEBUG + let button = ShortcutRecorderNSButton(frame: .zero) + button.transformRecordedShortcut = { _ in nil } + button.debugSetPendingChordStart( + ShortcutStroke( + key: "x", + command: true, + shift: false, + option: false, + control: false + ) + ) + + button.performClick(nil) + + XCTAssertFalse(button.debugIsRecording) +#else + XCTFail("Shortcut recorder debug hooks are only available in DEBUG") +#endif + } } final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { From b7bbc5077556b5eb111e461ad95b6a42f6db9186 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 2 Apr 2026 04:49:18 -0700 Subject: [PATCH 06/89] Add regression tests for chord prefix edge cases --- .../AppDelegateShortcutRoutingTests.swift | 133 ++++++++++++++++++ cmuxTests/WorkspaceUnitTests.swift | 24 ++++ 2 files changed, 157 insertions(+) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 98b4435417a1..1e2729a424d1 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -327,6 +327,139 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(manager.tabs.count, initialCount, "Chord suffix should not fire after the app resigns active") } + func testConfiguredChordPrefixBeatsConflictingSingleStrokeShortcut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { + SettingsWindowController.shared.close() + closeWindow(withId: windowId) + } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId) else { + XCTFail("Expected test window and manager") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialCount = manager.tabs.count + let shortcut = StoredShortcut( + key: ",", + command: true, + shift: false, + option: false, + control: false, + chordKey: "n" + ) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: ",", + modifiers: [.command], + keyCode: 43, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+, prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialCount + 1, "Chord prefix should arm instead of firing Settings") + } + + func testConfiguredChordDoesNotCrossWindowBoundary() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstWindow = window(withId: firstWindowId), + let secondWindow = window(withId: secondWindowId), + let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId) else { + XCTFail("Expected both test windows and managers") + return + } + + firstWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialFirstCount = firstManager.tabs.count + let initialSecondCount = secondManager.tabs.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "n" + ) + + withTemporaryShortcut(action: .newTab, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: firstWindow.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let actionEvent = makeKeyDownEvent( + key: "n", + modifiers: [], + keyCode: 45, + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct N action event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: actionEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(firstManager.tabs.count, initialFirstCount, "Prefix window should not change without a matching suffix") + XCTAssertEqual(secondManager.tabs.count, initialSecondCount, "Chord suffix in another window must not trigger the action") + } + func testChordedShortcutMismatchDoesNotConsumeSecondKey() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index a4fb96c5ca0c..eff924d54063 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -274,6 +274,30 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTAssertNil(ShortcutStroke.from(event: event, requireModifier: false)) } + func testEscapeCancelDetectionAllowsModifiedEscapeGeneratingShortcut() { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 33 + ) else { + XCTFail("Failed to construct modified escape-generating event") + return + } + + XCTAssertFalse(ShortcutStroke.isEscapeCancelEvent(event)) + XCTAssertEqual( + ShortcutStroke.from(event: event, requireModifier: false), + ShortcutStroke(key: "[", command: true, shift: false, option: false, control: false) + ) + } + func testShortcutRecorderStopsRecordingWhenFirstStrokeConfirmationIsRejected() { #if DEBUG let button = ShortcutRecorderNSButton(frame: .zero) From cb315ba7164ad89b6ba3c668f6c087d7ce568a91 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 2 Apr 2026 04:49:22 -0700 Subject: [PATCH 07/89] Fix configurable chord prefix routing --- Resources/Localizable.xcstrings | 34 ++++++++ Sources/AppDelegate.swift | 107 +++++++++++++++---------- Sources/KeyboardShortcutSettings.swift | 15 +++- 3 files changed, 111 insertions(+), 45 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d57abbfa5fd6..bb215c5b4afc 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -67117,6 +67117,23 @@ } } }, + "shortcut.key.tab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tab" + } + } + } + }, "shortcut.pressShortcut.prompt": { "extractionState": "manual", "localizations": { @@ -67236,6 +67253,23 @@ } } }, + "shortcut.recorder.pendingChord": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ …" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ …" + } + } + } + }, "shortcut.previousSurface.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0c98cbd4b4e2..466ab931a441 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2193,7 +2193,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var shortcutDefaultsObserver: NSObjectProtocol? private var menuBarVisibilityObserver: NSObjectProtocol? private var splitButtonTooltipRefreshScheduled = false - private var pendingConfiguredShortcutChordStart: ShortcutStroke? + private struct PendingConfiguredShortcutChord { + let firstStroke: ShortcutStroke + let windowNumber: Int? + } + private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? @@ -2902,7 +2906,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillResignActive(_ notification: Notification) { guard !isTerminatingApp else { return } - pendingConfiguredShortcutChordStart = nil + pendingConfiguredShortcutChord = nil activeConfiguredShortcutChordPrefixForCurrentEvent = nil _ = saveSessionSnapshot(includeScrollback: false) } @@ -9410,8 +9414,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let isControlOnly = hasControl && !hasCommand && !hasOption let controlDChar = chars == "d" || event.characters == "\u{04}" let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) - activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChordStart - pendingConfiguredShortcutChordStart = nil + let configuredShortcutEventWindowNumber = configuredShortcutChordWindowNumber(for: event) + if let pendingConfiguredShortcutChord, + pendingConfiguredShortcutChord.windowNumber == configuredShortcutEventWindowNumber { + activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChord.firstStroke + } else { + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } + pendingConfiguredShortcutChord = nil defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } #if DEBUG if isControlD { @@ -9635,18 +9645,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Scope the omnibar check to the shortcut's routed window context so a // focused omnibar in another window does not suppress Cmd+P here. let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil - if matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") - return true - } - - if !hasFocusedAddressBarInShortcutContext, - matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") - return true - } if shouldConsumeShortcutWhileCommandPaletteVisible( isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, @@ -9657,26 +9655,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if matchConfiguredShortcut(event: event, action: .quit) { - return handleQuitShortcutWarning() - } - if matchConfiguredShortcut(event: event, action: .openSettings) { - openPreferencesWindow(debugSource: "shortcut.openSettings") - return true - } - if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { - GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") - return true - } - - if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { - guard let targetWindow = mainWindowForShortcutEvent(event) else { - return false - } - targetWindow.toggleFullScreen(nil) - return true - } - // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese // input), don't intercept non-Cmd key events — let them flow through to the // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still @@ -9757,6 +9735,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event) { + return true + } + + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true + } + + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true + } + + if matchConfiguredShortcut(event: event, action: .quit) { + return handleQuitShortcutWarning() + } + if matchConfiguredShortcut(event: event, action: .openSettings) { + openPreferencesWindow(debugSource: "shortcut.openSettings") + return true + } + if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") + return true + } + + if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { + guard let targetWindow = mainWindowForShortcutEvent(event) else { + return false + } + targetWindow.toggleFullScreen(nil) + return true + } + // Primary UI shortcuts if matchConfiguredShortcut(event: event, action: .toggleSidebar) { _ = toggleSidebarInActiveMainWindow() @@ -10244,10 +10260,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - if armConfiguredShortcutChordIfNeeded(event: event) { - return true - } - return false } @@ -11012,12 +11024,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { + if let window = mainWindowForShortcutEvent(event) { + return window.windowNumber + } + if let window = event.window { + return window.windowNumber + } + return event.windowNumber > 0 ? event.windowNumber : nil + } + private func armConfiguredShortcutChordIfNeeded(event: NSEvent) -> Bool { for action in KeyboardShortcutSettings.Action.allCases { let shortcut = KeyboardShortcutSettings.shortcut(for: action) guard shortcut.hasChord else { continue } if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { - pendingConfiguredShortcutChordStart = shortcut.firstStroke + pendingConfiguredShortcutChord = PendingConfiguredShortcutChord( + firstStroke: shortcut.firstStroke, + windowNumber: configuredShortcutChordWindowNumber(for: event) + ) return true } } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index ffe24f909d18..5d9240166af9 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -424,7 +424,7 @@ struct ShortcutStroke: Equatable { var keyDisplayString: String { switch key { case "\t": - return "TAB" + return String(localized: "shortcut.key.tab", defaultValue: "Tab") case "\r": return "↩" default: @@ -510,10 +510,16 @@ struct ShortcutStroke: Equatable { } let escapeScalar = UnicodeScalar(0x1B)! - if event.characters?.unicodeScalars.contains(escapeScalar) == true { + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.capsLock, .function, .numericPad]) + let shouldTreatEscapeCharacterAsCancel = normalizedFlags.isEmpty || event.keyCode == 36 || event.keyCode == 76 + + if shouldTreatEscapeCharacterAsCancel, + event.characters?.unicodeScalars.contains(escapeScalar) == true { return true } - if event.charactersIgnoringModifiers?.unicodeScalars.contains(escapeScalar) == true { + if shouldTreatEscapeCharacterAsCancel, + event.charactersIgnoringModifiers?.unicodeScalars.contains(escapeScalar) == true { return true } return false @@ -832,7 +838,8 @@ final class ShortcutRecorderNSButton: NSButton { func updateTitle() { if isRecording { if let pendingChordStart { - title = "\(pendingChordStart.displayString) …" + let format = String(localized: "shortcut.recorder.pendingChord", defaultValue: "%@ …") + title = String.localizedStringWithFormat(format, pendingChordStart.displayString) } else { title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…") } From 218a42101c3dfa19e5b82806a1a069e1041c4d63 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Thu, 2 Apr 2026 15:55:44 -0700 Subject: [PATCH 08/89] Add CLI reload-config command --- CLI/cmux.swift | 40 ++++++++++++++++++++++++ Sources/GhosttyTerminalView.swift | 14 +++++++-- Sources/TerminalController.swift | 2 +- cmuxTests/WorkspaceUnitTests.swift | 49 ++++++++++++++++++++++++++++-- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 53c857c4ed40..36f4d83b8837 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1863,6 +1863,12 @@ struct CMUXCLI { case "refresh-surfaces": let response = try sendV1Command("refresh_surfaces", client: client) print(response) + case "reload-config": + let response = try sendV1Command( + try reloadConfigSocketCommand(commandArgs: commandArgs), + client: client + ) + print(response) case "surface-health": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) @@ -2760,6 +2766,23 @@ struct CMUXCLI { return response } + private func reloadConfigSocketCommand(commandArgs: [String]) throws -> String { + guard commandArgs.count <= 1 else { + throw CLIError(message: "reload-config accepts at most one argument: full or soft") + } + guard let mode = commandArgs.first else { + return "reload_config" + } + switch mode.lowercased() { + case "full": + return "reload_config full" + case "soft": + return "reload_config soft" + default: + throw CLIError(message: "reload-config only supports the optional arguments 'full' or 'soft'") + } + } + private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: @@ -6606,6 +6629,22 @@ struct CMUXCLI { Refresh surface snapshots for the focused workspace. """ + case "reload-config": + return """ + Usage: cmux reload-config [full|soft] + + Run the same configuration reload as the Reload Configuration shortcut. + This reloads Ghostty config, re-reads ~/.config/cmux/settings.json, and refreshes terminals. + + Arguments: + full Re-read Ghostty config files and cmux settings.json + soft Reuse the current Ghostty config object for a lighter reload + + Example: + cmux reload-config + cmux reload-config full + cmux reload-config soft + """ case "surface-health": return """ Usage: cmux surface-health [--workspace ] @@ -13307,6 +13346,7 @@ struct CMUXCLI { rename-tab [--workspace ] [--tab ] [--surface ] drag-surface-to-split --surface <id|ref> <left|right|up|down> refresh-surfaces + reload-config [full|soft] surface-health [--workspace <id|ref>] trigger-flash [--workspace <id|ref>] [--surface <id|ref>] list-panels [--workspace <id|ref>] diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 38ff05b64f83..59a9b945535b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2016,7 +2016,14 @@ class GhosttyApp { } } - func reloadConfiguration(soft: Bool = false, source: String = "unspecified") { + func reloadConfiguration( + soft: Bool = false, + source: String = "unspecified", + reloadSettingsFromFile: Bool = true + ) { + if reloadSettingsFromFile { + KeyboardShortcutSettings.settingsFileStore.reload() + } guard let app else { logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app") return @@ -2087,7 +2094,10 @@ class GhosttyApp { } guard shouldReload else { return } lastAppearanceColorScheme = currentColorScheme - reloadConfiguration(source: "appearanceSync:\(source)") + reloadConfiguration( + source: "appearanceSync:\(source)", + reloadSettingsFromFile: false + ) } func openConfigurationInTextEdit() { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index e8051e9b60d7..b91e6b333715 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11077,7 +11077,7 @@ class TerminalController { focus_pane <pane-id|index> - Focus a pane focus_surface_by_panel <panel_id> - Focus surface by panel ID close_surface [id|idx] - Close surface (collapse split) - reload_config [soft] - Reload Ghostty config and refresh terminals + reload_config [soft] - Reload Ghostty config, cmux settings, and refresh terminals refresh_surfaces - Force refresh all terminals surface_health [workspace] - Check view health of all surfaces diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index eff924d54063..f72d43b3cd83 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -449,6 +449,53 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertNotNil(KeyboardShortcutSettings.settingsFileManagedSubtitle(for: .newTab)) } + @MainActor + func testReloadConfigurationReloadsShortcutSettingsFile() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "newTab": "cmd+n" + } + } + """, + to: settingsFileURL + ) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + KeyboardShortcutSettings.shortcut(for: .newTab), + StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) + ) + + try writeSettingsFile( + """ + { + "shortcuts": { + "newTab": ["ctrl+b", "c"] + } + } + """, + to: settingsFileURL + ) + + GhosttyApp.shared.reloadConfiguration(source: "test.reload_config") + + XCTAssertEqual( + KeyboardShortcutSettings.shortcut(for: .newTab), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + ) + } + private func makeTemporaryDirectory() throws -> URL { let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -476,8 +523,6 @@ final class WorkspaceShortcutMapperTests: XCTestCase { XCTAssertNil(WorkspaceShortcutMapper.digitForWorkspace(at: 8, workspaceCount: 12)) } } - - final class WorkspacePlacementSettingsTests: XCTestCase { func testCurrentPlacementDefaultsToAfterCurrentWhenUnset() { let suiteName = "WorkspacePlacementSettingsTests.Default.\(UUID().uuidString)" From 292d3e5a60043951197f7e45494fb815a81a0b15 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:34:51 -0700 Subject: [PATCH 09/89] Simplify reload-config CLI command --- CLI/cmux.swift | 35 ++++++----------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 36f4d83b8837..d034ffc4a9fd 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1864,10 +1864,10 @@ struct CMUXCLI { let response = try sendV1Command("refresh_surfaces", client: client) print(response) case "reload-config": - let response = try sendV1Command( - try reloadConfigSocketCommand(commandArgs: commandArgs), - client: client - ) + if let unexpected = commandArgs.first { + throw CLIError(message: "reload-config does not accept arguments. Unexpected argument '\(unexpected)'") + } + let response = try sendV1Command("reload_config", client: client) print(response) case "surface-health": @@ -2766,23 +2766,6 @@ struct CMUXCLI { return response } - private func reloadConfigSocketCommand(commandArgs: [String]) throws -> String { - guard commandArgs.count <= 1 else { - throw CLIError(message: "reload-config accepts at most one argument: full or soft") - } - guard let mode = commandArgs.first else { - return "reload_config" - } - switch mode.lowercased() { - case "full": - return "reload_config full" - case "soft": - return "reload_config soft" - default: - throw CLIError(message: "reload-config only supports the optional arguments 'full' or 'soft'") - } - } - private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: @@ -6631,19 +6614,13 @@ struct CMUXCLI { """ case "reload-config": return """ - Usage: cmux reload-config [full|soft] + Usage: cmux reload-config Run the same configuration reload as the Reload Configuration shortcut. This reloads Ghostty config, re-reads ~/.config/cmux/settings.json, and refreshes terminals. - Arguments: - full Re-read Ghostty config files and cmux settings.json - soft Reuse the current Ghostty config object for a lighter reload - Example: cmux reload-config - cmux reload-config full - cmux reload-config soft """ case "surface-health": return """ @@ -13346,7 +13323,7 @@ struct CMUXCLI { rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title> drag-surface-to-split --surface <id|ref> <left|right|up|down> refresh-surfaces - reload-config [full|soft] + reload-config surface-health [--workspace <id|ref>] trigger-flash [--workspace <id|ref>] [--surface <id|ref>] list-panels [--workspace <id|ref>] From 8ee08325318d7f2493773a70235c52f4e758a7e8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:33:26 -0700 Subject: [PATCH 10/89] Add regression tests for shortcut reload edges --- .../AppDelegateShortcutRoutingTests.swift | 136 ++++++++++++++++++ cmuxTests/WorkspaceUnitTests.swift | 79 ++++++++++ 2 files changed, 215 insertions(+) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 1e2729a424d1..4c4cfb4217d1 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -391,6 +391,70 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(manager.tabs.count, initialCount + 1, "Chord prefix should arm instead of firing Settings") } + func testConfiguredChordPrefixBlocksUnrelatedSingleStrokeShortcutOnSecondKey() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window and workspace") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialWorkspaceCount = manager.tabs.count + let initialPanelCount = workspace.panels.count + let shortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "d" + ) + + withTemporaryShortcut(action: .splitRight, shortcut: shortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let conflictingSingleStrokeEvent = makeKeyDownEvent( + key: "n", + modifiers: [.command], + keyCode: 45, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+N event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: conflictingSingleStrokeEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(manager.tabs.count, initialWorkspaceCount, "Pending chord should block unrelated single-stroke actions") + XCTAssertEqual(workspace.panels.count, initialPanelCount, "Mismatched second key should not split the workspace") + } + func testConfiguredChordDoesNotCrossWindowBoundary() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -460,6 +524,78 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(secondManager.tabs.count, initialSecondCount, "Chord suffix in another window must not trigger the action") } + func testShortcutChangeClearsPendingConfiguredChord() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window and workspace") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let initialPanelCount = workspace.panels.count + let chordShortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "d" + ) + + withTemporaryShortcut(action: .splitRight, shortcut: chordShortcut) { + guard let prefixEvent = makeKeyDownEvent( + key: "b", + modifiers: [.control], + keyCode: 11, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Ctrl+B prefix event") + return + } + + guard let suffixEvent = makeKeyDownEvent( + key: "d", + modifiers: [], + keyCode: 2, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct D suffix event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") + return +#endif + + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "d", command: true, shift: false, option: false, control: false), + for: .splitRight + ) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + +#if DEBUG + XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: suffixEvent)) +#endif + } + + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual(workspace.panels.count, initialPanelCount, "Changing shortcuts should discard any pending chord prefix") + } + func testChordedShortcutMismatchDoesNotConsumeSecondKey() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index f72d43b3cd83..a9d53d4607e4 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -375,6 +375,38 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertEqual(store.activeSourcePath, settingsFileURL.path) } + func testSettingsFileStoreRejectsModifierFreeFirstStroke() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "toggleSidebar": "b", + "newTab": ["b", "c"], + "splitRight": ["ctrl+b", "d"] + } + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertNil(store.override(for: .toggleSidebar)) + XCTAssertNil(store.override(for: .newTab)) + XCTAssertEqual( + store.override(for: .splitRight), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "d") + ) + } + func testSettingsFileStoreUsesFallbackOnlyWhenPrimaryIsMissing() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } @@ -496,6 +528,53 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { ) } + func testManagedShortcutWritesDoNotOverwritePersistedValue() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + let missingSettingsFileURL = directoryURL.appendingPathComponent("missing.json", isDirectory: false) + let persistedShortcut = StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) + let managedShortcut = StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + + KeyboardShortcutSettings.setShortcut(persistedShortcut, for: .newTab) + + try writeSettingsFile( + """ + { + "shortcuts": { + "newTab": ["ctrl+b", "c"] + } + } + """, + to: settingsFileURL + ) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .newTab), managedShortcut) + + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "t", command: true, shift: false, option: false, control: false), + for: .newTab + ) + + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .newTab), managedShortcut) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: missingSettingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertFalse(KeyboardShortcutSettings.isManagedBySettingsFile(.newTab)) + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .newTab), persistedShortcut) + } + private func makeTemporaryDirectory() throws -> URL { let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) From 64d7cbff19924109f368c1d40b68996b4caecc9b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:33:30 -0700 Subject: [PATCH 11/89] Fix shortcut reload and chord edge cases --- Sources/AppDelegate.swift | 33 ++++++++++++------- Sources/KeyboardShortcutSettings.swift | 2 ++ .../KeyboardShortcutSettingsFileStore.swift | 1 + Sources/TerminalController.swift | 16 +++------ 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 466ab931a441..7f2043b0ea10 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2906,8 +2906,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationWillResignActive(_ notification: Notification) { guard !isTerminatingApp else { return } - pendingConfiguredShortcutChord = nil - activeConfiguredShortcutChordPrefixForCurrentEvent = nil + clearConfiguredShortcutChordState() _ = saveSessionSnapshot(includeScrollback: false) } @@ -9184,10 +9183,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] _ in + self?.clearConfiguredShortcutChordState() self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() } } + private func clearConfiguredShortcutChordState() { + pendingConfiguredShortcutChord = nil + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } + /// Coalesce shortcut-default changes and refresh on the next runloop turn to /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { @@ -10975,9 +10980,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent, - let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix { + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return false + } return matchShortcutStroke(event: event, stroke: secondStroke) } guard !shortcut.hasChord else { return false } @@ -10989,9 +10996,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent action: KeyboardShortcutSettings.Action ) -> Int? { let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent, - let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix { + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return nil + } return numberedShortcutDigit(event: event, stroke: secondStroke) } guard !shortcut.hasChord else { return nil } @@ -11005,9 +11014,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent arrowKeyCode: UInt16 ) -> Bool { let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent, - let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix { + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return false + } return matchDirectionalShortcut( event: event, stroke: secondStroke, diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 5d9240166af9..3c00b61ec445 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -310,6 +310,8 @@ enum KeyboardShortcutSettings { } static func setShortcut(_ shortcut: StoredShortcut, for action: Action) { + guard !isManagedBySettingsFile(action) else { return } + let storedShortcut: StoredShortcut if let normalizedShortcut = action.normalizedRecordedShortcut(shortcut) { storedShortcut = normalizedShortcut diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index edd1a6db813c..06dddc352244 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -207,6 +207,7 @@ final class KeyboardShortcutSettingsFileStore { guard parsedStrokes.count == strokes.count, let firstStroke = parsedStrokes.first else { return nil } + guard !firstStroke.modifierFlags.isEmpty else { return nil } let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil return StoredShortcut(first: firstStroke, second: secondStroke) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index b91e6b333715..575efe6bd860 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -11077,7 +11077,7 @@ class TerminalController { focus_pane <pane-id|index> - Focus a pane focus_surface_by_panel <panel_id> - Focus surface by panel ID close_surface [id|idx] - Close surface (collapse split) - reload_config [soft] - Reload Ghostty config, cmux settings, and refresh terminals + reload_config - Reload Ghostty config, cmux settings, and refresh terminals refresh_surfaces - Force refresh all terminals surface_health [workspace] - Check view health of all surfaces @@ -15400,20 +15400,14 @@ class TerminalController { private func reloadConfig(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let soft: Bool - switch trimmed { - case "", "full": - soft = false - case "soft": - soft = true - default: - return "ERROR: Usage: reload_config [soft]" + guard trimmed.isEmpty else { + return "ERROR: Usage: reload_config" } v2MainSync { - GhosttyApp.shared.reloadConfiguration(soft: soft, source: "socket.reload_config") + GhosttyApp.shared.reloadConfiguration(source: "socket.reload_config") } - return soft ? "OK Reloaded config (soft)" : "OK Reloaded config" + return "OK Reloaded config" } private func refreshSurfaces() -> String { From 421cf1b69de709acf508f1226048ff54e0fd7396 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:43:51 -0700 Subject: [PATCH 12/89] Add managed settings.json defaults and schema docs --- Sources/CmuxDirectoryTrust.swift | 2 + .../KeyboardShortcutSettingsFileStore.swift | 1356 ++++++++++++++++- Sources/SocketControlSettings.swift | 3 + Sources/cmuxApp.swift | 1 + cmuxTests/WorkspaceUnitTests.swift | 300 ++++ web/app/[locale]/docs/configuration/page.tsx | 394 +++-- web/app/[locale]/keyboard-shortcuts.tsx | 145 +- web/data/cmux-settings.schema.json | 534 +++++++ web/data/cmux-shortcuts.ts | 135 ++ web/messages/en.json | 4 +- web/messages/ja.json | 4 +- 11 files changed, 2555 insertions(+), 323 deletions(-) create mode 100644 web/data/cmux-settings.schema.json create mode 100644 web/data/cmux-shortcuts.ts diff --git a/Sources/CmuxDirectoryTrust.swift b/Sources/CmuxDirectoryTrust.swift index 1c906fbada9d..4c0190fb5130 100644 --- a/Sources/CmuxDirectoryTrust.swift +++ b/Sources/CmuxDirectoryTrust.swift @@ -6,6 +6,7 @@ import Foundation /// Global config (~/.config/cmux/cmux.json) is always trusted. final class CmuxDirectoryTrust { static let shared = CmuxDirectoryTrust() + static let didChangeNotification = Notification.Name("cmux.directoryTrustDidChange") private let storePath: String private var trustedPaths: Set<String> @@ -108,5 +109,6 @@ final class CmuxDirectoryTrust { let sorted = trustedPaths.sorted() guard let data = try? JSONEncoder().encode(sorted) else { return } FileManager.default.createFile(atPath: storePath, contents: data) + NotificationCenter.default.post(name: Self.didChangeNotification, object: nil) } } diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 06dddc352244..7fbab4a38e8b 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -17,10 +17,16 @@ final class KeyboardShortcutSettingsObserver: ObservableObject { } } -final class KeyboardShortcutSettingsFileStore { - static let shared = KeyboardShortcutSettingsFileStore() +final class CmuxSettingsFileStore { + static let shared = CmuxSettingsFileStore() + + static let currentSchemaVersion = 1 + static let schemaURLString = "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json" private static let releaseBundleIdentifier = "com.cmuxterm.app" + private static let backupsDefaultsKey = "cmux.settingsFile.backups.v1" + fileprivate static let trustedDirectoriesBackupIdentifier = "customCommands.trustedDirectories" + fileprivate static let socketPasswordBackupIdentifier = "automation.socketPassword" static var defaultPrimaryPath: String { let home = FileManager.default.homeDirectoryForCurrentUser.path @@ -43,22 +49,34 @@ final class KeyboardShortcutSettingsFileStore { private let primaryPath: String private let fallbackPath: String? private let fileManager: FileManager + private let notificationCenter: NotificationCenter private let stateLock = NSLock() + private var primaryWatcher: ShortcutSettingsFileWatcher? private var fallbackWatcher: ShortcutSettingsFileWatcher? + private var defaultsCancellable: AnyCancellable? + private var trustObserver: NSObjectProtocol? + private var socketPasswordObserver: NSObjectProtocol? private var shortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + private var activeManagedUserDefaults: [String: ManagedSettingsValue] = [:] + private var activeManagedCustomSettings = ManagedCustomSettings() + private var isApplyingManagedSettings = false private(set) var activeSourcePath: String? init( - primaryPath: String = KeyboardShortcutSettingsFileStore.defaultPrimaryPath, - fallbackPath: String? = KeyboardShortcutSettingsFileStore.defaultFallbackPath, + primaryPath: String = CmuxSettingsFileStore.defaultPrimaryPath, + fallbackPath: String? = CmuxSettingsFileStore.defaultFallbackPath, fileManager: FileManager = .default, + notificationCenter: NotificationCenter = .default, startWatching: Bool = true ) { self.primaryPath = primaryPath self.fallbackPath = fallbackPath self.fileManager = fileManager + self.notificationCenter = notificationCenter + + bootstrapPrimaryTemplateIfNeeded() reload() guard startWatching else { return } @@ -74,19 +92,48 @@ final class KeyboardShortcutSettingsFileStore { } } } + + defaultsCancellable = notificationCenter.publisher(for: UserDefaults.didChangeNotification) + .sink { [weak self] _ in + self?.reapplyManagedSettingsIfNeeded() + } + trustObserver = notificationCenter.addObserver( + forName: CmuxDirectoryTrust.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.reapplyManagedSettingsIfNeeded() + } + socketPasswordObserver = notificationCenter.addObserver( + forName: SocketControlPasswordStore.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.reapplyManagedSettingsIfNeeded() + } } deinit { primaryWatcher?.stop() fallbackWatcher?.stop() + defaultsCancellable?.cancel() + if let trustObserver { + notificationCenter.removeObserver(trustObserver) + } + if let socketPasswordObserver { + notificationCenter.removeObserver(socketPasswordObserver) + } } func reload() { let previousShortcuts = synchronized { shortcutsByAction } let previousActiveSourcePath = synchronized { activeSourcePath } - let resolved = resolveShortcuts() + let resolved = resolveSettings() + applyManagedSettings(snapshot: resolved) synchronized { shortcutsByAction = resolved.shortcuts + activeManagedUserDefaults = resolved.managedUserDefaults + activeManagedCustomSettings = resolved.managedCustomSettings activeSourcePath = resolved.path } @@ -103,95 +150,563 @@ final class KeyboardShortcutSettingsFileStore { synchronized { shortcutsByAction[action] != nil } } + private func bootstrapPrimaryTemplateIfNeeded() { + guard !fileManager.fileExists(atPath: primaryPath) else { return } + if let fallbackPath, fileManager.fileExists(atPath: fallbackPath) { + return + } + + let fileURL = URL(fileURLWithPath: primaryPath) + let directoryURL = fileURL.deletingLastPathComponent() + + do { + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] + ) + let template = Self.defaultTemplate() + try template.write(to: fileURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + } catch { + NSLog("[CmuxSettingsFileStore] failed to bootstrap %@: %@", primaryPath, String(describing: error)) + } + } + + private func reapplyManagedSettingsIfNeeded() { + let snapshot: ResolvedSettingsSnapshot? = synchronized { + guard !isApplyingManagedSettings else { return nil } + if activeManagedUserDefaults.isEmpty && activeManagedCustomSettings.isEmpty { + return nil + } + return ResolvedSettingsSnapshot( + path: activeSourcePath, + shortcuts: shortcutsByAction, + managedUserDefaults: activeManagedUserDefaults, + managedCustomSettings: activeManagedCustomSettings + ) + } + guard let snapshot else { return } + applyManagedSettings(snapshot: snapshot, updateBackups: false) + } + private func synchronized<T>(_ body: () -> T) -> T { stateLock.lock() defer { stateLock.unlock() } return body() } - private func resolveShortcuts() -> (path: String?, shortcuts: [KeyboardShortcutSettings.Action: StoredShortcut]) { - switch loadShortcuts(at: primaryPath) { - case .parsed(let shortcuts): - return (primaryPath, shortcuts) + private func resolveSettings() -> ResolvedSettingsSnapshot { + switch loadSettings(at: primaryPath) { + case .parsed(let snapshot): + return snapshot case .invalid: - return (primaryPath, [:]) + return ResolvedSettingsSnapshot(path: primaryPath) case .missing: break } guard let fallbackPath else { - return (nil, [:]) + return ResolvedSettingsSnapshot(path: nil) } - switch loadShortcuts(at: fallbackPath) { - case .parsed(let shortcuts): - return (fallbackPath, shortcuts) + switch loadSettings(at: fallbackPath) { + case .parsed(let snapshot): + return snapshot case .invalid: - return (fallbackPath, [:]) + return ResolvedSettingsSnapshot(path: fallbackPath) case .missing: - return (nil, [:]) + return ResolvedSettingsSnapshot(path: nil) } } private enum LoadResult { case missing case invalid - case parsed([KeyboardShortcutSettings.Action: StoredShortcut]) + case parsed(ResolvedSettingsSnapshot) } - private func loadShortcuts(at path: String) -> LoadResult { + private func loadSettings(at path: String) -> LoadResult { guard fileManager.fileExists(atPath: path) else { return .missing } - guard let data = fileManager.contents(atPath: path), - !data.isEmpty else { + guard let data = fileManager.contents(atPath: path), !data.isEmpty else { return .invalid } do { - let file = try JSONDecoder().decode(KeyboardShortcutSettingsFile.self, from: data) - return .parsed(parseShortcutBindings(file.shortcuts ?? [:], sourcePath: path)) + let sanitized = try JSONCParser.preprocess(data: data) + let object = try JSONSerialization.jsonObject(with: sanitized, options: []) + guard let root = object as? [String: Any] else { + return .invalid + } + return .parsed(parseSettingsFile(root: root, sourcePath: path)) } catch { - NSLog("[KeyboardShortcutSettings] parse error at %@: %@", path, String(describing: error)) + NSLog("[CmuxSettingsFileStore] parse error at %@: %@", path, String(describing: error)) return .invalid } } - private func parseShortcutBindings( - _ bindings: [String: KeyboardShortcutBindingDefinition], - sourcePath: String - ) -> [KeyboardShortcutSettings.Action: StoredShortcut] { - var parsed: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + private func parseSettingsFile(root: [String: Any], sourcePath: String) -> ResolvedSettingsSnapshot { + let schemaVersion = jsonInt(root["schemaVersion"]) ?? 1 + if schemaVersion > Self.currentSchemaVersion { + NSLog( + "[CmuxSettingsFileStore] %@ uses future schemaVersion %d; parsing known fields only", + sourcePath, + schemaVersion + ) + } + + var snapshot = ResolvedSettingsSnapshot(path: sourcePath) + + if let appSection = root["app"] as? [String: Any] { + parseAppSection(appSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let notificationsSection = root["notifications"] as? [String: Any] { + parseNotificationsSection(notificationsSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let sidebarSection = root["sidebar"] as? [String: Any] { + parseSidebarSection(sidebarSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let workspaceColorsSection = root["workspaceColors"] as? [String: Any] { + parseWorkspaceColorsSection(workspaceColorsSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let sidebarAppearanceSection = root["sidebarAppearance"] as? [String: Any] { + parseSidebarAppearanceSection(sidebarAppearanceSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let automationSection = root["automation"] as? [String: Any] { + parseAutomationSection(automationSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let customCommandsSection = root["customCommands"] as? [String: Any] { + parseCustomCommandsSection(customCommandsSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let browserSection = root["browser"] as? [String: Any] { + parseBrowserSection(browserSection, sourcePath: sourcePath, snapshot: &snapshot) + } + if let shortcutsSection = root["shortcuts"] { + parseShortcutsSection(shortcutsSection, sourcePath: sourcePath, snapshot: &snapshot) + } + + return snapshot + } + + private func parseAppSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let raw = jsonString(section["language"]) { + guard let language = AppLanguage(rawValue: raw) else { + logInvalid("app.language", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[LanguageSettings.languageKey] = .string(language.rawValue) + } + if let raw = jsonString(section["appearance"]) { + let normalized = AppearanceSettings.mode(for: raw).rawValue + let accepted = Set(AppearanceMode.allCases.map(\.rawValue)) + guard accepted.contains(raw) else { + logInvalid("app.appearance", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[AppearanceSettings.appearanceModeKey] = .string(normalized) + } + if let raw = jsonString(section["appIcon"]) { + guard let mode = AppIconMode(rawValue: raw) else { + logInvalid("app.appIcon", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[AppIconSettings.modeKey] = .string(mode.rawValue) + } + if let raw = jsonString(section["newWorkspacePlacement"]) { + guard let placement = NewWorkspacePlacement(rawValue: raw) else { + logInvalid("app.newWorkspacePlacement", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[WorkspacePlacementSettings.placementKey] = .string(placement.rawValue) + } + if let value = jsonBool(section["minimalMode"]) { + let mode = value ? WorkspacePresentationModeSettings.Mode.minimal : .standard + snapshot.managedUserDefaults[WorkspacePresentationModeSettings.modeKey] = .string(mode.rawValue) + } + if let value = jsonBool(section["keepWorkspaceOpenWhenClosingLastSurface"]) { + snapshot.managedUserDefaults[LastSurfaceCloseShortcutSettings.key] = .bool(!value) + } + if let value = jsonBool(section["focusPaneOnFirstClick"]) { + snapshot.managedUserDefaults[PaneFirstClickFocusSettings.enabledKey] = .bool(value) + } + if let value = jsonString(section["preferredEditor"]) { + snapshot.managedUserDefaults[PreferredEditorSettings.key] = .string(value) + } + if let value = jsonBool(section["reorderOnNotification"]) { + snapshot.managedUserDefaults[WorkspaceAutoReorderSettings.key] = .bool(value) + } + if let value = jsonBool(section["sendAnonymousTelemetry"]) { + snapshot.managedUserDefaults[TelemetrySettings.sendAnonymousTelemetryKey] = .bool(value) + } + if let value = jsonBool(section["warnBeforeQuit"]) { + snapshot.managedUserDefaults[QuitWarningSettings.warnBeforeQuitKey] = .bool(value) + } + if let value = jsonBool(section["renameSelectsExistingName"]) { + snapshot.managedUserDefaults[CommandPaletteRenameSelectionSettings.selectAllOnFocusKey] = .bool(value) + } + if let value = jsonBool(section["commandPaletteSearchesAllSurfaces"]) { + snapshot.managedUserDefaults[CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey] = .bool(value) + } + } + + private func parseNotificationsSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let value = jsonBool(section["dockBadge"]) { + snapshot.managedUserDefaults[NotificationBadgeSettings.dockBadgeEnabledKey] = .bool(value) + } + if let value = jsonBool(section["showInMenuBar"]) { + snapshot.managedUserDefaults[MenuBarExtraSettings.showInMenuBarKey] = .bool(value) + } + if let value = jsonBool(section["unreadPaneRing"]) { + snapshot.managedUserDefaults[NotificationPaneRingSettings.enabledKey] = .bool(value) + } + if let value = jsonBool(section["paneFlash"]) { + snapshot.managedUserDefaults[NotificationPaneFlashSettings.enabledKey] = .bool(value) + } + if let raw = jsonString(section["sound"]) { + let allowed = Set(NotificationSoundSettings.systemSounds.map(\.value)) + guard allowed.contains(raw) else { + logInvalid("notifications.sound", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[NotificationSoundSettings.key] = .string(raw) + } + if let raw = jsonString(section["customSoundFilePath"]) { + snapshot.managedUserDefaults[NotificationSoundSettings.customFilePathKey] = .string(raw) + } + if let raw = jsonString(section["command"]) { + snapshot.managedUserDefaults[NotificationSoundSettings.customCommandKey] = .string(raw) + } + } + + private func parseSidebarSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let value = jsonBool(section["hideAllDetails"]) { + snapshot.managedUserDefaults[SidebarWorkspaceDetailSettings.hideAllDetailsKey] = .bool(value) + } + if let raw = jsonString(section["branchLayout"]) { + switch raw { + case "vertical": + snapshot.managedUserDefaults[SidebarBranchLayoutSettings.key] = .bool(true) + case "inline": + snapshot.managedUserDefaults[SidebarBranchLayoutSettings.key] = .bool(false) + default: + logInvalid("sidebar.branchLayout", sourcePath: sourcePath) + } + } + if let value = jsonBool(section["showNotificationMessage"]) { + snapshot.managedUserDefaults[SidebarWorkspaceDetailSettings.showNotificationMessageKey] = .bool(value) + } + if let value = jsonBool(section["showBranchDirectory"]) { + snapshot.managedUserDefaults["sidebarShowBranchDirectory"] = .bool(value) + } + if let value = jsonBool(section["showPullRequests"]) { + snapshot.managedUserDefaults["sidebarShowPullRequest"] = .bool(value) + } + if let value = jsonBool(section["openPullRequestLinksInCmuxBrowser"]) { + snapshot.managedUserDefaults[BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey] = .bool(value) + } + if let value = jsonBool(section["openPortLinksInCmuxBrowser"]) { + snapshot.managedUserDefaults[BrowserLinkOpenSettings.openSidebarPortLinksInCmuxBrowserKey] = .bool(value) + } + if let value = jsonBool(section["showSSH"]) { + snapshot.managedUserDefaults["sidebarShowSSH"] = .bool(value) + } + if let value = jsonBool(section["showPorts"]) { + snapshot.managedUserDefaults["sidebarShowPorts"] = .bool(value) + } + if let value = jsonBool(section["showLog"]) { + snapshot.managedUserDefaults["sidebarShowLog"] = .bool(value) + } + if let value = jsonBool(section["showProgress"]) { + snapshot.managedUserDefaults["sidebarShowProgress"] = .bool(value) + } + if let value = jsonBool(section["showCustomMetadata"]) { + snapshot.managedUserDefaults["sidebarShowStatusPills"] = .bool(value) + } + } + + private func parseWorkspaceColorsSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let raw = jsonString(section["indicatorStyle"]) { + let normalized = SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: raw).rawValue + let accepted = Set(SidebarActiveTabIndicatorStyle.allCases.map(\.rawValue)).union([ + "rail", "border", "wash", "lift", "typography", "washRail", "blueWashColorRail", + ]) + guard accepted.contains(raw) else { + logInvalid("workspaceColors.indicatorStyle", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[SidebarActiveTabIndicatorSettings.styleKey] = .string(normalized) + } + if section.keys.contains("selectionColor") { + guard let value = parseNullableHex( + section["selectionColor"], + path: "workspaceColors.selectionColor", + sourcePath: sourcePath + ) else { return } + snapshot.managedUserDefaults["sidebarSelectionColorHex"] = .nullableString(value) + } + if section.keys.contains("notificationBadgeColor") { + guard let value = parseNullableHex( + section["notificationBadgeColor"], + path: "workspaceColors.notificationBadgeColor", + sourcePath: sourcePath + ) else { return } + snapshot.managedUserDefaults["sidebarNotificationBadgeColorHex"] = .nullableString(value) + } + if let rawOverrides = section["paletteOverrides"] as? [String: Any] { + var normalized: [String: String] = [:] + let validNames = Set(WorkspaceTabColorSettings.defaultPalette.map(\.name)) + for (name, rawValue) in rawOverrides { + guard validNames.contains(name) else { + NSLog("[CmuxSettingsFileStore] ignoring unknown workspace color '%@' in %@", name, sourcePath) + continue + } + guard let hex = jsonString(rawValue), + let normalizedHex = WorkspaceTabColorSettings.normalizedHex(hex) else { + NSLog("[CmuxSettingsFileStore] ignoring invalid workspace color override '%@' in %@", name, sourcePath) + continue + } + normalized[name] = normalizedHex + } + snapshot.managedUserDefaults[WorkspaceTabColorSettings.defaultOverridesKey] = .stringDictionary(normalized) + } + if let rawColors = jsonStringArray(section["customColors"]) { + let normalized = rawColors.compactMap(WorkspaceTabColorSettings.normalizedHex(_:)) + snapshot.managedUserDefaults[WorkspaceTabColorSettings.customColorsKey] = .stringArray(normalized) + } + } + + private func parseSidebarAppearanceSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let value = jsonBool(section["matchTerminalBackground"]) { + snapshot.managedUserDefaults["sidebarMatchTerminalBackground"] = .bool(value) + } + if let raw = jsonString(section["tintColor"]) { + guard let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + logInvalid("sidebarAppearance.tintColor", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults["sidebarTintHex"] = .string(normalized) + } + if section.keys.contains("lightModeTintColor") { + guard let value = parseNullableHex( + section["lightModeTintColor"], + path: "sidebarAppearance.lightModeTintColor", + sourcePath: sourcePath + ) else { return } + snapshot.managedUserDefaults["sidebarTintHexLight"] = .nullableString(value) + } + if section.keys.contains("darkModeTintColor") { + guard let value = parseNullableHex( + section["darkModeTintColor"], + path: "sidebarAppearance.darkModeTintColor", + sourcePath: sourcePath + ) else { return } + snapshot.managedUserDefaults["sidebarTintHexDark"] = .nullableString(value) + } + if let value = jsonDouble(section["tintOpacity"]) { + let clamped = min(max(value, 0), 1) + snapshot.managedUserDefaults["sidebarTintOpacity"] = .double(clamped) + } + } + + private func parseAutomationSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let raw = jsonString(section["socketControlMode"]) { + let knownModes = Set([ + "off", "cmuxonly", "automation", "password", "allowall", "openaccess", "fullopenaccess", + "notifications", "full", + ]) + let normalizedRaw = raw.replacingOccurrences(of: "-", with: "").lowercased() + guard knownModes.contains(normalizedRaw) else { + logInvalid("automation.socketControlMode", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[SocketControlSettings.appStorageKey] = .string( + SocketControlSettings.migrateMode(raw).rawValue + ) + } + if section.keys.contains("socketPassword") { + if section["socketPassword"] is NSNull { + snapshot.managedCustomSettings.socketPassword = .clear + } else if let raw = jsonString(section["socketPassword"]) { + snapshot.managedCustomSettings.socketPassword = raw.isEmpty ? .clear : .set(raw) + } else { + logInvalid("automation.socketPassword", sourcePath: sourcePath) + return + } + } + if let value = jsonBool(section["claudeCodeIntegration"]) { + snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.hooksEnabledKey] = .bool(value) + } + if let raw = jsonString(section["claudeBinaryPath"]) { + snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.customClaudePathKey] = .string(raw) + } + if let value = jsonInt(section["portBase"]) { + guard value > 0 else { + logInvalid("automation.portBase", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults["cmuxPortBase"] = .int(value) + } + if let value = jsonInt(section["portRange"]) { + guard value > 0 else { + logInvalid("automation.portRange", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults["cmuxPortRange"] = .int(value) + } + } + + private func parseCustomCommandsSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let values = jsonStringArray(section["trustedDirectories"]) { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + snapshot.managedCustomSettings.trustedDirectories = normalized + } else if section.keys.contains("trustedDirectories") { + logInvalid("customCommands.trustedDirectories", sourcePath: sourcePath) + } + } + + private func parseBrowserSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + if let raw = jsonString(section["defaultSearchEngine"]) { + guard let engine = BrowserSearchEngine(rawValue: raw) else { + logInvalid("browser.defaultSearchEngine", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[BrowserSearchSettings.searchEngineKey] = .string(engine.rawValue) + } + if let value = jsonBool(section["showSearchSuggestions"]) { + snapshot.managedUserDefaults[BrowserSearchSettings.searchSuggestionsEnabledKey] = .bool(value) + } + if let raw = jsonString(section["theme"]) { + guard let mode = BrowserThemeMode(rawValue: raw) else { + logInvalid("browser.theme", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[BrowserThemeSettings.modeKey] = .string(mode.rawValue) + } + if let value = jsonBool(section["openTerminalLinksInCmuxBrowser"]) { + snapshot.managedUserDefaults[BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey] = .bool(value) + } + if let value = jsonBool(section["interceptTerminalOpenCommandInCmuxBrowser"]) { + snapshot.managedUserDefaults[BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey] = .bool(value) + } + if let values = jsonStringArray(section["hostsToOpenInEmbeddedBrowser"]) { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + snapshot.managedUserDefaults[BrowserLinkOpenSettings.browserHostWhitelistKey] = .string(normalized.joined(separator: "\n")) + } else if section.keys.contains("hostsToOpenInEmbeddedBrowser") { + logInvalid("browser.hostsToOpenInEmbeddedBrowser", sourcePath: sourcePath) + } + if let values = jsonStringArray(section["urlsToAlwaysOpenExternally"]) { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + snapshot.managedUserDefaults[BrowserLinkOpenSettings.browserExternalOpenPatternsKey] = .string( + normalized.joined(separator: "\n") + ) + } else if section.keys.contains("urlsToAlwaysOpenExternally") { + logInvalid("browser.urlsToAlwaysOpenExternally", sourcePath: sourcePath) + } + if let values = jsonStringArray(section["insecureHttpHostsAllowedInEmbeddedBrowser"]) { + let normalized = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + snapshot.managedUserDefaults[BrowserInsecureHTTPSettings.allowlistKey] = .string( + normalized.joined(separator: "\n") + ) + } else if section.keys.contains("insecureHttpHostsAllowedInEmbeddedBrowser") { + logInvalid("browser.insecureHttpHostsAllowedInEmbeddedBrowser", sourcePath: sourcePath) + } + if let value = jsonBool(section["showImportHintOnBlankTabs"]) { + snapshot.managedUserDefaults[BrowserImportHintSettings.showOnBlankTabsKey] = .bool(value) + } + if let raw = jsonString(section["reactGrabVersion"]) { + snapshot.managedUserDefaults[ReactGrabSettings.versionKey] = .string(raw) + } + } - for (rawAction, definition) in bindings { + private func parseShortcutsSection( + _ value: Any, + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + guard let section = value as? [String: Any] else { + logInvalid("shortcuts", sourcePath: sourcePath) + return + } + + if let value = jsonBool(section["showModifierHoldHints"]) { + snapshot.managedUserDefaults[ShortcutHintDebugSettings.showHintsOnCommandHoldKey] = .bool(value) + } + + var bindings = section["bindings"] as? [String: Any] ?? [:] + for (key, rawValue) in section where key != "bindings" && key != "showModifierHoldHints" { + bindings[key] = rawValue + } + + for (rawAction, rawBinding) in bindings { guard let action = KeyboardShortcutSettings.Action(rawValue: rawAction) else { - NSLog("[KeyboardShortcutSettings] ignoring unknown shortcut action '%@' in %@", rawAction, sourcePath) + NSLog("[CmuxSettingsFileStore] ignoring unknown shortcut action '%@' in %@", rawAction, sourcePath) continue } - guard let shortcut = parseShortcutBinding(definition, for: action) else { + guard let shortcut = parseShortcutBindingValue(rawBinding, action: action) else { NSLog( - "[KeyboardShortcutSettings] ignoring invalid shortcut binding for '%@' in %@", + "[CmuxSettingsFileStore] ignoring invalid shortcut binding for '%@' in %@", rawAction, sourcePath ) continue } - parsed[action] = shortcut + snapshot.shortcuts[action] = shortcut } - - return parsed } - private func parseShortcutBinding( - _ definition: KeyboardShortcutBindingDefinition, - for action: KeyboardShortcutSettings.Action + private func parseShortcutBindingValue( + _ rawValue: Any, + action: KeyboardShortcutSettings.Action ) -> StoredShortcut? { let shortcut: StoredShortcut? - switch definition { - case .single(let strokeText): - shortcut = parseStoredShortcut(strokes: [strokeText]) - case .chord(let strokeTexts): - shortcut = parseStoredShortcut(strokes: strokeTexts) + if let stroke = jsonString(rawValue) { + shortcut = parseStoredShortcut(strokes: [stroke]) + } else if let strokes = jsonStringArray(rawValue) { + shortcut = parseStoredShortcut(strokes: strokes) + } else { + shortcut = nil } guard let shortcut else { return nil } @@ -296,33 +811,728 @@ final class KeyboardShortcutSettingsFileStore { return lowered } } + + private func parseNullableHex( + _ rawValue: Any?, + path: String, + sourcePath: String + ) -> String?? { + if rawValue is NSNull { + return .some(nil) + } + guard let raw = jsonString(rawValue), + let normalized = WorkspaceTabColorSettings.normalizedHex(raw) else { + logInvalid(path, sourcePath: sourcePath) + return nil + } + return .some(normalized) + } + + private func applyManagedSettings( + snapshot: ResolvedSettingsSnapshot, + updateBackups: Bool = true + ) { + var backups = loadBackups() + let currentManagedIdentifiers = Set(backups.keys) + let nextManagedIdentifiers = Set(snapshot.managedUserDefaults.keys) + .union(snapshot.managedCustomSettings.managedIdentifiers) + + synchronized { + isApplyingManagedSettings = true + } + defer { + synchronized { + isApplyingManagedSettings = false + } + } + + if updateBackups { + for (defaultsKey, value) in snapshot.managedUserDefaults where backups[defaultsKey] == nil { + backups[defaultsKey] = backupValueForUserDefaultsKey(defaultsKey, managedValue: value) + } + if snapshot.managedCustomSettings.trustedDirectories != nil, + backups[Self.trustedDirectoriesBackupIdentifier] == nil { + backups[Self.trustedDirectoriesBackupIdentifier] = .stringArray(CmuxDirectoryTrust.shared.allTrustedPaths) + } + if snapshot.managedCustomSettings.socketPassword != nil, + backups[Self.socketPasswordBackupIdentifier] == nil { + backups[Self.socketPasswordBackupIdentifier] = currentSocketPasswordBackupValue() + } + } + + for identifier in currentManagedIdentifiers.subtracting(nextManagedIdentifiers) { + guard let backup = backups[identifier] else { continue } + restoreBackup(backup, for: identifier) + backups.removeValue(forKey: identifier) + } + + for (defaultsKey, value) in snapshot.managedUserDefaults { + applyManagedUserDefaultsValue(value, for: defaultsKey) + } + applyManagedCustomSettings(snapshot.managedCustomSettings) + + if updateBackups { + saveBackups(backups) + } + } + + private func applyManagedCustomSettings(_ settings: ManagedCustomSettings) { + if let trustedDirectories = settings.trustedDirectories, + CmuxDirectoryTrust.shared.allTrustedPaths != trustedDirectories { + CmuxDirectoryTrust.shared.replaceAll(with: trustedDirectories) + } + + if let socketPassword = settings.socketPassword { + switch socketPassword { + case .set(let value): + let current = (try? SocketControlPasswordStore.loadPassword()) ?? nil + if current != value { + try? SocketControlPasswordStore.savePassword(value) + } + case .clear: + let current = (try? SocketControlPasswordStore.loadPassword()) ?? nil + if current != nil { + try? SocketControlPasswordStore.clearPassword() + } + } + } + } + + private func restoreBackup(_ backup: BackupValue, for identifier: String) { + switch identifier { + case Self.trustedDirectoriesBackupIdentifier: + if case .stringArray(let values) = backup { + CmuxDirectoryTrust.shared.replaceAll(with: values) + } else { + CmuxDirectoryTrust.shared.replaceAll(with: []) + } + case Self.socketPasswordBackupIdentifier: + switch backup { + case .string(let value): + try? SocketControlPasswordStore.savePassword(value) + case .absent: + try? SocketControlPasswordStore.clearPassword() + default: + break + } + default: + restoreUserDefaultsBackup(backup, for: identifier) + } + } + + private func backupValueForUserDefaultsKey(_ defaultsKey: String, managedValue: ManagedSettingsValue) -> BackupValue { + let defaults = UserDefaults.standard + switch managedValue { + case .bool: + guard defaults.object(forKey: defaultsKey) != nil else { return .absent } + return .bool(defaults.bool(forKey: defaultsKey)) + case .int: + guard defaults.object(forKey: defaultsKey) != nil else { return .absent } + return .int(defaults.integer(forKey: defaultsKey)) + case .double: + guard defaults.object(forKey: defaultsKey) != nil else { return .absent } + return .double(defaults.double(forKey: defaultsKey)) + case .string, .nullableString: + guard let value = defaults.string(forKey: defaultsKey) else { return .absent } + return .string(value) + case .stringArray: + guard let value = defaults.array(forKey: defaultsKey) as? [String] else { return .absent } + return .stringArray(value) + case .stringDictionary: + guard let value = defaults.dictionary(forKey: defaultsKey) as? [String: String] else { + return .absent + } + return .stringDictionary(value) + } + } + + private func currentSocketPasswordBackupValue() -> BackupValue { + guard let current = try? SocketControlPasswordStore.loadPassword() else { + return .absent + } + return .string(current) + } + + private func applyManagedUserDefaultsValue(_ value: ManagedSettingsValue, for defaultsKey: String) { + let defaults = UserDefaults.standard + switch value { + case .bool(let next): + let current = defaults.object(forKey: defaultsKey) as? Bool + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .int(let next): + let current = defaults.object(forKey: defaultsKey) as? Int + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .double(let next): + let current = defaults.object(forKey: defaultsKey) as? Double + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .string(let next): + let current = defaults.string(forKey: defaultsKey) + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .nullableString(let next): + let current = defaults.string(forKey: defaultsKey) + if current != next { + if let next { + defaults.set(next, forKey: defaultsKey) + } else { + defaults.removeObject(forKey: defaultsKey) + } + } + case .stringArray(let next): + let current = defaults.array(forKey: defaultsKey) as? [String] + if current != next { + defaults.set(next, forKey: defaultsKey) + } + case .stringDictionary(let next): + let current = defaults.dictionary(forKey: defaultsKey) as? [String: String] + if current != next { + defaults.set(next, forKey: defaultsKey) + } + } + + switch defaultsKey { + case LanguageSettings.languageKey: + let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system + LanguageSettings.apply(language) + case AppIconSettings.modeKey: + AppIconSettings.applyIcon(AppIconSettings.resolvedMode()) + default: + break + } + } + + private func restoreUserDefaultsBackup(_ backup: BackupValue, for defaultsKey: String) { + let defaults = UserDefaults.standard + switch backup { + case .absent: + defaults.removeObject(forKey: defaultsKey) + case .bool(let value): + defaults.set(value, forKey: defaultsKey) + case .int(let value): + defaults.set(value, forKey: defaultsKey) + case .double(let value): + defaults.set(value, forKey: defaultsKey) + case .string(let value): + defaults.set(value, forKey: defaultsKey) + case .stringArray(let value): + defaults.set(value, forKey: defaultsKey) + case .stringDictionary(let value): + defaults.set(value, forKey: defaultsKey) + } + + switch defaultsKey { + case LanguageSettings.languageKey: + let language = AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system + LanguageSettings.apply(language) + case AppIconSettings.modeKey: + AppIconSettings.applyIcon(AppIconSettings.resolvedMode()) + default: + break + } + } + + private func loadBackups() -> [String: BackupValue] { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey: Self.backupsDefaultsKey), + let backups = try? JSONDecoder().decode([String: BackupValue].self, from: data) else { + return [:] + } + return backups + } + + private func saveBackups(_ backups: [String: BackupValue]) { + let defaults = UserDefaults.standard + if backups.isEmpty { + defaults.removeObject(forKey: Self.backupsDefaultsKey) + return + } + guard let data = try? JSONEncoder().encode(backups) else { return } + defaults.set(data, forKey: Self.backupsDefaultsKey) + } + + private func logInvalid(_ path: String, sourcePath: String) { + NSLog("[CmuxSettingsFileStore] ignoring invalid setting '%@' in %@", path, sourcePath) + } + + private func jsonString(_ rawValue: Any?) -> String? { + rawValue as? String + } + + private func jsonBool(_ rawValue: Any?) -> Bool? { + guard let number = rawValue as? NSNumber else { return nil } + guard CFGetTypeID(number) == CFBooleanGetTypeID() else { return nil } + return number.boolValue + } + + private func jsonInt(_ rawValue: Any?) -> Int? { + guard let number = rawValue as? NSNumber else { return nil } + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + let doubleValue = number.doubleValue + guard doubleValue.rounded() == doubleValue else { return nil } + return number.intValue + } + + private func jsonDouble(_ rawValue: Any?) -> Double? { + guard let number = rawValue as? NSNumber else { return nil } + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + return number.doubleValue + } + + private func jsonStringArray(_ rawValue: Any?) -> [String]? { + guard let values = rawValue as? [Any] else { return nil } + var strings: [String] = [] + strings.reserveCapacity(values.count) + for value in values { + guard let string = value as? String else { return nil } + strings.append(string) + } + return strings + } + + static func defaultTemplate() -> String { + var lines: [String] = [ + "{", + " \"$schema\": \"\(schemaURLString)\",", + " \"schemaVersion\": \(currentSchemaVersion),", + "", + " // This file uses JSON with comments (JSONC).", + " // Uncomment and edit any setting to make it file-managed.", + " // Remove a setting to fall back to the value saved in Settings.", + " // cmux creates this template on launch when both settings file locations are missing.", + " // ~/.config/cmux/settings.json takes precedence over the Application Support fallback.", + "", + ] + + let sections = defaultTemplateSections() + for (index, section) in sections.enumerated() { + lines.append(contentsOf: commentedTemplateLines(for: section)) + if index < sections.count - 1 { + lines.append("") + } + } + + lines.append("}") + return lines.joined(separator: "\n") + "\n" + } + + private static func commentedTemplateLines(for section: [String: Any]) -> [String] { + let json = prettyJSONString(section) + let sectionLines = json + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + guard sectionLines.count >= 2 else { return [] } + + return sectionLines + .dropFirst() + .dropLast() + .enumerated() + .map { index, line in + if index == sectionLines.count - 3 { + return " // \(line)," + } + return " // \(line)" + } + } + + private static func defaultTemplateSections() -> [[String: Any]] { + let shortcutsBindings = Dictionary( + uniqueKeysWithValues: KeyboardShortcutSettings.Action.allCases.map { action in + (action.rawValue, shortcutTemplateValue(action.defaultShortcut, usesNumberedDigits: action.usesNumberedDigitMatching)) + } + ) + + return [ + [ + "app": [ + "language": LanguageSettings.defaultLanguage.rawValue, + "appearance": AppearanceSettings.defaultMode.rawValue, + "appIcon": AppIconSettings.defaultMode.rawValue, + "newWorkspacePlacement": WorkspacePlacementSettings.defaultPlacement.rawValue, + "minimalMode": false, + "keepWorkspaceOpenWhenClosingLastSurface": !LastSurfaceCloseShortcutSettings.defaultValue, + "focusPaneOnFirstClick": PaneFirstClickFocusSettings.defaultEnabled, + "preferredEditor": "", + "reorderOnNotification": WorkspaceAutoReorderSettings.defaultValue, + "sendAnonymousTelemetry": TelemetrySettings.defaultSendAnonymousTelemetry, + "warnBeforeQuit": QuitWarningSettings.defaultWarnBeforeQuit, + "renameSelectsExistingName": CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus, + "commandPaletteSearchesAllSurfaces": CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces, + ], + ], + [ + "notifications": [ + "dockBadge": NotificationBadgeSettings.defaultDockBadgeEnabled, + "showInMenuBar": MenuBarExtraSettings.defaultShowInMenuBar, + "unreadPaneRing": NotificationPaneRingSettings.defaultEnabled, + "paneFlash": NotificationPaneFlashSettings.defaultEnabled, + "sound": NotificationSoundSettings.defaultValue, + "customSoundFilePath": NotificationSoundSettings.defaultCustomFilePath, + "command": NotificationSoundSettings.defaultCustomCommand, + ], + ], + [ + "sidebar": [ + "hideAllDetails": SidebarWorkspaceDetailSettings.defaultHideAllDetails, + "branchLayout": SidebarBranchLayoutSettings.defaultVerticalLayout ? "vertical" : "inline", + "showNotificationMessage": SidebarWorkspaceDetailSettings.defaultShowNotificationMessage, + "showBranchDirectory": true, + "showPullRequests": true, + "openPullRequestLinksInCmuxBrowser": BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser, + "openPortLinksInCmuxBrowser": BrowserLinkOpenSettings.defaultOpenSidebarPortLinksInCmuxBrowser, + "showSSH": true, + "showPorts": true, + "showLog": true, + "showProgress": true, + "showCustomMetadata": true, + ], + ], + [ + "workspaceColors": [ + "indicatorStyle": SidebarActiveTabIndicatorSettings.defaultStyle.rawValue, + "selectionColor": NSNull(), + "notificationBadgeColor": NSNull(), + "paletteOverrides": [:], + "customColors": [String](), + ], + ], + [ + "sidebarAppearance": [ + "matchTerminalBackground": false, + "tintColor": SidebarTintDefaults.hex, + "lightModeTintColor": NSNull(), + "darkModeTintColor": NSNull(), + "tintOpacity": SidebarTintDefaults.opacity, + ], + ], + [ + "automation": [ + "socketControlMode": SocketControlSettings.defaultMode.rawValue, + "socketPassword": "", + "claudeCodeIntegration": ClaudeCodeIntegrationSettings.defaultHooksEnabled, + "claudeBinaryPath": "", + "portBase": 9100, + "portRange": 10, + ], + ], + [ + "customCommands": [ + "trustedDirectories": [String](), + ], + ], + [ + "browser": [ + "defaultSearchEngine": BrowserSearchSettings.defaultSearchEngine.rawValue, + "showSearchSuggestions": BrowserSearchSettings.defaultSearchSuggestionsEnabled, + "theme": BrowserThemeSettings.defaultMode.rawValue, + "openTerminalLinksInCmuxBrowser": BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser, + "interceptTerminalOpenCommandInCmuxBrowser": BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser, + "hostsToOpenInEmbeddedBrowser": [String](), + "urlsToAlwaysOpenExternally": [String](), + "insecureHttpHostsAllowedInEmbeddedBrowser": BrowserInsecureHTTPSettings.defaultAllowlistPatterns, + "showImportHintOnBlankTabs": BrowserImportHintSettings.defaultShowOnBlankTabs, + "reactGrabVersion": ReactGrabSettings.defaultVersion, + ], + ], + [ + "shortcuts": [ + "showModifierHoldHints": ShortcutHintDebugSettings.defaultShowHintsOnCommandHold, + "bindings": shortcutsBindings, + ], + ], + ] + } + + private static func shortcutTemplateValue( + _ shortcut: StoredShortcut, + usesNumberedDigits: Bool + ) -> Any { + let defaultShortcut = usesNumberedDigits ? (shortcut.secondStroke ?? shortcut.firstStroke) : nil + let rendered = renderShortcutStroke(shortcut.firstStroke, preserveDigit: !usesNumberedDigits) + if let secondStroke = shortcut.secondStroke { + return [rendered, renderShortcutStroke(secondStroke, preserveDigit: true)] + } + if let defaultShortcut { + return renderShortcutStroke(defaultShortcut, preserveDigit: true) + } + return rendered + } + + private static func renderShortcutStroke(_ stroke: ShortcutStroke, preserveDigit: Bool) -> String { + var parts: [String] = [] + if stroke.command { parts.append("cmd") } + if stroke.shift { parts.append("shift") } + if stroke.option { parts.append("opt") } + if stroke.control { parts.append("ctrl") } + parts.append(renderShortcutKey(stroke.key, preserveDigit: preserveDigit)) + return parts.joined(separator: "+") + } + + private static func renderShortcutKey(_ key: String, preserveDigit: Bool) -> String { + if preserveDigit { + return key + } + return key + } + + private static func prettyJSONString(_ value: Any) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), + let string = String(data: data, encoding: .utf8) else { + return "{}" + } + return string + } } -private struct KeyboardShortcutSettingsFile: Decodable { - let shortcuts: [String: KeyboardShortcutBindingDefinition]? +typealias KeyboardShortcutSettingsFileStore = CmuxSettingsFileStore + +private struct ResolvedSettingsSnapshot { + var path: String? + var shortcuts: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + var managedUserDefaults: [String: ManagedSettingsValue] = [:] + var managedCustomSettings = ManagedCustomSettings() +} + +private enum ManagedStringOverride: Equatable { + case set(String) + case clear } -private enum KeyboardShortcutBindingDefinition: Decodable { - case single(String) - case chord([String]) +private struct ManagedCustomSettings: Equatable { + var trustedDirectories: [String]? + var socketPassword: ManagedStringOverride? + + var isEmpty: Bool { + trustedDirectories == nil && socketPassword == nil + } + + var managedIdentifiers: Set<String> { + var identifiers: Set<String> = [] + if trustedDirectories != nil { + identifiers.insert(CmuxSettingsFileStore.trustedDirectoriesBackupIdentifier) + } + if socketPassword != nil { + identifiers.insert(CmuxSettingsFileStore.socketPasswordBackupIdentifier) + } + return identifiers + } +} + +private enum ManagedSettingsValue: Equatable { + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case nullableString(String?) + case stringArray([String]) + case stringDictionary([String: String]) +} + +private enum BackupValue: Codable, Equatable { + case absent + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case stringArray([String]) + case stringDictionary([String: String]) + + private enum Kind: String, Codable { + case absent + case bool + case int + case double + case string + case stringArray + case stringDictionary + } + + private enum CodingKeys: String, CodingKey { + case kind + case boolValue + case intValue + case doubleValue + case stringValue + case stringArrayValue + case stringDictionaryValue + } init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let value = try? container.decode(String.self) { - self = .single(value) - return + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .kind) { + case .absent: + self = .absent + case .bool: + self = .bool(try container.decode(Bool.self, forKey: .boolValue)) + case .int: + self = .int(try container.decode(Int.self, forKey: .intValue)) + case .double: + self = .double(try container.decode(Double.self, forKey: .doubleValue)) + case .string: + self = .string(try container.decode(String.self, forKey: .stringValue)) + case .stringArray: + self = .stringArray(try container.decode([String].self, forKey: .stringArrayValue)) + case .stringDictionary: + self = .stringDictionary(try container.decode([String: String].self, forKey: .stringDictionaryValue)) } - if let values = try? container.decode([String].self) { - self = .chord(values) - return + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .absent: + try container.encode(Kind.absent, forKey: .kind) + case .bool(let value): + try container.encode(Kind.bool, forKey: .kind) + try container.encode(value, forKey: .boolValue) + case .int(let value): + try container.encode(Kind.int, forKey: .kind) + try container.encode(value, forKey: .intValue) + case .double(let value): + try container.encode(Kind.double, forKey: .kind) + try container.encode(value, forKey: .doubleValue) + case .string(let value): + try container.encode(Kind.string, forKey: .kind) + try container.encode(value, forKey: .stringValue) + case .stringArray(let value): + try container.encode(Kind.stringArray, forKey: .kind) + try container.encode(value, forKey: .stringArrayValue) + case .stringDictionary(let value): + try container.encode(Kind.stringDictionary, forKey: .kind) + try container.encode(value, forKey: .stringDictionaryValue) } - throw DecodingError.typeMismatch( - KeyboardShortcutBindingDefinition.self, - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Expected a shortcut string or a two-stroke shortcut array" - ) - ) + } +} + +private enum JSONCParser { + static func preprocess(data: Data) throws -> Data { + guard let source = String(data: data, encoding: .utf8) else { + throw JSONCError.invalidUTF8 + } + let withoutBOM = source.hasPrefix("\u{feff}") ? String(source.dropFirst()) : source + let stripped = stripComments(from: withoutBOM) + let normalized = stripTrailingCommas(from: stripped) + return Data(normalized.utf8) + } + + private static func stripComments(from source: String) -> String { + var result = "" + var index = source.startIndex + var inString = false + var isEscaped = false + + while index < source.endIndex { + let character = source[index] + + if inString { + result.append(character) + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + index = source.index(after: index) + continue + } + + if character == "\"" { + inString = true + result.append(character) + index = source.index(after: index) + continue + } + + if character == "/" { + let nextIndex = source.index(after: index) + if nextIndex < source.endIndex { + let next = source[nextIndex] + if next == "/" { + index = source.index(after: nextIndex) + while index < source.endIndex && source[index] != "\n" { + index = source.index(after: index) + } + continue + } + if next == "*" { + index = source.index(after: nextIndex) + while index < source.endIndex { + let current = source[index] + let followingIndex = source.index(after: index) + if current == "*" && followingIndex < source.endIndex && source[followingIndex] == "/" { + index = source.index(after: followingIndex) + break + } + index = followingIndex + } + continue + } + } + } + + result.append(character) + index = source.index(after: index) + } + + return result + } + + private static func stripTrailingCommas(from source: String) -> String { + var result = "" + var index = source.startIndex + var inString = false + var isEscaped = false + + while index < source.endIndex { + let character = source[index] + + if inString { + result.append(character) + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + index = source.index(after: index) + continue + } + + if character == "\"" { + inString = true + result.append(character) + index = source.index(after: index) + continue + } + + if character == "," { + var lookahead = source.index(after: index) + while lookahead < source.endIndex && source[lookahead].isWhitespace { + lookahead = source.index(after: lookahead) + } + if lookahead < source.endIndex && (source[lookahead] == "}" || source[lookahead] == "]") { + index = source.index(after: index) + continue + } + } + + result.append(character) + index = source.index(after: index) + } + + return result + } + + private enum JSONCError: Error { + case invalidUTF8 } } @@ -379,7 +1589,7 @@ private final class ShortcutSettingsFileWatcher { } source.setCancelHandler { - Darwin.close(fd) + close(fd) } self.source = source @@ -388,32 +1598,26 @@ private final class ShortcutSettingsFileWatcher { private func startDirectoryWatcher() { let directoryPath = (path as NSString).deletingLastPathComponent - if !fileManager.fileExists(atPath: directoryPath) { - try? fileManager.createDirectory( - atPath: directoryPath, - withIntermediateDirectories: true, - attributes: nil - ) - } - let fd = open(directoryPath, O_EVTONLY) guard fd >= 0 else { return } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, - eventMask: [.write, .link, .rename], + eventMask: [.write, .rename, .delete], queue: watchQueue ) source.setEventHandler { [weak self] in guard let self else { return } - guard self.fileManager.fileExists(atPath: self.path) else { return } - self.start() - self.onChange() + if self.fileManager.fileExists(atPath: self.path) { + self.start() + } else { + self.onChange() + } } source.setCancelHandler { - Darwin.close(fd) + close(fd) } self.source = source diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 9524cf51eebf..f4f3f77a8613 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -63,6 +63,7 @@ enum SocketControlMode: String, CaseIterable, Identifiable { enum SocketControlPasswordStore { static let directoryName = "cmux" static let fileName = "socket-control-password" + static let didChangeNotification = Notification.Name("cmux.socketControlPasswordDidChange") private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion" private static let keychainMigrationVersion = 1 private static let legacyKeychainService = "com.cmuxterm.app.socket-control" @@ -196,6 +197,7 @@ enum SocketControlPasswordStore { let data = Data(normalized.utf8) try data.write(to: fileURL, options: .atomic) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + NotificationCenter.default.post(name: didChangeNotification, object: nil) } static func clearPassword(fileURL: URL? = nil) throws { @@ -206,6 +208,7 @@ enum SocketControlPasswordStore { return } try FileManager.default.removeItem(at: fileURL) + NotificationCenter.default.post(name: didChangeNotification, object: nil) } static func resetLazyKeychainFallbackCacheForTests() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ffe49bc80742..b4f4dfe37290 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -163,6 +163,7 @@ struct cmuxApp: App { } Self.configureGhosttyEnvironment() + _ = KeyboardShortcutSettings.settingsFileStore // Apply saved language preference before any UI loads LanguageSettings.apply(LanguageSettings.languageAtLaunch) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index a9d53d4607e4..5f777b7ad87b 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -323,6 +323,7 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! + private let settingsFileBackupsDefaultsKey = "cmux.settingsFile.backups.v1" override func setUp() { super.setUp() @@ -575,6 +576,305 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .newTab), persistedShortcut) } + func testBootstrapCreatesCommentedTemplateWhenPrimaryAndFallbackAreMissing() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL + .appendingPathComponent(".config/cmux", isDirectory: true) + .appendingPathComponent("settings.json", isDirectory: false) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertTrue(FileManager.default.fileExists(atPath: settingsFileURL.path)) + XCTAssertEqual(store.activeSourcePath, settingsFileURL.path) + XCTAssertNil(store.override(for: .newTab)) + + let contents = try String(contentsOf: settingsFileURL, encoding: .utf8) + XCTAssertTrue(contents.contains(#""$schema": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json""#)) + XCTAssertTrue(contents.contains(#""schemaVersion": 1,"#)) + XCTAssertTrue(contents.contains(#"// "app" : {"#)) + XCTAssertTrue(contents.contains(#"// "shortcuts" : {"#)) + } + + func testBootstrapDoesNotCreatePrimaryWhenFallbackAlreadyExists() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let primaryURL = directoryURL.appendingPathComponent("primary/settings.json", isDirectory: false) + let fallbackURL = directoryURL.appendingPathComponent("fallback/settings.json", isDirectory: false) + try FileManager.default.createDirectory( + at: fallbackURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try writeSettingsFile( + """ + { + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: fallbackURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + + XCTAssertFalse(FileManager.default.fileExists(atPath: primaryURL.path)) + XCTAssertEqual(store.activeSourcePath, fallbackURL.path) + } + + func testSettingsFileStoreParsesJSONCCommentsAndTrailingCommas() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "$schema": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json", + "schemaVersion": 1, + // tmux-like prefix + "shortcuts": { + "bindings": { + "newTab": [ + "ctrl+b", + "c", + ], + }, + }, + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + store.override(for: .newTab), + StoredShortcut(key: "b", command: false, shift: false, option: false, control: true, chordKey: "c") + ) + } + + func testFutureSchemaVersionStillParsesKnownFields() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "schemaVersion": 999, + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: settingsFileURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual( + store.override(for: .showNotifications), + StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) + ) + } + + func testManagedUserDefaultSettingRestoresBackedUpValueWhenFileSettingIsRemoved() throws { + let defaults = UserDefaults.standard + let managedKey = WorkspaceAutoReorderSettings.key + let previousValue = defaults.object(forKey: managedKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: managedKey) + } else { + defaults.removeObject(forKey: managedKey) + } + + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + defaults.set(false, forKey: managedKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let managedSettingsURL = directoryURL.appendingPathComponent("managed.json", isDirectory: false) + try writeSettingsFile( + """ + { + "app": { + "reorderOnNotification": true + } + } + """, + to: managedSettingsURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: managedSettingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(defaults.object(forKey: managedKey) as? Bool, true) + + let missingSettingsURL = directoryURL.appendingPathComponent("missing.json", isDirectory: false) + _ = KeyboardShortcutSettingsFileStore( + primaryPath: missingSettingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(defaults.object(forKey: managedKey) as? Bool, false) + XCTAssertNil(defaults.data(forKey: settingsFileBackupsDefaultsKey)) + } + + @MainActor + func testReloadConfigurationReloadsManagedAppSettingsFromSettingsFile() throws { + let defaults = UserDefaults.standard + let managedKey = WorkspacePlacementSettings.placementKey + let previousValue = defaults.object(forKey: managedKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: managedKey) + } else { + defaults.removeObject(forKey: managedKey) + } + + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + defaults.removeObject(forKey: managedKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "app": { + "newWorkspacePlacement": "top" + } + } + """, + to: settingsFileURL + ) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(WorkspacePlacementSettings.current(), .top) + + try writeSettingsFile( + """ + { + "app": { + "newWorkspacePlacement": "end" + } + } + """, + to: settingsFileURL + ) + + GhosttyApp.shared.reloadConfiguration(source: "test.reload_config_app_setting") + + XCTAssertEqual(WorkspacePlacementSettings.current(), .end) + } + + @MainActor + func testManagedWorkspacePlacementChangesDefaultInsertionBehavior() throws { + let defaults = UserDefaults.standard + let managedKey = WorkspacePlacementSettings.placementKey + let previousValue = defaults.object(forKey: managedKey) + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: managedKey) + } else { + defaults.removeObject(forKey: managedKey) + } + + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + defaults.removeObject(forKey: managedKey) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "app": { + "newWorkspacePlacement": "top" + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + let manager = TabManager() + guard let first = manager.tabs.first else { + XCTFail("Expected initial workspace") + return + } + + let second = manager.addWorkspace(placementOverride: .end) + let third = manager.addWorkspace(placementOverride: .end) + manager.selectWorkspace(first) + + let inserted = manager.addWorkspace() + + XCTAssertEqual(manager.tabs.map(\.id), [inserted.id, first.id, second.id, third.id]) + XCTAssertEqual(manager.selectedTabId, inserted.id) + } + private func makeTemporaryDirectory() throws -> URL { let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 4e9e21b4fa0f..8384f1b530b2 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -1,8 +1,71 @@ -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; import { buildAlternates } from "../../../../i18n/seo"; +import { Link } from "../../../../i18n/navigation"; import { CodeBlock } from "../../components/code-block"; import { Callout } from "../../components/callout"; +import settingsSchema from "../../../../data/cmux-settings.schema.json"; +import { shortcutCategories, type LocalizedText } from "../../../../data/cmux-shortcuts"; + +type SchemaProperty = { + title?: string; + description?: string; + type?: string | string[]; + enum?: string[]; + default?: unknown; + properties?: Record<string, SchemaProperty>; + items?: SchemaProperty; + oneOf?: SchemaProperty[]; + propertyNames?: { + enum?: string[]; + }; +}; + +type SchemaDocument = { + $id?: string; + properties?: Record<string, SchemaProperty>; +}; + +const typedSettingsSchema = settingsSchema as SchemaDocument; +const schemaProperties = typedSettingsSchema.properties ?? {}; +const schemaUrl = + typedSettingsSchema.$id ?? + "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json"; +const schemaSourceUrl = + "https://github.com/manaflow-ai/cmux/blob/main/web/data/cmux-settings.schema.json"; +const sectionOrder = [ + "app", + "notifications", + "sidebar", + "workspaceColors", + "sidebarAppearance", + "automation", + "customCommands", + "browser", + "shortcuts", +] as const; + +const settingsFileExample = `{ + "$schema": "${schemaUrl}", + "schemaVersion": 1, + + // "app": { + // "appearance": "dark", + // "newWorkspacePlacement": "afterCurrent" + // }, + + // "browser": { + // "openTerminalLinksInCmuxBrowser": true, + // "hostsToOpenInEmbeddedBrowser": ["localhost", "*.internal.example"] + // }, + + // "shortcuts": { + // "bindings": { + // "toggleSidebar": "cmd+b", + // "newTab": ["ctrl+b", "c"] + // } + // }, +}`; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; @@ -14,8 +77,126 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s }; } +function localizedText(text: LocalizedText, locale: string) { + return locale.startsWith("ja") ? text.ja : text.en; +} + +function shortcutComboToConfig(combo: string[]) { + const modifierMap: Record<string, string> = { + "⌘": "cmd", + "⇧": "shift", + "⌥": "opt", + "⌃": "ctrl", + }; + const keyMap: Record<string, string> = { + "←": "left", + "→": "right", + "↑": "up", + "↓": "down", + "↩": "enter", + "1…9": "1", + }; + + return combo + .map((part) => modifierMap[part] ?? keyMap[part] ?? part.toLowerCase()) + .join("+"); +} + +function formatSchemaType(property: SchemaProperty): string { + if (property.oneOf?.length) { + return property.oneOf.map(formatSchemaType).join(" | "); + } + if (Array.isArray(property.type)) { + return property.type.join(" | "); + } + if (property.type === "array") { + const itemType = property.items ? formatSchemaType(property.items) : "unknown"; + return `array<${itemType}>`; + } + if (property.type === "object") { + return "object"; + } + return property.type ?? "unknown"; +} + +function formatDefaultValue(value: unknown): string { + if (value === undefined) { + return "none"; + } + if (typeof value === "string") { + return JSON.stringify(value); + } + return JSON.stringify(value) ?? "none"; +} + +function PropertyCard({ path, property }: { path: string; property: SchemaProperty }) { + return ( + <div className="rounded-xl border border-border/70 bg-background/40 p-4"> + <div className="mb-2 flex items-center gap-2"> + <code className="text-[12px] font-medium">{path}</code> + </div> + {property.description && <p className="mb-3 text-sm text-muted">{property.description}</p>} + <dl className="space-y-2 text-sm"> + <div> + <dt className="font-medium text-foreground">Type</dt> + <dd className="text-muted"> + <code>{formatSchemaType(property)}</code> + </dd> + </div> + <div> + <dt className="font-medium text-foreground">Default</dt> + <dd className="text-muted"> + <code>{formatDefaultValue(property.default)}</code> + </dd> + </div> + {property.enum && ( + <div> + <dt className="font-medium text-foreground">Allowed values</dt> + <dd className="text-muted"> + <code>{property.enum.join(", ")}</code> + </dd> + </div> + )} + </dl> + </div> + ); +} + +function PropertyGrid({ + prefix, + properties, + skip = [], +}: { + prefix: string; + properties: Record<string, SchemaProperty>; + skip?: string[]; +}) { + const entries = Object.entries(properties).filter(([name]) => !skip.includes(name)); + return ( + <div className="not-prose grid gap-4 md:grid-cols-2"> + {entries.map(([name, property]) => ( + <PropertyCard + key={prefix ? `${prefix}.${name}` : name} + path={prefix ? `${prefix}.${name}` : name} + property={property} + /> + ))} + </div> + ); +} + export default function ConfigurationPage() { + const locale = useLocale(); const t = useTranslations("docs.configuration"); + const shortcutTranslations = useTranslations("docs.keyboardShortcuts"); + + const metadataProperties = { + $schema: schemaProperties.$schema, + schemaVersion: schemaProperties.schemaVersion, + } satisfies Record<string, SchemaProperty | undefined>; + + const shortcutsSection = schemaProperties.shortcuts; + const shortcutProperties = shortcutsSection?.properties ?? {}; return ( <> @@ -36,77 +217,20 @@ export default function ConfigurationPage() { <CodeBlock lang="bash">{`mkdir -p ~/.config/ghostty touch ~/.config/ghostty/config`}</CodeBlock> - <h2>{t("appearance")}</h2> - - <h3>{t("font")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`font-family = JetBrains Mono -font-size = 14`}</CodeBlock> - - <h3>{t("colors")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Theme (or use individual colors below) -theme = Dracula - -# Custom colors -background = #1e1e2e -foreground = #cdd6f4 -cursor-color = #f5e0dc -cursor-text = #1e1e2e -selection-background = #585b70 -selection-foreground = #cdd6f4`}</CodeBlock> - - <h3>{t("splitPanes")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Opacity for unfocused splits (0.0 to 1.0) -unfocused-split-opacity = 0.7 - -# Fill color for unfocused splits -unfocused-split-fill = #1e1e2e - -# Divider color between splits -split-divider-color = #45475a`}</CodeBlock> - - <h2>{t("behavior")}</h2> - - <h3>{t("scrollback")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Number of lines to keep in scrollback buffer -scrollback-limit = 10000`}</CodeBlock> - - <h3>{t("workingDirectory")}</h3> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Default directory for new terminals -working-directory = ~/Projects`}</CodeBlock> - - <h2>{t("appSettings")}</h2> - <p>{t("appSettingsDesc", { shortcut: "⌘," })}</p> - - <h3>{t("themeMode")}</h3> - <ul> - <li> - <strong>{t("themeSystem")}</strong> - </li> - <li> - <strong>{t("themeLight")}</strong> - </li> - <li> - <strong>{t("themeDark")}</strong> - </li> - </ul> - - <h3>{t("automationMode")}</h3> - <p>{t("automationModeDesc")}</p> - <ul> - <li> - <strong>{t("automationOff")}</strong> - </li> - <li> - <strong>{t("automationCmux")}</strong> - </li> - <li> - <strong>{t("automationAll")}</strong> - </li> - </ul> - <Callout type="warn">{t("automationCallout")}</Callout> + <h2>{t("exampleConfig")}</h2> + <CodeBlock title="~/.config/ghostty/config" lang="ini">{`font-family = SF Mono +font-size = 13 +theme = One Dark +scrollback-limit = 50000 +split-divider-color = #3e4451 +working-directory = ~/code`}</CodeBlock> - <h3>{t("settingsFile")}</h3> - <p>{t("settingsFileDesc")}</p> + <h2>cmux settings.json</h2> + <p> + cmux keeps app-owned settings in a separate user file instead of mixing them into Ghostty + config. On launch, if neither settings location exists, cmux writes a commented template to{" "} + <code>~/.config/cmux/settings.json</code>. + </p> <ol> <li> <code>~/.config/cmux/settings.json</code> @@ -115,44 +239,108 @@ working-directory = ~/Projects`}</CodeBlock> <code>~/Library/Application Support/com.cmuxterm.app/settings.json</code> </li> </ol> - <Callout type="info">{t("settingsFilePrecedence")}</Callout> - <CodeBlock title="~/.config/cmux/settings.json" lang="json">{`{ - "shortcuts": { - "toggleSidebar": "cmd+b", - "newTab": ["ctrl+b", "c"], - "selectWorkspaceByNumber": ["ctrl+b", "1"] - } -}`}</CodeBlock> - <p>{t("settingsFileChords")}</p> + <Callout type="info"> + <strong>Precedence:</strong> <code>~/.config/cmux/settings.json</code> wins over the + Application Support fallback. File-managed values override the value saved in the Settings + window. Remove a key to fall back to the Settings value again. + </Callout> + <Callout type="info"> + <strong>Reload:</strong> edit the file, then use <code>Cmd+Shift+,</code> or{" "} + <code>cmux reload-config</code> to re-read it without restarting the app. + </Callout> + <Callout type="warn"> + <strong>Migrations:</strong> keep <code>schemaVersion</code> at <code>1</code> for now. + Future cmux versions will use that field for upgrades. If cmux sees a newer schema version, + it logs a warning and parses known keys only. + </Callout> + <p> + The file accepts JSON with comments and trailing commas. The canonical schema is published + at <a href={schemaUrl}>{schemaUrl}</a> and the source lives at{" "} + <a href={schemaSourceUrl}>{schemaSourceUrl}</a>. + </p> + <CodeBlock title="~/.config/cmux/settings.json" lang="json"> + {settingsFileExample} + </CodeBlock> - <h3>{t("browserLinkBehavior")}</h3> - <p>{t("browserLinkDesc")}</p> - <ul> - <li> - <strong>{t("browserHostsEmbed")}</strong> - </li> - <li> - <strong>{t("browserHostsHttp")}</strong> - </li> - </ul> + <h2>Schema reference</h2> + <p> + This reference covers every supported key in <code>settings.json</code>. The embedded + browser, sidebar, notifications, automation, and cmux-owned keyboard shortcuts all live + here. + </p> - <h2>{t("exampleConfig")}</h2> - <CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Font -font-family = SF Mono -font-size = 13 + <h3>Metadata</h3> + <PropertyGrid + prefix="" + properties={Object.fromEntries( + Object.entries(metadataProperties).filter(([, property]) => property) + ) as Record<string, SchemaProperty>} + /> -# Colors -theme = One Dark + {sectionOrder.map((sectionName) => { + const property = schemaProperties[sectionName]; + if (!property?.properties) { + return null; + } -# Scrollback -scrollback-limit = 50000 + const skipBindings = sectionName === "shortcuts" ? ["bindings"] : []; -# Splits -unfocused-split-opacity = 0.85 -split-divider-color = #3e4451 + return ( + <section key={sectionName}> + <h3> + <code>{sectionName}</code> + </h3> + {property.description && <p>{property.description}</p>} + <PropertyGrid prefix={sectionName} properties={property.properties} skip={skipBindings} /> + </section> + ); + })} -# Working directory -working-directory = ~/code`}</CodeBlock> + <h3> + <code>shortcuts.bindings</code> + </h3> + <p> + Use a string for a single shortcut, or a two-item array for a chord. Example:{" "} + <code>["ctrl+b", "c"]</code>. Numbered actions use <code>1</code> as + the stored default and still match digits <code>1</code> through <code>9</code>. + </p> + <p> + The defaults below are the same cmux-owned actions listed on the{" "} + <Link href="/docs/keyboard-shortcuts">keyboard shortcuts page</Link>. + </p> + {shortcutCategories.map((category) => ( + <section key={category.id}> + <h4>{shortcutTranslations(`cat.${category.titleKey}` as never)}</h4> + <div className="not-prose overflow-hidden rounded-xl border border-border/70 bg-background/40"> + {category.shortcuts.map((shortcut, index) => ( + <div + key={shortcut.id} + className={`grid gap-3 px-4 py-3 md:grid-cols-[minmax(0,1fr)_220px] ${ + index > 0 ? "border-t border-border/70" : "" + }`} + > + <div> + <div className="mb-1 flex items-center gap-2"> + <code className="text-[12px] font-medium">{shortcut.id}</code> + </div> + <p className="text-sm text-foreground/90"> + {localizedText(shortcut.description, locale)} + {shortcut.note && ( + <span className="ml-2 text-xs text-muted"> + {localizedText(shortcut.note, locale)} + </span> + )} + </p> + </div> + <div className="text-sm text-muted"> + <div className="font-medium text-foreground">Default file value</div> + <code>{shortcutComboToConfig(shortcut.combos[0] ?? [])}</code> + </div> + </div> + ))} + </div> + </section> + ))} </> ); } diff --git a/web/app/[locale]/keyboard-shortcuts.tsx b/web/app/[locale]/keyboard-shortcuts.tsx index b1d0243976cf..6072d4e037f7 100644 --- a/web/app/[locale]/keyboard-shortcuts.tsx +++ b/web/app/[locale]/keyboard-shortcuts.tsx @@ -2,142 +2,7 @@ import { useMemo, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; - -type LocalizedText = { - en: string; - ja: string; -}; - -type Shortcut = { - id: string; - combos: string[][]; - description: LocalizedText; - note?: LocalizedText; -}; - -type ShortcutCategory = { - id: string; - titleKey: string; - blurbKey?: string; - shortcuts: Shortcut[]; -}; - -const CATEGORIES: ShortcutCategory[] = [ - { - id: "app", - titleKey: "app", - blurbKey: "appBlurb", - shortcuts: [ - { id: "openSettings", combos: [["⌘", ","]], description: { en: "Settings", ja: "設定" } }, - { id: "reloadConfiguration", combos: [["⌘", "⇧", ","]], description: { en: "Reload configuration", ja: "構成を再読み込み" } }, - { id: "commandPalette", combos: [["⌘", "⇧", "P"]], description: { en: "Command palette", ja: "コマンドパレット" } }, - { id: "newWindow", combos: [["⌘", "⇧", "N"]], description: { en: "New window", ja: "新規ウインドウ" } }, - { id: "closeWindow", combos: [["⌃", "⌘", "W"]], description: { en: "Close window", ja: "ウインドウを閉じる" } }, - { id: "toggleFullScreen", combos: [["⌃", "⌘", "F"]], description: { en: "Toggle full screen", ja: "フルスクリーンを切り替え" } }, - { id: "sendFeedback", combos: [["⌥", "⌘", "F"]], description: { en: "Send feedback", ja: "フィードバックを送信" } }, - { id: "quit", combos: [["⌘", "Q"]], description: { en: "Quit cmux", ja: "cmuxを終了" } }, - ], - }, - { - id: "workspaces", - titleKey: "workspaces", - blurbKey: "workspacesBlurb", - shortcuts: [ - { id: "toggleSidebar", combos: [["⌘", "B"]], description: { en: "Toggle sidebar", ja: "サイドバーを切り替え" } }, - { id: "newTab", combos: [["⌘", "N"]], description: { en: "New workspace", ja: "新規ワークスペース" } }, - { id: "openFolder", combos: [["⌘", "O"]], description: { en: "Open folder", ja: "フォルダを開く" } }, - { - id: "goToWorkspace", - combos: [["⌘", "P"]], - description: { en: "Go to workspace", ja: "ワークスペースへ移動" }, - note: { en: "workspace switcher", ja: "ワークスペーススイッチャー" }, - }, - { id: "nextSidebarTab", combos: [["⌃", "⌘", "]"]], description: { en: "Next workspace", ja: "次のワークスペース" } }, - { id: "prevSidebarTab", combos: [["⌃", "⌘", "["]], description: { en: "Previous workspace", ja: "前のワークスペース" } }, - { id: "selectWorkspaceByNumber", combos: [["⌘", "1…9"]], description: { en: "Select workspace 1…9", ja: "ワークスペース1…9を選択" } }, - { id: "renameWorkspace", combos: [["⌘", "⇧", "R"]], description: { en: "Rename workspace", ja: "ワークスペース名を変更" } }, - { id: "closeWorkspace", combos: [["⌘", "⇧", "W"]], description: { en: "Close workspace", ja: "ワークスペースを閉じる" } }, - ], - }, - { - id: "surfaces", - titleKey: "surfaces", - blurbKey: "surfacesBlurb", - shortcuts: [ - { id: "newSurface", combos: [["⌘", "T"]], description: { en: "New surface", ja: "新規サーフェス" } }, - { id: "nextSurface", combos: [["⌘", "⇧", "]"]], description: { en: "Next surface", ja: "次のサーフェス" } }, - { id: "prevSurface", combos: [["⌘", "⇧", "["]], description: { en: "Previous surface", ja: "前のサーフェス" } }, - { id: "selectSurfaceByNumber", combos: [["⌃", "1…9"]], description: { en: "Select surface 1…9", ja: "サーフェス1…9を選択" } }, - { id: "renameTab", combos: [["⌘", "R"]], description: { en: "Rename tab", ja: "タブ名を変更" } }, - { id: "closeTab", combos: [["⌘", "W"]], description: { en: "Close tab", ja: "タブを閉じる" } }, - { id: "closeOtherTabsInPane", combos: [["⌥", "⌘", "T"]], description: { en: "Close other tabs in pane", ja: "ペイン内の他のタブを閉じる" } }, - { id: "reopenClosedBrowserPanel", combos: [["⌘", "⇧", "T"]], description: { en: "Reopen closed browser panel", ja: "閉じたブラウザパネルを再度開く" } }, - { id: "toggleTerminalCopyMode", combos: [["⌘", "⇧", "M"]], description: { en: "Toggle terminal copy mode", ja: "ターミナルコピーモードを切り替え" } }, - ], - }, - { - id: "split-panes", - titleKey: "splitPanes", - shortcuts: [ - { id: "focusLeft", combos: [["⌥", "⌘", "←"]], description: { en: "Focus pane left", ja: "左のペインにフォーカス" } }, - { id: "focusRight", combos: [["⌥", "⌘", "→"]], description: { en: "Focus pane right", ja: "右のペインにフォーカス" } }, - { id: "focusUp", combos: [["⌥", "⌘", "↑"]], description: { en: "Focus pane up", ja: "上のペインにフォーカス" } }, - { id: "focusDown", combos: [["⌥", "⌘", "↓"]], description: { en: "Focus pane down", ja: "下のペインにフォーカス" } }, - { id: "splitRight", combos: [["⌘", "D"]], description: { en: "Split right", ja: "右に分割" } }, - { id: "splitDown", combos: [["⌘", "⇧", "D"]], description: { en: "Split down", ja: "下に分割" } }, - { id: "splitBrowserRight", combos: [["⌥", "⌘", "D"]], description: { en: "Split browser right", ja: "右にブラウザ分割" } }, - { id: "splitBrowserDown", combos: [["⌥", "⌘", "⇧", "D"]], description: { en: "Split browser down", ja: "下にブラウザ分割" } }, - { id: "toggleSplitZoom", combos: [["⌘", "⇧", "↩"]], description: { en: "Toggle pane zoom", ja: "ペインズームを切り替え" } }, - ], - }, - { - id: "browser", - titleKey: "browser", - shortcuts: [ - { id: "openBrowser", combos: [["⌘", "⇧", "L"]], description: { en: "Open browser", ja: "ブラウザを開く" } }, - { id: "focusBrowserAddressBar", combos: [["⌘", "L"]], description: { en: "Focus address bar", ja: "アドレスバーにフォーカス" } }, - { id: "browserBack", combos: [["⌘", "["]], description: { en: "Back", ja: "戻る" } }, - { id: "browserForward", combos: [["⌘", "]"]], description: { en: "Forward", ja: "進む" } }, - { - id: "browserReload", - combos: [["⌘", "R"]], - description: { en: "Reload page", ja: "ページを再読み込み" }, - note: { en: "focused browser", ja: "フォーカス中のブラウザ" }, - }, - { id: "browserZoomIn", combos: [["⌘", "="]], description: { en: "Zoom in", ja: "拡大" } }, - { id: "browserZoomOut", combos: [["⌘", "-"]], description: { en: "Zoom out", ja: "縮小" } }, - { id: "browserZoomReset", combos: [["⌘", "0"]], description: { en: "Actual size", ja: "実寸表示" } }, - { id: "toggleBrowserDeveloperTools", combos: [["⌥", "⌘", "I"]], description: { en: "Toggle browser developer tools", ja: "ブラウザ開発者ツールを切り替え" } }, - { id: "showBrowserJavaScriptConsole", combos: [["⌥", "⌘", "C"]], description: { en: "Show browser JavaScript console", ja: "ブラウザJavaScriptコンソールを表示" } }, - { - id: "toggleReactGrab", - combos: [["⌥", "⌘", "G"]], - description: { en: "Toggle React Grab", ja: "React Grabを切り替え" }, - note: { en: "focused browser", ja: "フォーカス中のブラウザ" }, - }, - ], - }, - { - id: "find", - titleKey: "find", - shortcuts: [ - { id: "find", combos: [["⌘", "F"]], description: { en: "Find", ja: "検索" } }, - { id: "findNext", combos: [["⌘", "G"]], description: { en: "Find next", ja: "次を検索" } }, - { id: "findPrevious", combos: [["⌘", "⇧", "G"]], description: { en: "Find previous", ja: "前を検索" } }, - { id: "hideFind", combos: [["⌘", "⇧", "F"]], description: { en: "Hide find bar", ja: "検索バーを隠す" } }, - { id: "useSelectionForFind", combos: [["⌘", "E"]], description: { en: "Use selection for find", ja: "選択範囲で検索" } }, - ], - }, - { - id: "notifications", - titleKey: "notifications", - shortcuts: [ - { id: "showNotifications", combos: [["⌘", "I"]], description: { en: "Show notifications", ja: "通知を表示" } }, - { id: "jumpToUnread", combos: [["⌘", "⇧", "U"]], description: { en: "Jump to latest unread", ja: "最新の未読へ移動" } }, - { id: "triggerFlash", combos: [["⌘", "⇧", "H"]], description: { en: "Flash focused panel", ja: "フォーカス中のパネルをフラッシュ" } }, - ], - }, -]; +import { shortcutCategories, type LocalizedText, type Shortcut } from "../../data/cmux-shortcuts"; function localizedText(text: LocalizedText, locale: string) { return locale.startsWith("ja") ? text.ja : text.en; @@ -203,8 +68,8 @@ export function KeyboardShortcuts() { const filtered = useMemo(() => { const q = normalize(query); - if (!q) return CATEGORIES; - return CATEGORIES.map((cat) => ({ + if (!q) return shortcutCategories; + return shortcutCategories.map((cat) => ({ ...cat, shortcuts: cat.shortcuts.filter((shortcut) => { const catTitle = t(`cat.${cat.titleKey}`); @@ -245,7 +110,7 @@ export function KeyboardShortcuts() { {!trimmedQuery && ( <nav className="mb-10 flex flex-wrap items-center gap-y-2"> - {CATEGORIES.map((cat, idx) => ( + {shortcutCategories.map((cat, idx) => ( <span key={cat.id} className="inline-flex items-center"> <a href={`#${cat.id}`} @@ -253,7 +118,7 @@ export function KeyboardShortcuts() { > {t(`cat.${cat.titleKey}`)} </a> - {idx < CATEGORIES.length - 1 && ( + {idx < shortcutCategories.length - 1 && ( <span className="mx-2.5 select-none text-[10px] text-border"> · </span> diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json new file mode 100644 index 000000000000..caad7c0b9d6f --- /dev/null +++ b/web/data/cmux-settings.schema.json @@ -0,0 +1,534 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json", + "title": "cmux settings.json", + "description": "User-level cmux settings. cmux accepts JSON with comments (JSONC) and trailing commas, but the underlying data model matches this schema.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "format": "uri", + "default": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json", + "description": "Optional schema URL for editor completion and validation." + }, + "schemaVersion": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Schema version for forward-compatible migrations. Newer versions are parsed on a best-effort basis." + }, + "app": { + "title": "app", + "description": "General app preferences from Settings > App.", + "type": "object", + "additionalProperties": false, + "properties": { + "language": { + "type": "string", + "enum": ["system", "en", "ar", "bs", "zh-Hans", "zh-Hant", "da", "de", "es", "fr", "it", "ja", "ko", "nb", "pl", "pt-BR", "ru", "th", "tr"], + "default": "system", + "description": "Preferred app language." + }, + "appearance": { + "type": "string", + "enum": ["system", "light", "dark"], + "default": "system", + "description": "App appearance mode." + }, + "appIcon": { + "type": "string", + "enum": ["automatic", "light", "dark"], + "default": "automatic", + "description": "Dock and app switcher icon style." + }, + "newWorkspacePlacement": { + "type": "string", + "enum": ["top", "afterCurrent", "end"], + "default": "afterCurrent", + "description": "Where new workspaces are inserted in the sidebar." + }, + "minimalMode": { + "type": "boolean", + "default": false, + "description": "Hide the workspace title bar and move controls into the sidebar." + }, + "keepWorkspaceOpenWhenClosingLastSurface": { + "type": "boolean", + "default": false, + "description": "When true, closing the last surface keeps the workspace open." + }, + "focusPaneOnFirstClick": { + "type": "boolean", + "default": true, + "description": "When cmux is inactive, the first click can activate and focus the clicked pane." + }, + "preferredEditor": { + "type": "string", + "default": "", + "description": "Custom editor command used by cmux where applicable. Leave empty to use the default." + }, + "reorderOnNotification": { + "type": "boolean", + "default": true, + "description": "Move workspaces with new notifications toward the top." + }, + "sendAnonymousTelemetry": { + "type": "boolean", + "default": true, + "description": "Allow anonymous telemetry." + }, + "warnBeforeQuit": { + "type": "boolean", + "default": true, + "description": "Show a confirmation before quitting cmux." + }, + "renameSelectsExistingName": { + "type": "boolean", + "default": true, + "description": "Select the current name when opening rename flows." + }, + "commandPaletteSearchesAllSurfaces": { + "type": "boolean", + "default": false, + "description": "Search every surface in the command palette switcher instead of only the active workspace." + } + } + }, + "notifications": { + "title": "notifications", + "description": "Notification behavior from Settings > Notifications.", + "type": "object", + "additionalProperties": false, + "properties": { + "dockBadge": { + "type": "boolean", + "default": true, + "description": "Show the unread count in the Dock tile." + }, + "showInMenuBar": { + "type": "boolean", + "default": true, + "description": "Show the menu bar extra." + }, + "unreadPaneRing": { + "type": "boolean", + "default": true, + "description": "Highlight panes with unread notifications." + }, + "paneFlash": { + "type": "boolean", + "default": true, + "description": "Flash the focused pane when requested." + }, + "sound": { + "type": "string", + "enum": ["default", "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink", "custom_file", "none"], + "default": "default", + "description": "Notification sound preset." + }, + "customSoundFilePath": { + "type": "string", + "default": "", + "description": "Local path to the custom notification sound file." + }, + "command": { + "type": "string", + "default": "", + "description": "Optional shell command to run alongside notification delivery." + } + } + }, + "sidebar": { + "title": "sidebar", + "description": "Sidebar content and metadata visibility from Settings > Sidebar.", + "type": "object", + "additionalProperties": false, + "properties": { + "hideAllDetails": { + "type": "boolean", + "default": false, + "description": "Hide all per-workspace detail rows." + }, + "branchLayout": { + "type": "string", + "enum": ["vertical", "inline"], + "default": "vertical", + "description": "Show git branch details stacked vertically or inline." + }, + "showNotificationMessage": { + "type": "boolean", + "default": true, + "description": "Show the latest notification text in the sidebar." + }, + "showBranchDirectory": { + "type": "boolean", + "default": true, + "description": "Show the workspace working directory." + }, + "showPullRequests": { + "type": "boolean", + "default": true, + "description": "Show pull request metadata in the sidebar." + }, + "openPullRequestLinksInCmuxBrowser": { + "type": "boolean", + "default": true, + "description": "Open sidebar pull request links in the embedded cmux browser." + }, + "openPortLinksInCmuxBrowser": { + "type": "boolean", + "default": true, + "description": "Open sidebar port links in the embedded cmux browser." + }, + "showSSH": { + "type": "boolean", + "default": true, + "description": "Show SSH connection details." + }, + "showPorts": { + "type": "boolean", + "default": true, + "description": "Show listening ports." + }, + "showLog": { + "type": "boolean", + "default": true, + "description": "Show recent log snippets." + }, + "showProgress": { + "type": "boolean", + "default": true, + "description": "Show progress indicators." + }, + "showCustomMetadata": { + "type": "boolean", + "default": true, + "description": "Show custom metadata pills." + } + } + }, + "workspaceColors": { + "title": "workspaceColors", + "description": "Workspace tab and badge colors from Settings > Workspace Colors.", + "type": "object", + "additionalProperties": false, + "properties": { + "indicatorStyle": { + "type": "string", + "enum": ["leftRail", "solidFill", "rail", "border", "wash", "lift", "typography", "washRail", "blueWashColorRail"], + "default": "leftRail", + "description": "Active workspace indicator style. Legacy aliases are accepted and normalized." + }, + "selectionColor": { + "$ref": "#/$defs/colorHexOrNull", + "default": null, + "description": "Override the selected workspace background color." + }, + "notificationBadgeColor": { + "$ref": "#/$defs/colorHexOrNull", + "default": null, + "description": "Override the unread notification badge color." + }, + "paletteOverrides": { + "type": "object", + "default": {}, + "description": "Override built-in workspace color names with custom hex values. Unknown names are ignored.", + "additionalProperties": { + "$ref": "#/$defs/colorHex" + } + }, + "customColors": { + "type": "array", + "items": { + "$ref": "#/$defs/colorHex" + }, + "maxItems": 24, + "default": [], + "description": "Custom workspace colors available in the picker." + } + } + }, + "sidebarAppearance": { + "title": "sidebarAppearance", + "description": "Sidebar tint settings from Settings > Sidebar Appearance.", + "type": "object", + "additionalProperties": false, + "properties": { + "matchTerminalBackground": { + "type": "boolean", + "default": false, + "description": "Use the terminal background instead of the sidebar tint." + }, + "tintColor": { + "$ref": "#/$defs/colorHex", + "default": "#000000", + "description": "Base sidebar tint color used when light/dark overrides are not set." + }, + "lightModeTintColor": { + "$ref": "#/$defs/colorHexOrNull", + "default": null, + "description": "Sidebar tint override for light appearance." + }, + "darkModeTintColor": { + "$ref": "#/$defs/colorHexOrNull", + "default": null, + "description": "Sidebar tint override for dark appearance." + }, + "tintOpacity": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.03, + "description": "Sidebar tint opacity from 0 to 1." + } + } + }, + "automation": { + "title": "automation", + "description": "Socket control and automation settings from Settings > Automation.", + "type": "object", + "additionalProperties": false, + "properties": { + "socketControlMode": { + "type": "string", + "enum": ["off", "cmuxOnly", "automation", "password", "allowAll", "openAccess", "fullOpenAccess", "notifications", "full"], + "default": "cmuxOnly", + "description": "Socket control mode. Legacy aliases are accepted and normalized." + }, + "socketPassword": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "", + "description": "Password for password-mode socket access. Use null or an empty string to clear it." + }, + "claudeCodeIntegration": { + "type": "boolean", + "default": true, + "description": "Enable cmux integration hooks for Claude Code." + }, + "claudeBinaryPath": { + "type": "string", + "default": "", + "description": "Custom path to the claude binary." + }, + "portBase": { + "type": "integer", + "minimum": 1, + "default": 9100, + "description": "Starting value for workspace CMUX_PORT assignments." + }, + "portRange": { + "type": "integer", + "minimum": 1, + "default": 10, + "description": "Number of ports reserved per workspace." + } + } + }, + "customCommands": { + "title": "customCommands", + "description": "Custom command trust settings from Settings > Custom Commands.", + "type": "object", + "additionalProperties": false, + "properties": { + "trustedDirectories": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Directories whose cmux.json commands can run without confirmation." + } + } + }, + "browser": { + "title": "browser", + "description": "Embedded browser settings from Settings > Browser.", + "type": "object", + "additionalProperties": false, + "properties": { + "defaultSearchEngine": { + "type": "string", + "enum": ["google", "duckduckgo", "bing", "kagi", "startpage"], + "default": "google", + "description": "Default search engine for non-URL queries." + }, + "showSearchSuggestions": { + "type": "boolean", + "default": true, + "description": "Show omnibar search suggestions." + }, + "theme": { + "type": "string", + "enum": ["system", "light", "dark"], + "default": "system", + "description": "Embedded browser theme." + }, + "openTerminalLinksInCmuxBrowser": { + "type": "boolean", + "default": true, + "description": "Open clicked terminal links in the embedded browser." + }, + "interceptTerminalOpenCommandInCmuxBrowser": { + "type": "boolean", + "default": true, + "description": "Intercept terminal open http(s) commands and route them through the embedded browser." + }, + "hostsToOpenInEmbeddedBrowser": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Allowlist of hosts that should stay inside the embedded browser." + }, + "urlsToAlwaysOpenExternally": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Rules that always open matching URLs in the system browser." + }, + "insecureHttpHostsAllowedInEmbeddedBrowser": { + "type": "array", + "items": { + "type": "string" + }, + "default": ["localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"], + "description": "HTTP hosts allowed in the embedded browser without a warning prompt." + }, + "showImportHintOnBlankTabs": { + "type": "boolean", + "default": true, + "description": "Show the browser import hint on blank tabs." + }, + "reactGrabVersion": { + "type": "string", + "default": "0.1.29", + "description": "Pinned react-grab version for the browser toolbar helper." + } + } + }, + "shortcuts": { + "title": "shortcuts", + "description": "Keyboard shortcut settings from Settings > Keyboard Shortcuts.", + "type": "object", + "additionalProperties": false, + "properties": { + "showModifierHoldHints": { + "type": "boolean", + "default": true, + "description": "Show shortcut hint pills while holding Cmd or Ctrl." + }, + "bindings": { + "type": "object", + "default": {}, + "description": "Shortcut overrides keyed by cmux action id. Use a string for a single shortcut or an array for a chord.", + "propertyNames": { + "enum": [ + "openSettings", + "reloadConfiguration", + "newWindow", + "closeWindow", + "toggleFullScreen", + "quit", + "toggleSidebar", + "newTab", + "openFolder", + "goToWorkspace", + "commandPalette", + "sendFeedback", + "showNotifications", + "jumpToUnread", + "triggerFlash", + "nextSurface", + "prevSurface", + "selectSurfaceByNumber", + "nextSidebarTab", + "prevSidebarTab", + "selectWorkspaceByNumber", + "renameTab", + "renameWorkspace", + "closeTab", + "closeOtherTabsInPane", + "closeWorkspace", + "reopenClosedBrowserPanel", + "newSurface", + "toggleTerminalCopyMode", + "focusLeft", + "focusRight", + "focusUp", + "focusDown", + "splitRight", + "splitDown", + "toggleSplitZoom", + "splitBrowserRight", + "splitBrowserDown", + "openBrowser", + "focusBrowserAddressBar", + "browserBack", + "browserForward", + "browserReload", + "browserZoomIn", + "browserZoomOut", + "browserZoomReset", + "find", + "findNext", + "findPrevious", + "hideFind", + "useSelectionForFind", + "toggleBrowserDeveloperTools", + "showBrowserJavaScriptConsole", + "toggleReactGrab" + ] + }, + "additionalProperties": { + "$ref": "#/$defs/shortcutBinding" + } + } + } + } + }, + "$defs": { + "colorHex": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$", + "description": "Uppercase or lowercase 6-digit hex color." + }, + "colorHexOrNull": { + "oneOf": [ + { + "$ref": "#/$defs/colorHex" + }, + { + "type": "null" + } + ] + }, + "shortcutBinding": { + "oneOf": [ + { + "type": "string", + "description": "Single-stroke shortcut, for example cmd+n." + }, + { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": { + "type": "string" + }, + "description": "Chorded shortcut. Example: [\"ctrl+b\", \"c\"]." + } + ] + } + } +} diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts new file mode 100644 index 000000000000..cc5cbb53085b --- /dev/null +++ b/web/data/cmux-shortcuts.ts @@ -0,0 +1,135 @@ +export type LocalizedText = { + en: string; + ja: string; +}; + +export type Shortcut = { + id: string; + combos: string[][]; + description: LocalizedText; + note?: LocalizedText; +}; + +export type ShortcutCategory = { + id: string; + titleKey: string; + blurbKey?: string; + shortcuts: Shortcut[]; +}; + +export const shortcutCategories: ShortcutCategory[] = [ + { + id: "app", + titleKey: "app", + blurbKey: "appBlurb", + shortcuts: [ + { id: "openSettings", combos: [["⌘", ","]], description: { en: "Settings", ja: "設定" } }, + { id: "reloadConfiguration", combos: [["⌘", "⇧", ","]], description: { en: "Reload configuration", ja: "構成を再読み込み" } }, + { id: "commandPalette", combos: [["⌘", "⇧", "P"]], description: { en: "Command palette", ja: "コマンドパレット" } }, + { id: "newWindow", combos: [["⌘", "⇧", "N"]], description: { en: "New window", ja: "新規ウインドウ" } }, + { id: "closeWindow", combos: [["⌃", "⌘", "W"]], description: { en: "Close window", ja: "ウインドウを閉じる" } }, + { id: "toggleFullScreen", combos: [["⌃", "⌘", "F"]], description: { en: "Toggle full screen", ja: "フルスクリーンを切り替え" } }, + { id: "sendFeedback", combos: [["⌥", "⌘", "F"]], description: { en: "Send feedback", ja: "フィードバックを送信" } }, + { id: "quit", combos: [["⌘", "Q"]], description: { en: "Quit cmux", ja: "cmuxを終了" } }, + ], + }, + { + id: "workspaces", + titleKey: "workspaces", + blurbKey: "workspacesBlurb", + shortcuts: [ + { id: "toggleSidebar", combos: [["⌘", "B"]], description: { en: "Toggle sidebar", ja: "サイドバーを切り替え" } }, + { id: "newTab", combos: [["⌘", "N"]], description: { en: "New workspace", ja: "新規ワークスペース" } }, + { id: "openFolder", combos: [["⌘", "O"]], description: { en: "Open folder", ja: "フォルダを開く" } }, + { + id: "goToWorkspace", + combos: [["⌘", "P"]], + description: { en: "Go to workspace", ja: "ワークスペースへ移動" }, + note: { en: "workspace switcher", ja: "ワークスペーススイッチャー" }, + }, + { id: "nextSidebarTab", combos: [["⌃", "⌘", "]"]], description: { en: "Next workspace", ja: "次のワークスペース" } }, + { id: "prevSidebarTab", combos: [["⌃", "⌘", "["]], description: { en: "Previous workspace", ja: "前のワークスペース" } }, + { id: "selectWorkspaceByNumber", combos: [["⌘", "1…9"]], description: { en: "Select workspace 1…9", ja: "ワークスペース1…9を選択" } }, + { id: "renameWorkspace", combos: [["⌘", "⇧", "R"]], description: { en: "Rename workspace", ja: "ワークスペース名を変更" } }, + { id: "closeWorkspace", combos: [["⌘", "⇧", "W"]], description: { en: "Close workspace", ja: "ワークスペースを閉じる" } }, + ], + }, + { + id: "surfaces", + titleKey: "surfaces", + blurbKey: "surfacesBlurb", + shortcuts: [ + { id: "newSurface", combos: [["⌘", "T"]], description: { en: "New surface", ja: "新規サーフェス" } }, + { id: "nextSurface", combos: [["⌘", "⇧", "]"]], description: { en: "Next surface", ja: "次のサーフェス" } }, + { id: "prevSurface", combos: [["⌘", "⇧", "["]], description: { en: "Previous surface", ja: "前のサーフェス" } }, + { id: "selectSurfaceByNumber", combos: [["⌃", "1…9"]], description: { en: "Select surface 1…9", ja: "サーフェス1…9を選択" } }, + { id: "renameTab", combos: [["⌘", "R"]], description: { en: "Rename tab", ja: "タブ名を変更" } }, + { id: "closeTab", combos: [["⌘", "W"]], description: { en: "Close tab", ja: "タブを閉じる" } }, + { id: "closeOtherTabsInPane", combos: [["⌥", "⌘", "T"]], description: { en: "Close other tabs in pane", ja: "ペイン内の他のタブを閉じる" } }, + { id: "reopenClosedBrowserPanel", combos: [["⌘", "⇧", "T"]], description: { en: "Reopen closed browser panel", ja: "閉じたブラウザパネルを再度開く" } }, + { id: "toggleTerminalCopyMode", combos: [["⌘", "⇧", "M"]], description: { en: "Toggle terminal copy mode", ja: "ターミナルコピーモードを切り替え" } }, + ], + }, + { + id: "split-panes", + titleKey: "splitPanes", + shortcuts: [ + { id: "focusLeft", combos: [["⌥", "⌘", "←"]], description: { en: "Focus pane left", ja: "左のペインにフォーカス" } }, + { id: "focusRight", combos: [["⌥", "⌘", "→"]], description: { en: "Focus pane right", ja: "右のペインにフォーカス" } }, + { id: "focusUp", combos: [["⌥", "⌘", "↑"]], description: { en: "Focus pane up", ja: "上のペインにフォーカス" } }, + { id: "focusDown", combos: [["⌥", "⌘", "↓"]], description: { en: "Focus pane down", ja: "下のペインにフォーカス" } }, + { id: "splitRight", combos: [["⌘", "D"]], description: { en: "Split right", ja: "右に分割" } }, + { id: "splitDown", combos: [["⌘", "⇧", "D"]], description: { en: "Split down", ja: "下に分割" } }, + { id: "splitBrowserRight", combos: [["⌥", "⌘", "D"]], description: { en: "Split browser right", ja: "右にブラウザ分割" } }, + { id: "splitBrowserDown", combos: [["⌥", "⌘", "⇧", "D"]], description: { en: "Split browser down", ja: "下にブラウザ分割" } }, + { id: "toggleSplitZoom", combos: [["⌘", "⇧", "↩"]], description: { en: "Toggle pane zoom", ja: "ペインズームを切り替え" } }, + ], + }, + { + id: "browser", + titleKey: "browser", + shortcuts: [ + { id: "openBrowser", combos: [["⌘", "⇧", "L"]], description: { en: "Open browser", ja: "ブラウザを開く" } }, + { id: "focusBrowserAddressBar", combos: [["⌘", "L"]], description: { en: "Focus address bar", ja: "アドレスバーにフォーカス" } }, + { id: "browserBack", combos: [["⌘", "["]], description: { en: "Back", ja: "戻る" } }, + { id: "browserForward", combos: [["⌘", "]"]], description: { en: "Forward", ja: "進む" } }, + { + id: "browserReload", + combos: [["⌘", "R"]], + description: { en: "Reload page", ja: "ページを再読み込み" }, + note: { en: "focused browser", ja: "フォーカス中のブラウザ" }, + }, + { id: "browserZoomIn", combos: [["⌘", "="]], description: { en: "Zoom in", ja: "拡大" } }, + { id: "browserZoomOut", combos: [["⌘", "-"]], description: { en: "Zoom out", ja: "縮小" } }, + { id: "browserZoomReset", combos: [["⌘", "0"]], description: { en: "Actual size", ja: "実寸表示" } }, + { id: "toggleBrowserDeveloperTools", combos: [["⌥", "⌘", "I"]], description: { en: "Toggle browser developer tools", ja: "ブラウザ開発者ツールを切り替え" } }, + { id: "showBrowserJavaScriptConsole", combos: [["⌥", "⌘", "C"]], description: { en: "Show browser JavaScript console", ja: "ブラウザJavaScriptコンソールを表示" } }, + { + id: "toggleReactGrab", + combos: [["⌥", "⌘", "G"]], + description: { en: "Toggle React Grab", ja: "React Grabを切り替え" }, + note: { en: "focused browser", ja: "フォーカス中のブラウザ" }, + }, + ], + }, + { + id: "find", + titleKey: "find", + shortcuts: [ + { id: "find", combos: [["⌘", "F"]], description: { en: "Find", ja: "検索" } }, + { id: "findNext", combos: [["⌘", "G"]], description: { en: "Find next", ja: "次を検索" } }, + { id: "findPrevious", combos: [["⌘", "⇧", "G"]], description: { en: "Find previous", ja: "前を検索" } }, + { id: "hideFind", combos: [["⌘", "⇧", "F"]], description: { en: "Hide find bar", ja: "検索バーを隠す" } }, + { id: "useSelectionForFind", combos: [["⌘", "E"]], description: { en: "Use selection for find", ja: "選択範囲で検索" } }, + ], + }, + { + id: "notifications", + titleKey: "notifications", + shortcuts: [ + { id: "showNotifications", combos: [["⌘", "I"]], description: { en: "Show notifications", ja: "通知を表示" } }, + { id: "jumpToUnread", combos: [["⌘", "⇧", "U"]], description: { en: "Jump to latest unread", ja: "最新の未読へ移動" } }, + { id: "triggerFlash", combos: [["⌘", "⇧", "H"]], description: { en: "Flash focused panel", ja: "フォーカス中のパネルをフラッシュ" } }, + ], + }, +]; diff --git a/web/messages/en.json b/web/messages/en.json index 68921b99322c..191770da7094 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -316,8 +316,8 @@ "configuration": { "title": "Configuration", "metaTitle": "Configuration", - "metaDescription": "Configure cmux via Ghostty config files and ~/.config/cmux/settings.json. Fonts, themes, app-owned shortcuts, automation mode, and browser behavior.", - "intro": "cmux reads terminal configuration from Ghostty config files. App-owned shortcuts can also be managed from a cmux settings file.", + "metaDescription": "Configure cmux via Ghostty config files and ~/.config/cmux/settings.json. Terminal rendering stays in Ghostty config, while cmux app settings, shortcuts, automation, sidebar, notifications, and browser behavior live in settings.json.", + "intro": "cmux reads terminal configuration from Ghostty config files. cmux-owned app settings also live in ~/.config/cmux/settings.json, including shortcuts, automation, sidebar behavior, notifications, and browser preferences.", "configLocations": "Config file locations", "configLocationsDesc": "cmux looks for configuration in these locations (in order):", "createConfig": "Create the config file if it doesn't exist:", diff --git a/web/messages/ja.json b/web/messages/ja.json index 7ff18565294f..f657d916ef99 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -271,8 +271,8 @@ }, "configuration": { "title": "設定", - "metaDescription": "Ghostty設定ファイルと ~/.config/cmux/settings.json によるcmuxの設定。フォント、テーマ、cmux管理ショートカット、自動化モード、ブラウザ動作。", - "intro": "cmuxはターミナル設定をGhosttyの設定ファイルから読み込みます。cmux管理のショートカットは専用の settings.json でも管理できます。", + "metaDescription": "Ghostty設定ファイルと ~/.config/cmux/settings.json によるcmuxの設定。ターミナル描画はGhostty設定、cmux側のアプリ設定、ショートカット、自動化、サイドバー、通知、ブラウザ動作は settings.json で管理します。", + "intro": "cmuxはターミナル設定をGhosttyの設定ファイルから読み込みます。cmux管理のアプリ設定も ~/.config/cmux/settings.json で管理でき、ショートカット、自動化、サイドバー、通知、ブラウザ設定を含みます。", "configLocations": "設定ファイルの場所", "configLocationsDesc": "cmuxは以下の場所から設定を検索します(順番に):", "createConfig": "設定ファイルが存在しない場合は作成してください:", From 05142f0da6adbfd795b138674594594bf45fe64a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:42:35 -0700 Subject: [PATCH 13/89] fix: harden shortcut routing edge cases --- Sources/AppDelegate.swift | 108 ++++++++++++------ Sources/TabManager.swift | 27 ++++- .../AppDelegateShortcutRoutingTests.swift | 6 + 3 files changed, 101 insertions(+), 40 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7f2043b0ea10..4640ab44179c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5451,15 +5451,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { - if let window = event.window, isMainTerminalWindow(window) { + if let window = resolvedShortcutEventWindow(event), + isMainTerminalWindow(window) { return window } - let eventWindowNumber = event.windowNumber - if eventWindowNumber > 0, - let numberedWindow = NSApp.window(withWindowNumber: eventWindowNumber), - isMainTerminalWindow(numberedWindow) { - return numberedWindow - } if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { return keyWindow } @@ -5469,6 +5464,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } + private func resolvedShortcutEventWindow(_ event: NSEvent) -> NSWindow? { + if let window = event.window { + return window + } + let eventWindowNumber = event.windowNumber + guard eventWindowNumber > 0 else { return nil } + return NSApp.window(withWindowNumber: eventWindowNumber) + } + /// Re-sync app-level active window pointers from the currently focused main terminal window. /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. @discardableResult @@ -6100,16 +6104,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if shortcutEventHasAddressableWindow(event) { + if let eventWindow = resolvedShortcutEventWindow(event), + cmuxWindowShouldOwnCloseShortcut(eventWindow) { + // Auxiliary cmux windows do not own a terminal tab manager. Let them fall back + // to the active main terminal window so app shortcuts like Cmd+W still route. + } else { #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: "shortcut.routing", - reason: "event_context_required_no_fallback", - event: event, - chosenContext: nil - ) + logWorkspaceCreationRouting( + phase: "choose", + source: "shortcut.routing", + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) #endif - return nil + return nil + } } if let keyWindow = NSApp.keyWindow, @@ -9938,10 +9948,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Cmd+W must close the focused panel even if first-responder momentarily lags on a // browser NSTextView during split focus transitions. if matchConfiguredShortcut(event: event, action: .closeTab) { + let targetWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. // This AppDelegate path is a fallback for cases where AppKit routes the // event through the global shortcut handler first. - if let targetWindow = [NSApp.keyWindow, event.window] + if let targetWindow = [targetWindow, NSApp.keyWindow] .compactMap({ $0 }) .first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) { #if DEBUG @@ -9949,27 +9961,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif targetWindow.performClose(nil) return true - } else if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + } else if let targetWindow, cmuxWindowShouldOwnCloseShortcut(targetWindow) { targetWindow.performClose(nil) } else { - let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { + if let routedManager { #if DEBUG + let selectedWorkspace = routedManager.selectedWorkspace dlog( - "shortcut.cmdW route=ghostty workspace=\(terminalContext.workspaceId.uuidString.prefix(5)) " + - "panel=\(terminalContext.panelId.uuidString.prefix(5)) selected=\(terminalContext.tabManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + "shortcut.cmdW route=workspaceModel workspace=\(selectedWorkspace?.id.uuidString.prefix(5) ?? "nil") " + + "panel=\(selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil") " + + "selected=\(routedManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" ) #endif - terminalContext.tabManager.closePanelWithConfirmation( - tabId: terminalContext.workspaceId, - surfaceId: terminalContext.panelId - ) + routedManager.closeCurrentPanelWithConfirmation() } else { #if DEBUG - dlog("shortcut.cmdW route=focusedPanelFallback") + dlog("shortcut.cmdW route=noManager") #endif - tabManager?.closeCurrentPanelWithConfirmation() + return false } } return true @@ -10167,17 +10177,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .browserBack) { - tabManager?.focusedBrowserPanel?.goBack() + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.goBack() return true } if matchConfiguredShortcut(event: event, action: .browserForward) { - tabManager?.focusedBrowserPanel?.goForward() + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.goForward() return true } if matchConfiguredShortcut(event: event, action: .browserReload) { - tabManager?.focusedBrowserPanel?.reload() + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.reload() return true } @@ -11091,6 +11110,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // a Latin shortcut key — skip this guard and fall through to layout-based matching. let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true + let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true + if shortcutKeyIsDigit, + hasEventChars, + eventCharsAreASCII, + digitForNumberKeyCode(event.keyCode) == nil { + return false + } if hasEventChars, eventCharsAreASCII, flags.contains(.command), @@ -11145,6 +11171,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) guard flags == stroke.modifierFlags else { return nil } + let numberKeyDigit = digitForNumberKeyCode(event.keyCode) if let digit = numberedShortcutDigit( eventCharacter: event.charactersIgnoringModifiers, @@ -11154,16 +11181,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return digit } - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if let digit = numberedShortcutDigit( - eventCharacter: layoutCharacter, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { - return digit + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + let hasUsableASCIIEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + && (eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true) + if !hasUsableASCIIEventChars || numberKeyDigit != nil { + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if let digit = numberedShortcutDigit( + eventCharacter: layoutCharacter, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return digit + } } - return digitForNumberKeyCode(event.keyCode) + return numberKeyDigit } private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 4e69d49e3aae..29212ff1a948 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2690,8 +2690,9 @@ class TabManager: ObservableObject { UITestRecorder.incrementInt("closePanelInvocations") #endif guard let selectedId = selectedTabId, - let tab = tabs.first(where: { $0.id == selectedId }), - let focusedPanelId = tab.focusedPanelId else { return } + let tab = tabs.first(where: { $0.id == selectedId }) else { return } + reconcileFocusedPanelFromFirstResponderForKeyboard() + guard let focusedPanelId = shortcutCloseTargetPanelId(in: tab) else { return } closePanelWithConfirmation(tab: tab, panelId: focusedPanelId) } @@ -3008,6 +3009,28 @@ class TabManager: ObservableObject { #endif } + private func shortcutCloseTargetPanelId(in workspace: Workspace) -> UUID? { + if let focusedPanelId = workspace.focusedPanelId, + workspace.panels[focusedPanelId] != nil { + return focusedPanelId + } + + if workspace.panels.count == 1 { + return workspace.panels.keys.first + } + + let candidatePane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first + if let candidatePane, + let selectedTabId = workspace.bonsplitController.selectedTab(inPane: candidatePane)?.id + ?? workspace.bonsplitController.tabs(inPane: candidatePane).first?.id, + let panelId = workspace.panelIdFromSurfaceId(selectedTabId), + workspace.panels[panelId] != nil { + return panelId + } + + return nil + } + func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } closePanelWithConfirmation(tab: tab, panelId: surfaceId) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 4c4cfb4217d1..6d33c71e00b4 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -1383,6 +1383,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } + let defaults = UserDefaults.standard let originalSetting = defaults.object(forKey: appDelegateLastSurfaceCloseShortcutDefaultsKey) defaults.set(false, forKey: appDelegateLastSurfaceCloseShortcutDefaultsKey) @@ -1405,6 +1407,10 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + // This test exercises keep-workspace-open semantics, not close-confirm heuristics. + // Mark the shell idle so Cmd+W routes through the immediate close path deterministically. + workspace.updatePanelShellActivityState(panelId: initialPanelId, state: .promptIdle) + guard let event = makeKeyDownEvent( key: "w", modifiers: [.command], From 39cd0f69314e6654ae017a2df8e5ec9f6613abbd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:53:43 -0700 Subject: [PATCH 14/89] fix: preserve palette and browser shortcut routing --- Sources/AppDelegate.swift | 66 ++++++++- Sources/ContentView.swift | 2 +- .../AppDelegateShortcutRoutingTests.swift | 140 ++++++++++++++++++ .../ShortcutAndCommandPaletteTests.swift | 20 +++ 4 files changed, 225 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4640ab44179c..cc792fa96bf6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2199,6 +2199,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? + private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? @@ -9188,16 +9189,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func installShortcutDefaultsObserver() { guard shortcutDefaultsObserver == nil else { return } + refreshConfiguredShortcutChordActions() shortcutDefaultsObserver = NotificationCenter.default.addObserver( forName: KeyboardShortcutSettings.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in + self?.refreshConfiguredShortcutChordActions() self?.clearConfiguredShortcutChordState() self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() } } + private func refreshConfiguredShortcutChordActions() { + configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { + KeyboardShortcutSettings.shortcut(for: $0).hasChord + } + } + private func clearConfiguredShortcutChordState() { pendingConfiguredShortcutChord = nil activeConfiguredShortcutChordPrefixForCurrentEvent = nil @@ -9661,6 +9670,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // focused omnibar in another window does not suppress Cmd+P here. let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil + if commandPaletteEffectiveInTargetWindow { + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true + } + + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + !hasFocusedAddressBarInShortcutContext, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { + return true + } + } + if shouldConsumeShortcutWhileCommandPaletteVisible( isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, normalizedFlags: normalizedFlags, @@ -10255,21 +10290,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .find) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } tabManager?.startSearch() return true } if matchConfiguredShortcut(event: event, action: .findNext) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } tabManager?.findNext() return true } if matchConfiguredShortcut(event: event, action: .findPrevious) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } tabManager?.findPrevious() return true } if matchConfiguredShortcut(event: event, action: .hideFind) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } tabManager?.hideFind() return true } @@ -11064,8 +11111,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return event.windowNumber > 0 ? event.windowNumber : nil } - private func armConfiguredShortcutChordIfNeeded(event: NSEvent) -> Bool { - for action in KeyboardShortcutSettings.Action.allCases { + private func armConfiguredShortcutChordIfNeeded( + event: NSEvent, + actions: [KeyboardShortcutSettings.Action]? = nil + ) -> Bool { + for action in actions ?? configuredShortcutChordActions { let shortcut = KeyboardShortcutSettings.shortcut(for: action) guard shortcut.hasChord else { continue } if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { @@ -11659,6 +11709,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserPanelOwning(webView)?.searchState != nil } + private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { + let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutResponder = shortcutWindow?.firstResponder + let owningWebView = tabManager?.focusedBrowserPanel?.webView as? CmuxWebView + guard let owningWebView else { return false } + return shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + event, + responder: shortcutResponder, + owningWebView: owningWebView + ) + } + private func browserPanelOwning(_ webView: CmuxWebView) -> BrowserPanel? { var candidateManagers: [TabManager] = [] var seenManagers = Set<ObjectIdentifier>() diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0a9b1ae252b0..3f1044b77f6b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8918,7 +8918,7 @@ enum ShortcutHintModifierPolicy { guard !shortcut.hasChord else { return false } let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) - guard normalized == shortcut.modifierFlags else { + guard normalized == [.command] else { return false } return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 6d33c71e00b4..8baefd85281f 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -2158,6 +2158,146 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(observedPaletteWindow?.windowNumber, window.windowNumber) } + func testCmdPStillRequestsCommandPaletteSwitcherWhilePaletteIsVisible() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { appDelegate.setCommandPaletteVisible(false, for: window) } + + let switcherExpectation = expectation(description: "Expected switcher request while command palette is visible") + var observedSwitcherWindow: NSWindow? + let switcherToken = NotificationCenter.default.addObserver( + forName: .commandPaletteSwitcherRequested, + object: nil, + queue: nil + ) { notification in + observedSwitcherWindow = notification.object as? NSWindow + switcherExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(switcherToken) } + + guard let event = makeKeyDownEvent( + key: "p", + modifiers: [.command], + keyCode: 35, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+P event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [switcherExpectation], timeout: 1.0) + XCTAssertEqual(observedSwitcherWindow?.windowNumber, window.windowNumber) + } + + func testCmdShiftPStillRequestsCommandPaletteCommandsWhilePaletteIsVisible() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + appDelegate.setCommandPaletteVisible(true, for: window) + defer { appDelegate.setCommandPaletteVisible(false, for: window) } + + let paletteExpectation = expectation(description: "Expected commands request while command palette is visible") + var observedPaletteWindow: NSWindow? + let paletteToken = NotificationCenter.default.addObserver( + forName: .commandPaletteRequested, + object: nil, + queue: nil + ) { notification in + observedPaletteWindow = notification.object as? NSWindow + paletteExpectation.fulfill() + } + defer { NotificationCenter.default.removeObserver(paletteToken) } + + guard let event = makeKeyDownEvent( + key: "P", + modifiers: [.command, .shift], + keyCode: 35, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+Shift+P event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + wait(for: [paletteExpectation], timeout: 1.0) + XCTAssertEqual(observedPaletteWindow?.windowNumber, window.windowNumber) + } + + func testCmdFFocusedBrowserKeepsWebContentFirstRouting() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + manager.openBrowser(inWorkspace: workspace.id) != nil else { + XCTFail("Expected focused browser panel") + return + } + + XCTAssertNotNil(manager.focusedBrowserPanel) + XCTAssertNil(manager.focusedBrowserPanel?.searchState) + + guard let event = makeKeyDownEvent( + key: "f", + modifiers: [.command], + keyCode: 3, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+F event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: event), + "Cmd+F should fall through so browser web content gets first chance" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + + XCTAssertNil(manager.focusedBrowserPanel?.searchState) + } + func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 6fbdf8638aea..aaa2bb6ac247 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -596,6 +596,26 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { } } + func testShortcutHintStaysCommandOnlyWhenWorkspaceShortcutIsCustomized() { + let action = KeyboardShortcutSettings.Action.selectWorkspaceByNumber + let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) + defer { + KeyboardShortcutSettings.setShortcut(originalShortcut, for: action) + } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "1", command: false, shift: false, option: false, control: true), + for: action + ) + + withDefaultsSuite { defaults in + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + } + } + func testShortcutHintUsesIntentionalHoldDelay() { XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001) } From a4bf022e1a07fdb480d2d7c045e72b34422d4ceb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:00:08 -0700 Subject: [PATCH 15/89] chore: retrigger missing PR workflows From 1134d5dca065b125dcb73a56d097a93b0f11ee47 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:18:31 -0700 Subject: [PATCH 16/89] feat: add settings.json entry points --- Resources/Localizable.xcstrings | 51 ++++++++++++++ .../KeyboardShortcutSettingsFileStore.swift | 20 ++++++ Sources/cmuxApp.swift | 37 ++++++++-- cmuxTests/WorkspaceUnitTests.swift | 67 +++++++++++++++++++ 4 files changed, 168 insertions(+), 7 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index bb215c5b4afc..34dfd3c2c257 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -37177,6 +37177,23 @@ } } }, + "menu.app.openCmuxSettingsFile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open cmux settings.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux settings.jsonを開く" + } + } + } + }, "menu.checkForUpdates": { "extractionState": "manual", "localizations": { @@ -47850,6 +47867,40 @@ } } }, + "settings.app.settingsFile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings File" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定ファイル" + } + } + } + }, + "settings.app.settingsFile.openButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open settings.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.jsonを開く" + } + } + } + }, "settings.app.commandPaletteSearchAllSurfaces": { "extractionState": "manual", "localizations": { diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 7fbab4a38e8b..abd1e4cf9f0f 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -150,6 +150,26 @@ final class CmuxSettingsFileStore { synchronized { shortcutsByAction[action] != nil } } + func settingsFileURLForEditing() -> URL { + if let activeSourcePath = synchronized({ activeSourcePath }) { + return URL(fileURLWithPath: activeSourcePath) + } + + bootstrapPrimaryTemplateIfNeeded() + reload() + + if let activeSourcePath = synchronized({ activeSourcePath }) { + return URL(fileURLWithPath: activeSourcePath) + } + + return URL(fileURLWithPath: primaryPath) + } + + func settingsFileDisplayPath() -> String { + let path = synchronized { activeSourcePath } ?? primaryPath + return (path as NSString).abbreviatingWithTildeInPath + } + private func bootstrapPrimaryTemplateIfNeeded() { guard !fileManager.fileExists(atPath: primaryPath) else { return } if let fallbackPath, fileManager.fileExists(atPath: fallbackPath) { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index b4f4dfe37290..5ec0a2f2e872 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -351,19 +351,21 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) { appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } + splitCommandButton(title: String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration"), shortcut: menuShortcut(for: .reloadConfiguration)) { + GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration") + } + Button(String(localized: "menu.app.openCmuxSettingsFile", defaultValue: "Open cmux settings.json")) { + openCmuxSettingsFileInEditor() + } + Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) { + GhosttyApp.shared.openConfigurationInTextEdit() + } } CommandGroup(replacing: .appInfo) { Button(String(localized: "menu.app.about", defaultValue: "About cmux")) { showAboutPanel() } - Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) { - GhosttyApp.shared.openConfigurationInTextEdit() - } - splitCommandButton(title: String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration"), shortcut: menuShortcut(for: .reloadConfiguration)) { - GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration") - } - Divider() Button(String(localized: "menu.app.checkForUpdates", defaultValue: "Check for Updates…")) { appDelegate.checkForUpdates(nil) } @@ -3863,6 +3865,11 @@ enum PreferredEditorSettings { } } +private func openCmuxSettingsFileInEditor() { + let url = KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing() + PreferredEditorSettings.open(url) +} + struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 @@ -3947,6 +3954,7 @@ struct SettingsView: View { @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false @ObservedObject private var notificationStore = TerminalNotificationStore.shared + @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -4441,6 +4449,7 @@ struct SettingsView: View { } var body: some View { + let _ = keyboardShortcutSettingsObserver.revision ScrollViewReader { proxy in ZStack(alignment: .top) { ScrollView { @@ -4564,6 +4573,20 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.settingsFile", defaultValue: "Settings File"), + subtitle: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath() + ) { + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { + openCmuxSettingsFileInEditor() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsFileOpenButton") + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 5f777b7ad87b..108bea10e2b4 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -632,6 +632,73 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertEqual(store.activeSourcePath, fallbackURL.path) } + func testSettingsFileURLForEditingUsesActiveFallbackWithoutCreatingPrimary() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let primaryURL = directoryURL.appendingPathComponent("primary/settings.json", isDirectory: false) + let fallbackURL = directoryURL.appendingPathComponent("fallback/settings.json", isDirectory: false) + try FileManager.default.createDirectory( + at: fallbackURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try writeSettingsFile( + """ + { + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: fallbackURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + + XCTAssertEqual(store.settingsFileURLForEditing().path, fallbackURL.path) + XCTAssertFalse(FileManager.default.fileExists(atPath: primaryURL.path)) + } + + func testSettingsFileURLForEditingPrefersInvalidPrimaryForRepair() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let primaryURL = directoryURL.appendingPathComponent("primary/settings.json", isDirectory: false) + let fallbackURL = directoryURL.appendingPathComponent("fallback/settings.json", isDirectory: false) + try FileManager.default.createDirectory( + at: primaryURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: fallbackURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try writeSettingsFile("{ not valid json", to: primaryURL) + try writeSettingsFile( + """ + { + "shortcuts": { + "showNotifications": "cmd+i" + } + } + """, + to: fallbackURL + ) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + + XCTAssertEqual(store.settingsFileURLForEditing().path, primaryURL.path) + XCTAssertEqual(store.activeSourcePath, primaryURL.path) + } + func testSettingsFileStoreParsesJSONCCommentsAndTrailingCommas() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } From 4bb4ef97b6377d6b7c75554bfaa48603b9b8d439 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:43:09 -0700 Subject: [PATCH 17/89] feat: make workspace color palette a named dictionary --- Resources/Localizable.xcstrings | 68 ++++++ Sources/ContentView.swift | 2 +- .../KeyboardShortcutSettingsFileStore.swift | 91 ++++++- Sources/TabManager.swift | 228 +++++++++++------ Sources/TerminalController.swift | 4 +- Sources/cmuxApp.swift | 121 +++++---- cmuxTests/WorkspaceUnitTests.swift | 229 +++++++++++++++--- web/app/[locale]/docs/configuration/page.tsx | 43 +++- web/data/cmux-settings.schema.json | 32 ++- 9 files changed, 618 insertions(+), 200 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 34dfd3c2c257..dd7be67aae06 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -64550,6 +64550,57 @@ } } }, + "settings.workspaceColors.customEntry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Named palette entry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前付きのパレット項目です。" + } + } + } + }, + "settings.workspaceColors.dictionaryNote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前付きカラーの追加や削除は settings.json を編集してください。「カスタムカラーを選択…」は引き続きローカルの Custom N エントリを追加します。" + } + } + } + }, + "settings.workspaceColors.emptyPalette": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パレット項目はありません。settings.json でカラーを追加するか、ワークスペースのコンテキストメニューから「カスタムカラーを選択…」を使ってください。" + } + } + } + }, "settings.workspaceColors.indicator": { "extractionState": "manual", "localizations": { @@ -65383,6 +65434,23 @@ } } }, + "settings.workspaceColors.resetPalette.subtitleV2": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore the built-in palette and remove extra named colors." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "組み込みパレットを復元し、追加した名前付きカラーを削除します。" + } + } + } + }, "shortcut.closeWindow.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3f1044b77f6b..a85282abd748 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12633,7 +12633,7 @@ private struct TabItemView: View, Equatable { alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") - let seed = tab.customColor ?? WorkspaceTabColorSettings.customColors().first ?? "" + let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" let input = NSTextField(string: seed) input.placeholderString = "#1565C0" input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index abd1e4cf9f0f..401aaa9a2c90 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -490,9 +490,36 @@ final class CmuxSettingsFileStore { ) else { return } snapshot.managedUserDefaults["sidebarNotificationBadgeColorHex"] = .nullableString(value) } + if section.keys.contains("colors") { + guard let rawColors = section["colors"] as? [String: Any] else { + logInvalid("workspaceColors.colors", sourcePath: sourcePath) + return + } + + var normalizedPalette: [String: String] = [:] + for (rawName, rawValue) in rawColors { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + NSLog("[CmuxSettingsFileStore] ignoring empty workspace color name in %@", sourcePath) + continue + } + guard let hex = jsonString(rawValue), + let normalizedHex = WorkspaceTabColorSettings.normalizedHex(hex) else { + NSLog("[CmuxSettingsFileStore] ignoring invalid workspace color '%@' in %@", name, sourcePath) + continue + } + normalizedPalette[name] = normalizedHex + } + snapshot.managedUserDefaults[WorkspaceTabColorSettings.paletteKey] = .stringDictionary(normalizedPalette) + return + } + + let validNames = Set(WorkspaceTabColorSettings.defaultPalette.map(\.name)) + var normalizedLegacyPalette: [String: String]? = nil if let rawOverrides = section["paletteOverrides"] as? [String: Any] { - var normalized: [String: String] = [:] - let validNames = Set(WorkspaceTabColorSettings.defaultPalette.map(\.name)) + var palette = Dictionary( + uniqueKeysWithValues: WorkspaceTabColorSettings.defaultPalette.map { ($0.name, $0.hex) } + ) for (name, rawValue) in rawOverrides { guard validNames.contains(name) else { NSLog("[CmuxSettingsFileStore] ignoring unknown workspace color '%@' in %@", name, sourcePath) @@ -503,13 +530,31 @@ final class CmuxSettingsFileStore { NSLog("[CmuxSettingsFileStore] ignoring invalid workspace color override '%@' in %@", name, sourcePath) continue } - normalized[name] = normalizedHex + palette[name] = normalizedHex } - snapshot.managedUserDefaults[WorkspaceTabColorSettings.defaultOverridesKey] = .stringDictionary(normalized) + normalizedLegacyPalette = palette } - if let rawColors = jsonStringArray(section["customColors"]) { - let normalized = rawColors.compactMap(WorkspaceTabColorSettings.normalizedHex(_:)) - snapshot.managedUserDefaults[WorkspaceTabColorSettings.customColorsKey] = .stringArray(normalized) + if let rawCustomColors = jsonStringArray(section["customColors"]) { + var palette = normalizedLegacyPalette ?? Dictionary( + uniqueKeysWithValues: WorkspaceTabColorSettings.defaultPalette.map { ($0.name, $0.hex) } + ) + var existingNames = Set(palette.keys) + var seenCustomHexes: Set<String> = [] + for rawHex in rawCustomColors { + guard let normalizedHex = WorkspaceTabColorSettings.normalizedHex(rawHex), + seenCustomHexes.insert(normalizedHex).inserted else { continue } + var index = 1 + while existingNames.contains("Custom \(index)") { + index += 1 + } + let name = "Custom \(index)" + palette[name] = normalizedHex + existingNames.insert(name) + } + normalizedLegacyPalette = palette + } + if let normalizedLegacyPalette { + snapshot.managedUserDefaults[WorkspaceTabColorSettings.paletteKey] = .stringDictionary(normalizedLegacyPalette) } } @@ -959,6 +1004,12 @@ final class CmuxSettingsFileStore { guard let value = defaults.array(forKey: defaultsKey) as? [String] else { return .absent } return .stringArray(value) case .stringDictionary: + if defaultsKey == WorkspaceTabColorSettings.paletteKey { + guard let value = WorkspaceTabColorSettings.backupPaletteMap(defaults: defaults) else { + return .absent + } + return .stringDictionary(value) + } guard let value = defaults.dictionary(forKey: defaultsKey) as? [String: String] else { return .absent } @@ -975,6 +1026,15 @@ final class CmuxSettingsFileStore { private func applyManagedUserDefaultsValue(_ value: ManagedSettingsValue, for defaultsKey: String) { let defaults = UserDefaults.standard + if defaultsKey == WorkspaceTabColorSettings.paletteKey, + case .stringDictionary(let next) = value { + let current = WorkspaceTabColorSettings.resolvedPaletteMap(defaults: defaults) + if current != next { + WorkspaceTabColorSettings.persistPaletteMap(next, defaults: defaults) + } + return + } + switch value { case .bool(let next): let current = defaults.object(forKey: defaultsKey) as? Bool @@ -1030,6 +1090,18 @@ final class CmuxSettingsFileStore { private func restoreUserDefaultsBackup(_ backup: BackupValue, for defaultsKey: String) { let defaults = UserDefaults.standard + if defaultsKey == WorkspaceTabColorSettings.paletteKey { + switch backup { + case .absent: + WorkspaceTabColorSettings.reset(defaults: defaults) + case .stringDictionary(let value): + WorkspaceTabColorSettings.persistPaletteMap(value, defaults: defaults) + default: + break + } + return + } + switch backup { case .absent: defaults.removeObject(forKey: defaultsKey) @@ -1218,8 +1290,9 @@ final class CmuxSettingsFileStore { "indicatorStyle": SidebarActiveTabIndicatorSettings.defaultStyle.rawValue, "selectionColor": NSNull(), "notificationBadgeColor": NSNull(), - "paletteOverrides": [:], - "customColors": [String](), + "colors": Dictionary( + uniqueKeysWithValues: WorkspaceTabColorSettings.defaultPalette.map { ($0.name, $0.hex) } + ), ], ], [ diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 29212ff1a948..3f0d95bc5836 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -239,13 +239,14 @@ struct WorkspaceTabColorEntry: Equatable, Identifiable { let name: String let hex: String - var id: String { "\(name)-\(hex)" } + var id: String { name } } enum WorkspaceTabColorSettings { - static let defaultOverridesKey = "workspaceTabColor.defaultOverrides" - static let customColorsKey = "workspaceTabColor.customColors" - static let maxCustomColors = 24 + static let paletteKey = "workspaceTabColor.colors" + + private static let legacyDefaultOverridesKey = "workspaceTabColor.defaultOverrides" + private static let legacyCustomColorsKey = "workspaceTabColor.customColors" private static let originalPRPalette: [WorkspaceTabColorEntry] = [ WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"), @@ -271,93 +272,88 @@ enum WorkspaceTabColorSettings { } static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { - defaultPaletteWithOverrides(defaults: defaults) + customColorEntries(defaults: defaults) + let paletteMap = effectivePaletteMap(defaults: defaults) + let builtInOrder = defaultPalette.compactMap { entry -> WorkspaceTabColorEntry? in + guard let hex = paletteMap[entry.name] else { return nil } + return WorkspaceTabColorEntry(name: entry.name, hex: hex) + } + let builtInNames = Set(defaultPalette.map(\.name)) + let customEntries = paletteMap + .filter { !builtInNames.contains($0.key) } + .sorted { lhs, rhs in + lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending + } + .map { WorkspaceTabColorEntry(name: $0.key, hex: $0.value) } + return builtInOrder + customEntries } - static func defaultPaletteWithOverrides(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { - let palette = defaultPalette - let overrides = defaultOverrideMap(defaults: defaults) - return palette.map { entry in - WorkspaceTabColorEntry(name: entry.name, hex: overrides[entry.name] ?? entry.hex) - } + static func customPaletteEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + let builtInNames = Set(defaultPalette.map(\.name)) + return palette(defaults: defaults).filter { !builtInNames.contains($0.name) } } - static func defaultColorHex(named name: String, defaults: UserDefaults = .standard) -> String { - let palette = defaultPalette - guard let entry = palette.first(where: { $0.name == name }) else { - return palette.first?.hex ?? "#1565C0" - } - return defaultOverrideMap(defaults: defaults)[name] ?? entry.hex + static func defaultColorHex(named name: String) -> String? { + defaultPalette.first(where: { $0.name == name })?.hex } - static func setDefaultColor(named name: String, hex: String, defaults: UserDefaults = .standard) { - let palette = defaultPalette - guard let entry = palette.first(where: { $0.name == name }), - let normalized = normalizedHex(hex) else { return } + static func currentColorHex(named name: String, defaults: UserDefaults = .standard) -> String? { + effectivePaletteMap(defaults: defaults)[name] + } - var overrides = defaultOverrideMap(defaults: defaults) - if normalized == entry.hex { - overrides.removeValue(forKey: name) - } else { - overrides[name] = normalized - } - saveDefaultOverrideMap(overrides, defaults: defaults) + static func setColor(named name: String, hex: String, defaults: UserDefaults = .standard) { + guard let normalizedName = normalizedColorName(name), + let normalizedHex = normalizedHex(hex) else { return } + + var palette = editablePaletteMap(defaults: defaults) + palette[normalizedName] = normalizedHex + persistPaletteMap(palette, defaults: defaults) } - static func customColors(defaults: UserDefaults = .standard) -> [String] { - guard let raw = defaults.array(forKey: customColorsKey) as? [String] else { return [] } - var result: [String] = [] - var seen: Set<String> = [] - for value in raw { - guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } - result.append(normalized) - if result.count >= maxCustomColors { break } - } - return result + static func removeColor(named name: String, defaults: UserDefaults = .standard) { + guard let normalizedName = normalizedColorName(name) else { return } + var palette = editablePaletteMap(defaults: defaults) + palette.removeValue(forKey: normalizedName) + persistPaletteMap(palette, defaults: defaults) } - static func customColorEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { - customColors(defaults: defaults).enumerated().map { index, hex in - WorkspaceTabColorEntry(name: "Custom \(index + 1)", hex: hex) + static func persistPaletteMap(_ rawPalette: [String: String], defaults: UserDefaults = .standard) { + let normalizedPalette = normalizedPaletteMap(rawPalette) + if normalizedPalette == defaultPaletteMap { + defaults.removeObject(forKey: paletteKey) + } else { + defaults.set(normalizedPalette, forKey: paletteKey) } + defaults.removeObject(forKey: legacyDefaultOverridesKey) + defaults.removeObject(forKey: legacyCustomColorsKey) } - @discardableResult - static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? { - guard let normalized = normalizedHex(hex) else { return nil } - var colors = customColors(defaults: defaults) - colors.removeAll { $0 == normalized } - colors.insert(normalized, at: 0) - setCustomColors(colors, defaults: defaults) - return normalized + static func backupPaletteMap(defaults: UserDefaults = .standard) -> [String: String]? { + if let stored = storedPaletteMap(defaults: defaults) { + return stored + } + return legacyPaletteMap(defaults: defaults) } - static func removeCustomColor(_ hex: String, defaults: UserDefaults = .standard) { - guard let normalized = normalizedHex(hex) else { return } - var colors = customColors(defaults: defaults) - colors.removeAll { $0 == normalized } - setCustomColors(colors, defaults: defaults) + static func resolvedPaletteMap(defaults: UserDefaults = .standard) -> [String: String] { + effectivePaletteMap(defaults: defaults) } - static func setCustomColors(_ hexes: [String], defaults: UserDefaults = .standard) { - var normalizedColors: [String] = [] - var seen: Set<String> = [] - for value in hexes { - guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } - normalizedColors.append(normalized) - if normalizedColors.count >= maxCustomColors { break } + static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? { + guard let normalized = normalizedHex(hex) else { return nil } + var palette = editablePaletteMap(defaults: defaults) + if palette.contains(where: { $0.value == normalized }) { + return normalized } - if normalizedColors.isEmpty { - defaults.removeObject(forKey: customColorsKey) - } else { - defaults.set(normalizedColors, forKey: customColorsKey) - } + palette[nextCustomColorName(existingNames: Set(palette.keys))] = normalized + persistPaletteMap(palette, defaults: defaults) + return normalized } static func reset(defaults: UserDefaults = .standard) { - defaults.removeObject(forKey: defaultOverridesKey) - defaults.removeObject(forKey: customColorsKey) + defaults.removeObject(forKey: paletteKey) + defaults.removeObject(forKey: legacyDefaultOverridesKey) + defaults.removeObject(forKey: legacyCustomColorsKey) } static func normalizedHex(_ raw: String) -> String? { @@ -396,23 +392,95 @@ enum WorkspaceTabColorSettings { return baseColor } - private static func defaultOverrideMap(defaults: UserDefaults) -> [String: String] { - guard let raw = defaults.dictionary(forKey: defaultOverridesKey) as? [String: String] else { return [:] } - let validNames = Set(defaultPalette.map(\.name)) + private static func effectivePaletteMap(defaults: UserDefaults) -> [String: String] { + if let stored = storedPaletteMap(defaults: defaults) { + return stored + } + if let legacy = legacyPaletteMap(defaults: defaults) { + return legacy + } + return defaultPaletteMap + } + + private static func editablePaletteMap(defaults: UserDefaults) -> [String: String] { + if let stored = storedPaletteMap(defaults: defaults) { + return stored + } + if let legacy = legacyPaletteMap(defaults: defaults) { + return legacy + } + return defaultPaletteMap + } + + private static func storedPaletteMap(defaults: UserDefaults) -> [String: String]? { + guard let raw = defaults.dictionary(forKey: paletteKey) as? [String: String] else { return nil } + return normalizedPaletteMap(raw) + } + + private static func legacyPaletteMap(defaults: UserDefaults) -> [String: String]? { + let hasLegacyOverrides = defaults.object(forKey: legacyDefaultOverridesKey) != nil + let hasLegacyCustomColors = defaults.object(forKey: legacyCustomColorsKey) != nil + guard hasLegacyOverrides || hasLegacyCustomColors else { return nil } + + var palette = defaultPaletteMap + + if let rawOverrides = defaults.dictionary(forKey: legacyDefaultOverridesKey) as? [String: String] { + let validNames = Set(defaultPalette.map(\.name)) + for (name, hex) in rawOverrides { + guard validNames.contains(name), + let normalized = normalizedHex(hex) else { continue } + palette[name] = normalized + } + } + + if let rawCustomColors = defaults.array(forKey: legacyCustomColorsKey) as? [String] { + var index = 1 + var seenCustomHexes: Set<String> = [] + for rawHex in rawCustomColors { + guard let normalized = normalizedHex(rawHex), + seenCustomHexes.insert(normalized).inserted else { continue } + let name = nextCustomColorName( + existingNames: Set(palette.keys), + startingAt: index + ) + palette[name] = normalized + index += 1 + } + } + + return palette + } + + private static func normalizedPaletteMap(_ rawPalette: [String: String]) -> [String: String] { var normalized: [String: String] = [:] - for (name, hex) in raw { - guard validNames.contains(name), - let normalizedHex = normalizedHex(hex) else { continue } - normalized[name] = normalizedHex + for (rawName, rawHex) in rawPalette { + guard let name = normalizedColorName(rawName), + let hex = normalizedHex(rawHex) else { continue } + normalized[name] = hex } return normalized } - private static func saveDefaultOverrideMap(_ map: [String: String], defaults: UserDefaults) { - if map.isEmpty { - defaults.removeObject(forKey: defaultOverridesKey) - } else { - defaults.set(map, forKey: defaultOverridesKey) + private static var defaultPaletteMap: [String: String] { + Dictionary(uniqueKeysWithValues: defaultPalette.map { ($0.name, $0.hex) }) + } + + private static func normalizedColorName(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func nextCustomColorName( + existingNames: Set<String>, + startingAt initialIndex: Int = 1 + ) -> String { + var index = max(1, initialIndex) + while true { + let candidate = "Custom \(index)" + if !existingNames.contains(where: { $0.caseInsensitiveCompare(candidate) == .orderedSame }) { + return candidate + } + index += 1 } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 575efe6bd860..a97d4a179c82 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4156,8 +4156,8 @@ class TerminalController { return } let colorInput = colorRaw.trimmingCharacters(in: .whitespacesAndNewlines) - // Resolve named colors from effective palette (includes user overrides, excludes custom entries) - let effectivePalette = WorkspaceTabColorSettings.defaultPaletteWithOverrides() + // Resolve named colors from the effective palette, including file-defined additions. + let effectivePalette = WorkspaceTabColorSettings.palette() let hex: String if let entry = effectivePalette.first(where: { $0.name.caseInsensitiveCompare(colorInput) == .orderedSame diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 5ec0a2f2e872..cbded5e213cd 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3975,8 +3975,7 @@ struct SettingsView: View { @State private var telemetryValueAtLaunch = TelemetrySettings.enabledForCurrentLaunch @State private var showLanguageRestartAlert = false @State private var isResettingSettings = false - @State private var workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() - @State private var workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() + @State private var workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() @State private var trustedDirectoriesDraft: String = CmuxDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") private var selectedWorkspacePlacement: NewWorkspacePlacement { @@ -5034,71 +5033,68 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardNote(String(localized: "settings.workspaceColors.paletteNote", defaultValue: "Customize the workspace color palette used by Sidebar > Workspace Color. \"Choose Custom Color...\" entries are persisted below.")) - - ForEach(Array(workspaceTabDefaultEntries.enumerated()), id: \.element.name) { index, entry in - if index > 0 { - SettingsCardDivider() - } - SettingsCardRow( - entry.name, - subtitle: String(localized: "settings.workspaceColors.base", defaultValue: "Base: \(baseTabColorHex(for: entry.name))") - ) { - HStack(spacing: 8) { - ColorPicker( - "", - selection: defaultTabColorBinding(for: entry.name), - supportsOpacity: false - ) - .labelsHidden() - .frame(width: 38) - - Text(entry.hex) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } - } - - SettingsCardDivider() + SettingsCardNote( + String( + localized: "settings.workspaceColors.dictionaryNote", + defaultValue: "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." + ) + ) - if workspaceTabCustomColors.isEmpty { - SettingsCardNote(String(localized: "settings.workspaceColors.noCustomColors", defaultValue: "Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu.")) + if workspaceTabPaletteEntries.isEmpty { + SettingsCardNote( + String( + localized: "settings.workspaceColors.emptyPalette", + defaultValue: "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." + ) + ) } else { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.workspaceColors.customColors", defaultValue: "Custom Colors")) - .font(.system(size: 13, weight: .semibold)) - - ForEach(workspaceTabCustomColors, id: \.self) { hex in + ForEach(Array(workspaceTabPaletteEntries.enumerated()), id: \.element.name) { index, entry in + if index > 0 { + SettingsCardDivider() + } + SettingsCardRow( + entry.name, + subtitle: baseTabColorHex(for: entry.name).map { + String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") + } ?? String( + localized: "settings.workspaceColors.customEntry", + defaultValue: "Named palette entry." + ) + ) { HStack(spacing: 8) { - Circle() - .fill(Color(nsColor: NSColor(hex: hex) ?? .gray)) - .frame(width: 11, height: 11) - - Text(hex) + ColorPicker( + "", + selection: tabColorBinding(for: entry.name), + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) + + Text(entry.hex) .font(.system(size: 12, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) - - Spacer(minLength: 8) - - Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { - removeWorkspaceCustomColor(hex) + .frame(width: 76, alignment: .trailing) + + if baseTabColorHex(for: entry.name) == nil { + Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { + removeWorkspaceColor(named: entry.name) + } + .buttonStyle(.bordered) + .controlSize(.small) } - .buttonStyle(.bordered) - .controlSize(.small) } } } - .padding(.horizontal, 14) - .padding(.vertical, 10) } SettingsCardDivider() SettingsCardRow( String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), - subtitle: String(localized: "settings.workspaceColors.resetPalette.subtitle", defaultValue: "Restore built-in defaults and clear all custom colors.") + subtitle: String( + localized: "settings.workspaceColors.resetPalette.subtitleV2", + defaultValue: "Restore the built-in palette and remove extra named colors." + ) ) { Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { resetWorkspaceTabColors() @@ -5929,28 +5925,28 @@ struct SettingsView: View { DispatchQueue.main.async { isResettingSettings = false } } - private func defaultTabColorBinding(for name: String) -> Binding<Color> { + private func tabColorBinding(for name: String) -> Binding<Color> { Binding( get: { - let hex = WorkspaceTabColorSettings.defaultColorHex(named: name) + let hex = WorkspaceTabColorSettings.currentColorHex(named: name) + ?? WorkspaceTabColorSettings.defaultColorHex(named: name) + ?? "#1565C0" return Color(nsColor: NSColor(hex: hex) ?? .systemBlue) }, set: { newValue in let hex = NSColor(newValue).hexString() - WorkspaceTabColorSettings.setDefaultColor(named: name, hex: hex) + WorkspaceTabColorSettings.setColor(named: name, hex: hex) reloadWorkspaceTabColorSettings() } ) } - private func baseTabColorHex(for name: String) -> String { - WorkspaceTabColorSettings.defaultPalette - .first(where: { $0.name == name })? - .hex ?? "#1565C0" + private func baseTabColorHex(for name: String) -> String? { + WorkspaceTabColorSettings.defaultColorHex(named: name) } - private func removeWorkspaceCustomColor(_ hex: String) { - WorkspaceTabColorSettings.removeCustomColor(hex) + private func removeWorkspaceColor(named name: String) { + WorkspaceTabColorSettings.removeColor(named: name) reloadWorkspaceTabColorSettings() } @@ -5960,8 +5956,7 @@ struct SettingsView: View { } private func reloadWorkspaceTabColorSettings() { - workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() - workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() + workspaceTabPaletteEntries = WorkspaceTabColorSettings.palette() } private func saveBrowserInsecureHTTPAllowlist() { diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 108bea10e2b4..c203007926e4 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -598,6 +598,8 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertTrue(contents.contains(#""$schema": "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json""#)) XCTAssertTrue(contents.contains(#""schemaVersion": 1,"#)) XCTAssertTrue(contents.contains(#"// "app" : {"#)) + XCTAssertTrue(contents.contains(#"// "colors" : {"#)) + XCTAssertTrue(contents.contains(##"// "Red" : "#C0392B""##)) XCTAssertTrue(contents.contains(#"// "shortcuts" : {"#)) } @@ -820,6 +822,129 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertNil(defaults.data(forKey: settingsFileBackupsDefaultsKey)) } + func testSettingsFileStoreAppliesWorkspaceColorDictionaryAndAllowsRemovingDefaults() throws { + let defaults = UserDefaults.standard + let previousPalette = defaults.dictionary(forKey: WorkspaceTabColorSettings.paletteKey) as? [String: String] + let previousLegacyOverrides = defaults.dictionary(forKey: "workspaceTabColor.defaultOverrides") as? [String: String] + let previousLegacyCustomColors = defaults.array(forKey: "workspaceTabColor.customColors") as? [String] + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + WorkspaceTabColorSettings.reset(defaults: defaults) + if let previousPalette { + defaults.set(previousPalette, forKey: WorkspaceTabColorSettings.paletteKey) + } + if let previousLegacyOverrides { + defaults.set(previousLegacyOverrides, forKey: "workspaceTabColor.defaultOverrides") + } + if let previousLegacyCustomColors { + defaults.set(previousLegacyCustomColors, forKey: "workspaceTabColor.customColors") + } + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + WorkspaceTabColorSettings.reset(defaults: defaults) + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "workspaceColors": { + "colors": { + "Blue": "#2244ff", + "Neon Mint": "#00f5d4" + } + } + } + """, + to: settingsFileURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + let palette = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertEqual(palette.map(\.name), ["Blue", "Neon Mint"]) + XCTAssertEqual(palette.map(\.hex), ["#2244FF", "#00F5D4"]) + } + + func testManagedWorkspaceColorsRestoreLegacyPaletteWhenFileSettingIsRemoved() throws { + let defaults = UserDefaults.standard + let previousPalette = defaults.dictionary(forKey: WorkspaceTabColorSettings.paletteKey) as? [String: String] + let previousLegacyOverrides = defaults.dictionary(forKey: "workspaceTabColor.defaultOverrides") as? [String: String] + let previousLegacyCustomColors = defaults.array(forKey: "workspaceTabColor.customColors") as? [String] + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + WorkspaceTabColorSettings.reset(defaults: defaults) + if let previousPalette { + defaults.set(previousPalette, forKey: WorkspaceTabColorSettings.paletteKey) + } + if let previousLegacyOverrides { + defaults.set(previousLegacyOverrides, forKey: "workspaceTabColor.defaultOverrides") + } + if let previousLegacyCustomColors { + defaults.set(previousLegacyCustomColors, forKey: "workspaceTabColor.customColors") + } + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + + WorkspaceTabColorSettings.reset(defaults: defaults) + defaults.set(["Blue": "#010203"], forKey: "workspaceTabColor.defaultOverrides") + defaults.set(["#778899"], forKey: "workspaceTabColor.customColors") + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let managedSettingsURL = directoryURL.appendingPathComponent("managed.json", isDirectory: false) + try writeSettingsFile( + """ + { + "workspaceColors": { + "colors": { + "Neon Mint": "#00F5D4" + } + } + } + """, + to: managedSettingsURL + ) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: managedSettingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + XCTAssertEqual(WorkspaceTabColorSettings.palette(defaults: defaults).map(\.name), ["Neon Mint"]) + + let missingSettingsURL = directoryURL.appendingPathComponent("missing.json", isDirectory: false) + _ = KeyboardShortcutSettingsFileStore( + primaryPath: missingSettingsURL.path, + fallbackPath: nil, + startWatching: false + ) + + let restored = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertEqual(restored.first(where: { $0.name == "Blue" })?.hex, "#010203") + XCTAssertEqual(restored.first(where: { $0.name == "Custom 1" })?.hex, "#778899") + XCTAssertNil(defaults.data(forKey: settingsFileBackupsDefaultsKey)) + } + @MainActor func testReloadConfigurationReloadsManagedAppSettingsFromSettingsFile() throws { let defaults = UserDefaults.standard @@ -1366,6 +1491,15 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { } func testBuiltInPaletteMatchesOriginalPRPalette() { + let palette = WorkspaceTabColorSettings.defaultPalette + XCTAssertEqual(palette.count, 16) + XCTAssertEqual(palette.first?.name, "Red") + XCTAssertEqual(palette.first?.hex, "#C0392B") + XCTAssertEqual(palette.last?.name, "Charcoal") + XCTAssertFalse(palette.contains(where: { $0.name == "Gold" })) + } + + func testPaletteFallsBackToBuiltInDefaultsWhenUnset() { let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") @@ -1373,16 +1507,11 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { } defer { defaults.removePersistentDomain(forName: suiteName) } - let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults) - XCTAssertEqual(palette.count, 16) - XCTAssertEqual(palette.first?.name, "Red") - XCTAssertEqual(palette.first?.hex, "#C0392B") - XCTAssertEqual(palette.last?.name, "Charcoal") - XCTAssertFalse(palette.contains(where: { $0.name == "Gold" })) + XCTAssertEqual(WorkspaceTabColorSettings.palette(defaults: defaults), WorkspaceTabColorSettings.defaultPalette) } - func testDefaultOverrideRoundTripFallsBackWhenResetToBase() { - let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)" + func testSetColorRoundTripFallsBackWhenResetToBase() { + let suiteName = "WorkspaceTabColorSettingsTests.SetColor.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") return @@ -1391,25 +1520,27 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { let first = WorkspaceTabColorSettings.defaultPalette[0] XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + WorkspaceTabColorSettings.currentColorHex(named: first.name, defaults: defaults), first.hex ) - WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults) + WorkspaceTabColorSettings.setColor(named: first.name, hex: "#00aa33", defaults: defaults) XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + WorkspaceTabColorSettings.currentColorHex(named: first.name, defaults: defaults), "#00AA33" ) + XCTAssertNotNil(defaults.dictionary(forKey: WorkspaceTabColorSettings.paletteKey)) - WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults) + WorkspaceTabColorSettings.setColor(named: first.name, hex: first.hex, defaults: defaults) XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + WorkspaceTabColorSettings.currentColorHex(named: first.name, defaults: defaults), first.hex ) + XCTAssertNil(defaults.object(forKey: WorkspaceTabColorSettings.paletteKey)) } - func testAddCustomColorPersistsAndDeduplicatesByMostRecent() { - let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)" + func testAddCustomColorCreatesNamedEntriesAndDeduplicatesByHex() { + let suiteName = "WorkspaceTabColorSettingsTests.NamedCustomColors.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") return @@ -1430,13 +1561,54 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { ) XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults)) + let customEntries = WorkspaceTabColorSettings.customPaletteEntries(defaults: defaults) + XCTAssertEqual(customEntries.map(\.name), ["Custom 1", "Custom 2"]) + XCTAssertEqual(customEntries.map(\.hex), ["#00AA33", "#112233"]) + } + + func testPaletteDictionaryCanRemoveBuiltInEntriesAndAddNamedOnes() { + let suiteName = "WorkspaceTabColorSettingsTests.DictionaryPalette.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + var palette = Dictionary(uniqueKeysWithValues: WorkspaceTabColorSettings.defaultPalette.map { ($0.name, $0.hex) }) + palette.removeValue(forKey: "Red") + palette["Neon Mint"] = "#00F5D4" + WorkspaceTabColorSettings.persistPaletteMap(palette, defaults: defaults) + + let resolved = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertFalse(resolved.contains(where: { $0.name == "Red" })) + XCTAssertEqual(resolved.first?.name, "Crimson") + XCTAssertEqual(resolved.last?.name, "Neon Mint") + XCTAssertEqual(resolved.last?.hex, "#00F5D4") + } + + func testLegacyKeysStillResolveIntoEffectivePalette() { + let suiteName = "WorkspaceTabColorSettingsTests.LegacyKeys.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(["Blue": "#010203"], forKey: "workspaceTabColor.defaultOverrides") + defaults.set(["#778899"], forKey: "workspaceTabColor.customColors") + + let resolved = WorkspaceTabColorSettings.palette(defaults: defaults) XCTAssertEqual( - WorkspaceTabColorSettings.customColors(defaults: defaults), - ["#00AA33", "#112233"] + resolved.first(where: { $0.name == "Blue" })?.hex, + "#010203" + ) + XCTAssertEqual( + resolved.first(where: { $0.name == "Custom 1" })?.hex, + "#778899" ) } - func testPaletteIncludesCustomEntriesAndResetClearsAll() { + func testResetClearsNewAndLegacyStorage() { let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") @@ -1444,23 +1616,16 @@ final class WorkspaceTabColorSettingsTests: XCTestCase { } defer { defaults.removePersistentDomain(forName: suiteName) } - let first = WorkspaceTabColorSettings.defaultPalette[0] - WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults) - _ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults) - - let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults) - XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1) - XCTAssertEqual(paletteBeforeReset[0].hex, "#334455") - XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1") - XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899") + WorkspaceTabColorSettings.persistPaletteMap(["Neon Mint": "#00F5D4"], defaults: defaults) + defaults.set(["Blue": "#010203"], forKey: "workspaceTabColor.defaultOverrides") + defaults.set(["#778899"], forKey: "workspaceTabColor.customColors") WorkspaceTabColorSettings.reset(defaults: defaults) - XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), []) - XCTAssertEqual( - WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), - first.hex - ) + XCTAssertNil(defaults.object(forKey: WorkspaceTabColorSettings.paletteKey)) + XCTAssertNil(defaults.object(forKey: "workspaceTabColor.defaultOverrides")) + XCTAssertNil(defaults.object(forKey: "workspaceTabColor.customColors")) + XCTAssertEqual(WorkspaceTabColorSettings.palette(defaults: defaults), WorkspaceTabColorSettings.defaultPalette) } func testDisplayColorLightModeKeepsOriginalHex() { diff --git a/web/app/[locale]/docs/configuration/page.tsx b/web/app/[locale]/docs/configuration/page.tsx index 8384f1b530b2..deef6ee9e491 100644 --- a/web/app/[locale]/docs/configuration/page.tsx +++ b/web/app/[locale]/docs/configuration/page.tsx @@ -59,6 +59,14 @@ const settingsFileExample = `{ // "hostsToOpenInEmbeddedBrowser": ["localhost", "*.internal.example"] // }, + // "workspaceColors": { + // "colors": { + // "Red": "#C0392B", + // "Blue": "#1565C0", + // "Neon Mint": "#00F5D4" + // } + // }, + // "shortcuts": { // "bindings": { // "toggleSidebar": "cmd+b", @@ -126,9 +134,16 @@ function formatDefaultValue(value: unknown): string { if (typeof value === "string") { return JSON.stringify(value); } + if (typeof value === "object" && value !== null) { + return JSON.stringify(value, null, 2) ?? "none"; + } return JSON.stringify(value) ?? "none"; } +function hasComplexDefaultValue(value: unknown): boolean { + return typeof value === "object" && value !== null; +} + function PropertyCard({ path, property }: { path: string; property: SchemaProperty }) { return ( <div className="rounded-xl border border-border/70 bg-background/40 p-4"> @@ -146,7 +161,13 @@ function PropertyCard({ path, property }: { path: string; property: SchemaProper <div> <dt className="font-medium text-foreground">Default</dt> <dd className="text-muted"> - <code>{formatDefaultValue(property.default)}</code> + {hasComplexDefaultValue(property.default) ? ( + <pre className="overflow-x-auto rounded-lg bg-background/60 p-3 text-xs text-foreground"> + <code>{formatDefaultValue(property.default)}</code> + </pre> + ) : ( + <code>{formatDefaultValue(property.default)}</code> + )} </dd> </div> {property.enum && ( @@ -292,6 +313,26 @@ working-directory = ~/code`}</CodeBlock> </h3> {property.description && <p>{property.description}</p>} <PropertyGrid prefix={sectionName} properties={property.properties} skip={skipBindings} /> + {sectionName === "workspaceColors" && ( + <> + <p> + <code>workspaceColors.colors</code> is the full palette. Keep the built-in keys + you want, delete keys to remove colors from the picker, and add more named color + entries to extend it. Older <code>paletteOverrides</code> and{" "} + <code>customColors</code> files still parse during upgrades, but new files + should use <code>colors</code>. + </p> + <CodeBlock lang="json">{`{ + "workspaceColors": { + "colors": { + "Red": "#C0392B", + "Blue": "#1565C0", + "Neon Mint": "#00F5D4" + } + } +}`}</CodeBlock> + </> + )} </section> ); })} diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index caad7c0b9d6f..a2b4f483755c 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -230,22 +230,30 @@ "default": null, "description": "Override the unread notification badge color." }, - "paletteOverrides": { + "colors": { "type": "object", - "default": {}, - "description": "Override built-in workspace color names with custom hex values. Unknown names are ignored.", + "description": "Full named workspace color palette. Include built-in entries you want to keep, remove keys to remove colors, and add more named entries to extend the picker.", + "default": { + "Red": "#C0392B", + "Crimson": "#922B21", + "Orange": "#A04000", + "Amber": "#7D6608", + "Olive": "#4A5C18", + "Green": "#196F3D", + "Teal": "#006B6B", + "Aqua": "#0E6B8C", + "Blue": "#1565C0", + "Navy": "#1A5276", + "Indigo": "#283593", + "Purple": "#6A1B9A", + "Magenta": "#AD1457", + "Rose": "#880E4F", + "Brown": "#7B3F00", + "Charcoal": "#3E4B5E" + }, "additionalProperties": { "$ref": "#/$defs/colorHex" } - }, - "customColors": { - "type": "array", - "items": { - "$ref": "#/$defs/colorHex" - }, - "maxItems": 24, - "default": [], - "description": "Custom workspace colors available in the picker." } } }, From c6593b222b05408bb59065445493f8879ad1901d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:28:52 -0700 Subject: [PATCH 18/89] feat: add textedit button for settings file --- Resources/Localizable.xcstrings | 17 +++++++++++++++++ Sources/cmuxApp.swift | 28 +++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index dd7be67aae06..ae48fc70b284 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -47901,6 +47901,23 @@ } } }, + "settings.app.settingsFile.openInTextEditButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in TextEdit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "TextEditで開く" + } + } + } + }, "settings.app.commandPaletteSearchAllSurfaces": { "extractionState": "manual", "localizations": { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index cbded5e213cd..c88abe8f4c47 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3870,6 +3870,15 @@ private func openCmuxSettingsFileInEditor() { PreferredEditorSettings.open(url) } +private func openCmuxSettingsFileInTextEdit() { + #if os(macOS) + let fileURL = KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing() + let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app") + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration) + #endif +} + struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 @@ -4576,12 +4585,21 @@ struct SettingsView: View { String(localized: "settings.app.settingsFile", defaultValue: "Settings File"), subtitle: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath() ) { - Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { - openCmuxSettingsFileInEditor() + HStack(spacing: 8) { + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { + openCmuxSettingsFileInEditor() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsFileOpenButton") + + Button(String(localized: "settings.app.settingsFile.openInTextEditButton", defaultValue: "Open in TextEdit")) { + openCmuxSettingsFileInTextEdit() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsFileOpenInTextEditButton") } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsFileOpenButton") } SettingsCardDivider() From 0516d784951dc791ffbcf917e197226a113b5694 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:31:53 -0700 Subject: [PATCH 19/89] refactor: reorder ghostty menu items --- Resources/Localizable.xcstrings | 4 ++-- Sources/cmuxApp.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index ae48fc70b284..5685ae09ce0c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -36945,13 +36945,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Reload Configuration" + "value": "Reload Ghostty Settings" } }, "ja": { "stringUnit": { "state": "translated", - "value": "構成を再読み込み" + "value": "Ghostty設定を再読み込み" } }, "zh-Hans": { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index c88abe8f4c47..4ed632b30732 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -351,15 +351,15 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) { appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } - splitCommandButton(title: String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration"), shortcut: menuShortcut(for: .reloadConfiguration)) { + Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) { + GhosttyApp.shared.openConfigurationInTextEdit() + } + splitCommandButton(title: String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Ghostty Settings"), shortcut: menuShortcut(for: .reloadConfiguration)) { GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration") } Button(String(localized: "menu.app.openCmuxSettingsFile", defaultValue: "Open cmux settings.json")) { openCmuxSettingsFileInEditor() } - Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) { - GhosttyApp.shared.openConfigurationInTextEdit() - } } CommandGroup(replacing: .appInfo) { From 1e32852919b2bd2fd562d94dc4ce96ff2beb4010 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:35:56 -0700 Subject: [PATCH 20/89] refactor: restore settings menu ordering --- Resources/Localizable.xcstrings | 4 +-- Sources/cmuxApp.swift | 54 ++++++++++++++++----------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 5685ae09ce0c..ae48fc70b284 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -36945,13 +36945,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Reload Ghostty Settings" + "value": "Reload Configuration" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Ghostty設定を再読み込み" + "value": "構成を再読み込み" } }, "zh-Hans": { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 4ed632b30732..cdff23214a69 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -351,15 +351,15 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) { appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } + Button(String(localized: "menu.app.openCmuxSettingsFile", defaultValue: "Open cmux settings.json")) { + openCmuxSettingsFileInEditor() + } Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) { GhosttyApp.shared.openConfigurationInTextEdit() } - splitCommandButton(title: String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Ghostty Settings"), shortcut: menuShortcut(for: .reloadConfiguration)) { + splitCommandButton(title: String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration"), shortcut: menuShortcut(for: .reloadConfiguration)) { GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration") } - Button(String(localized: "menu.app.openCmuxSettingsFile", defaultValue: "Open cmux settings.json")) { - openCmuxSettingsFileInEditor() - } } CommandGroup(replacing: .appInfo) { @@ -4462,6 +4462,29 @@ struct SettingsView: View { ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { + SettingsCard { + SettingsCardRow( + String(localized: "settings.app.settingsFile", defaultValue: "Settings File"), + subtitle: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath() + ) { + HStack(spacing: 8) { + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { + openCmuxSettingsFileInEditor() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsFileOpenButton") + + Button(String(localized: "settings.app.settingsFile.openInTextEditButton", defaultValue: "Open in TextEdit")) { + openCmuxSettingsFileInTextEdit() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsFileOpenInTextEditButton") + } + } + } + SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) SettingsCard { SettingsCardRow( @@ -4581,29 +4604,6 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow( - String(localized: "settings.app.settingsFile", defaultValue: "Settings File"), - subtitle: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath() - ) { - HStack(spacing: 8) { - Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { - openCmuxSettingsFileInEditor() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsFileOpenButton") - - Button(String(localized: "settings.app.settingsFile.openInTextEditButton", defaultValue: "Open in TextEdit")) { - openCmuxSettingsFileInTextEdit() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsFileOpenInTextEditButton") - } - } - - SettingsCardDivider() - SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") From b1721dd374b5b9eb63a41a316ca660d17f52ad80 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:39:49 -0700 Subject: [PATCH 21/89] refactor: restyle settings file entry --- Resources/Localizable.xcstrings | 8 ++-- Sources/cmuxApp.swift | 75 +++++++++++++++++++++++---------- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index ae48fc70b284..957991a84a4c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -37183,13 +37183,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Open cmux settings.json" + "value": "Open settings.json" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux settings.jsonを開く" + "value": "settings.jsonを開く" } } } @@ -47873,13 +47873,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Settings File" + "value": "settings.json" } }, "ja": { "stringUnit": { "state": "translated", - "value": "設定ファイル" + "value": "settings.json" } } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index cdff23214a69..6e490aeef95a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -351,7 +351,7 @@ struct cmuxApp: App { splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) { appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } - Button(String(localized: "menu.app.openCmuxSettingsFile", defaultValue: "Open cmux settings.json")) { + Button(String(localized: "menu.app.openCmuxSettingsFile", defaultValue: "Open settings.json")) { openCmuxSettingsFileInEditor() } Button(String(localized: "menu.app.ghosttySettings", defaultValue: "Ghostty Settings…")) { @@ -4462,28 +4462,12 @@ struct SettingsView: View { ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { - SettingsCard { - SettingsCardRow( - String(localized: "settings.app.settingsFile", defaultValue: "Settings File"), - subtitle: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath() - ) { - HStack(spacing: 8) { - Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { - openCmuxSettingsFileInEditor() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsFileOpenButton") - - Button(String(localized: "settings.app.settingsFile.openInTextEditButton", defaultValue: "Open in TextEdit")) { - openCmuxSettingsFileInTextEdit() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsFileOpenInTextEditButton") - } - } - } + SettingsFileHeroCard( + title: String(localized: "settings.app.settingsFile", defaultValue: "settings.json"), + path: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), + onOpen: openCmuxSettingsFileInEditor, + onOpenInTextEdit: openCmuxSettingsFileInTextEdit + ) SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) SettingsCard { @@ -6051,6 +6035,51 @@ private struct SettingsCard<Content: View>: View { } } +private struct SettingsFileHeroCard: View { + let title: String + let path: String + let onOpen: () -> Void + let onOpenInTextEdit: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + + Text(path) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + .textSelection(.enabled) + + HStack(spacing: 8) { + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { + onOpen() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityIdentifier("SettingsFileOpenButton") + + Button(String(localized: "settings.app.settingsFile.openInTextEditButton", defaultValue: "Open in TextEdit")) { + onOpenInTextEdit() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsFileOpenInTextEditButton") + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 15, style: .continuous) + .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.92)) + .overlay( + RoundedRectangle(cornerRadius: 15, style: .continuous) + .stroke(Color.accentColor.opacity(0.18), lineWidth: 1) + ) + ) + } +} + private struct SettingsCardRow<Trailing: View>: View { let title: String let subtitle: String? From a641c6fb1d1da2f3d046f0d3ed32e5117adb5fd0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:03:08 -0700 Subject: [PATCH 22/89] refactor: move settings file actions into header --- Resources/Localizable.xcstrings | 17 ------- Sources/cmuxApp.swift | 79 +++++++++++++++------------------ 2 files changed, 35 insertions(+), 61 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 957991a84a4c..fd9aa39855e7 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -47867,23 +47867,6 @@ } } }, - "settings.app.settingsFile": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "settings.json" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "settings.json" - } - } - } - }, "settings.app.settingsFile.openButton": { "extractionState": "manual", "localizations": { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 6e490aeef95a..bd18c3640617 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4462,13 +4462,6 @@ struct SettingsView: View { ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { - SettingsFileHeroCard( - title: String(localized: "settings.app.settingsFile", defaultValue: "settings.json"), - path: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), - onOpen: openCmuxSettingsFileInEditor, - onOpenInTextEdit: openCmuxSettingsFileInTextEdit - ) - SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) SettingsCard { SettingsCardRow( @@ -5721,8 +5714,23 @@ struct SettingsView: View { .font(.system(size: 16, weight: .semibold)) .foregroundColor(.primary.opacity(0.92)) Spacer(minLength: 0) + HStack(spacing: 6) { + SettingsHeaderActionButton( + title: String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json"), + helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), + accessibilityIdentifier: "SettingsFileOpenButton", + action: openCmuxSettingsFileInEditor + ) + SettingsHeaderActionButton( + title: String(localized: "settings.app.settingsFile.openInTextEditButton", defaultValue: "Open in TextEdit"), + helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), + accessibilityIdentifier: "SettingsFileOpenInTextEditButton", + action: openCmuxSettingsFileInTextEdit + ) + } } .padding(.leading, settingsTitleLeadingInset) + .padding(.trailing, 20) .padding(.top, 12) } .frame(height: 62) @@ -5734,7 +5742,6 @@ struct SettingsView: View { .frame(height: 1), alignment: .bottom ) - .allowsHitTesting(false) } .background(Color(nsColor: .windowBackgroundColor).ignoresSafeArea()) .toggleStyle(.switch) @@ -6035,48 +6042,32 @@ private struct SettingsCard<Content: View>: View { } } -private struct SettingsFileHeroCard: View { +private struct SettingsHeaderActionButton: View { let title: String - let path: String - let onOpen: () -> Void - let onOpenInTextEdit: () -> Void + let helpText: String + let accessibilityIdentifier: String + let action: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 12) { + Button(action: action) { Text(title) - .font(.system(size: 16, weight: .semibold)) - - Text(path) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(.secondary) - .textSelection(.enabled) - - HStack(spacing: 8) { - Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { - onOpen() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .accessibilityIdentifier("SettingsFileOpenButton") - - Button(String(localized: "settings.app.settingsFile.openInTextEditButton", defaultValue: "Open in TextEdit")) { - onOpenInTextEdit() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsFileOpenInTextEditButton") - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.92)) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.34)) + ) .overlay( - RoundedRectangle(cornerRadius: 15, style: .continuous) - .stroke(Color.accentColor.opacity(0.18), lineWidth: 1) + Capsule(style: .continuous) + .stroke(Color(nsColor: NSColor.separatorColor).opacity(0.22), lineWidth: 1) ) - ) + } + .buttonStyle(.plain) + .controlSize(.small) + .help(helpText) + .accessibilityIdentifier(accessibilityIdentifier) } } From 7f6502bca269584e289e465f651c23150d41226f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:08:26 -0700 Subject: [PATCH 23/89] refactor: simplify settings file header action --- Resources/Localizable.xcstrings | 17 ----------------- Sources/cmuxApp.swift | 6 ------ 2 files changed, 23 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index fd9aa39855e7..cae5a6e12eec 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -47884,23 +47884,6 @@ } } }, - "settings.app.settingsFile.openInTextEditButton": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open in TextEdit" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "TextEditで開く" - } - } - } - }, "settings.app.commandPaletteSearchAllSurfaces": { "extractionState": "manual", "localizations": { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index bd18c3640617..a2a39a64329a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5719,12 +5719,6 @@ struct SettingsView: View { title: String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json"), helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), accessibilityIdentifier: "SettingsFileOpenButton", - action: openCmuxSettingsFileInEditor - ) - SettingsHeaderActionButton( - title: String(localized: "settings.app.settingsFile.openInTextEditButton", defaultValue: "Open in TextEdit"), - helpText: KeyboardShortcutSettings.settingsFileStore.settingsFileDisplayPath(), - accessibilityIdentifier: "SettingsFileOpenInTextEditButton", action: openCmuxSettingsFileInTextEdit ) } From 1f71b3e7286a86e4b8d67b52f59aa8d2a8a917f7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:16:33 -0700 Subject: [PATCH 24/89] docs: explain shortcut chords from settings --- Resources/Localizable.xcstrings | 51 +++++++++++++++++++ Sources/cmuxApp.swift | 21 ++++++++ .../[locale]/docs/keyboard-shortcuts/page.tsx | 29 +++++++++++ web/app/sitemap.ts | 2 +- web/messages/en.json | 6 +++ web/messages/ja.json | 6 +++ 6 files changed, 114 insertions(+), 1 deletion(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cae5a6e12eec..88e585463fce 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -63462,6 +63462,57 @@ } } }, + "settings.shortcuts.chords": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shortcut Chords" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカットコード" + } + } + } + }, + "settings.shortcuts.chords.docsButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chord docs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コードの説明" + } + } + } + }, + "settings.shortcuts.chords.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"]." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.json で tmux 風の2段階ショートカットを追加できます。例: [\"ctrl+b\", \"c\"]。" + } + } + } + }, "settings.shortcuts.showHints": { "extractionState": "manual", "localizations": { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a2a39a64329a..f3947bdf88a4 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3883,6 +3883,7 @@ struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 private let notificationSoundControlWidth: CGFloat = 280 + private let shortcutChordsDocsURL = URL(string: "https://cmux.com/docs/keyboard-shortcuts#shortcut-chords")! @AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @@ -5609,6 +5610,26 @@ struct SettingsView: View { .id(SettingsNavigationTarget.keyboardShortcuts) .accessibilityIdentifier("SettingsKeyboardShortcutsSection") SettingsCard { + SettingsCardRow( + String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), + subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"].") + ) { + HStack(spacing: 8) { + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { + openCmuxSettingsFileInTextEdit() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsKeyboardShortcutsOpenSettingsFileButton") + + Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) + .font(.caption) + .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") + } + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), subtitle: showShortcutHintsOnCommandHold diff --git a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx index 70b25ea89d3d..005a4c517430 100644 --- a/web/app/[locale]/docs/keyboard-shortcuts/page.tsx +++ b/web/app/[locale]/docs/keyboard-shortcuts/page.tsx @@ -1,8 +1,21 @@ import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server"; import { buildAlternates } from "../../../../i18n/seo"; +import { Link } from "../../../../i18n/navigation"; +import { Callout } from "../../components/callout"; +import { CodeBlock } from "../../components/code-block"; import { KeyboardShortcuts } from "../../keyboard-shortcuts"; +const shortcutChordExample = `{ + "shortcuts": { + "bindings": { + "newSurface": ["ctrl+b", "c"], + "showNotifications": ["ctrl+b", "i"], + "toggleSidebar": "cmd+b" + } + } +}`; + export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "docs.keyboardShortcuts" }); @@ -20,6 +33,22 @@ export default function KeyboardShortcutsPage() { <> <h1>{t("title")}</h1> <p>{t("description")}</p> + + <h2 id="shortcut-chords" className="scroll-mt-24">{t("chordsTitle")}</h2> + <p> + {t.rich("chordsIntro", { + settingsFile: (chunks) => <code>{chunks}</code>, + configurationLink: (chunks) => <Link href="/docs/configuration">{chunks}</Link>, + })} + </p> + <Callout type="info">{t("chordsCallout")}</Callout> + <CodeBlock title="settings.json" lang="json">{shortcutChordExample}</CodeBlock> + <ul> + <li>{t("chordsRuleSingle")}</li> + <li>{t("chordsRuleArray")}</li> + <li>{t("chordsRuleSyntax")}</li> + </ul> + <KeyboardShortcuts /> </> ); diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index a4ce63ac05ce..f78e97ce2285 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -19,7 +19,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { path: "/docs/concepts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/configuration", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/custom-commands", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 }, - { path: "/docs/keyboard-shortcuts", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.7 }, + { path: "/docs/keyboard-shortcuts", lastModified: "2026-04-03", changeFrequency: "monthly" as const, priority: 0.7 }, { path: "/docs/api", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/notifications", lastModified: "2026-03-18", changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/ssh", lastModified: "2026-03-31", changeFrequency: "monthly" as const, priority: 0.8 }, diff --git a/web/messages/en.json b/web/messages/en.json index 191770da7094..1d419a82679e 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -419,6 +419,12 @@ "description": "Default cmux keyboard shortcuts. Every cmux-owned shortcut can be changed in Settings or ~/.config/cmux/settings.json, including two-step chords.", "metaTitle": "Keyboard Shortcuts", "metaDescription": "Default cmux keyboard shortcuts for workspaces, surfaces, split panes, browser, notifications, find, and app controls on macOS. All cmux-owned shortcuts are configurable in Settings or settings.json.", + "chordsTitle": "Shortcut chords", + "chordsIntro": "cmux supports two-step shortcut chords in <settingsFile>~/.config/cmux/settings.json</settingsFile>. For the full settings file schema, see the <configurationLink>configuration docs</configurationLink>.", + "chordsCallout": "Settings can edit shortcuts directly. Use settings.json when you want an exact tmux-style prefix binding or you keep shortcuts in dotfiles.", + "chordsRuleSingle": "Use a plain string for a one-step shortcut.", + "chordsRuleArray": "Use a two-item array for a chord. The first item is the prefix stroke, the second is the key that follows it.", + "chordsRuleSyntax": "Each item uses the same syntax as regular bindings, for example cmd+b, ctrl+b, shift+/, or ctrl+1.", "searchPlaceholder": "Search shortcuts...", "searchLabel": "Search keyboard shortcuts", "noResults": "No shortcuts found", diff --git a/web/messages/ja.json b/web/messages/ja.json index f657d916ef99..e9aec5b6e0cf 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -373,6 +373,12 @@ "keyboardShortcuts": { "title": "キーボードショートカット", "description": "cmuxのデフォルトショートカット一覧です。cmux管理のショートカットはすべて設定画面または ~/.config/cmux/settings.json で変更でき、2段階のコードにも対応しています。", + "chordsTitle": "ショートカットコード", + "chordsIntro": "cmux は <settingsFile>~/.config/cmux/settings.json</settingsFile> で2段階のショートカットコードを定義できます。設定ファイル全体の仕様は <configurationLink>設定ドキュメント</configurationLink> を参照してください。", + "chordsCallout": "ショートカットは設定画面でも編集できますが、tmux 風のプレフィックスを正確に書きたい場合や dotfiles で管理したい場合は settings.json が分かりやすい方法です。", + "chordsRuleSingle": "1回のショートカットは文字列で指定します。", + "chordsRuleArray": "コードは2要素の配列で指定します。1つ目がプレフィックス、2つ目が続けて押すキーです。", + "chordsRuleSyntax": "各要素は通常のショートカットと同じ構文で、例: cmd+b、ctrl+b、shift+/、ctrl+1。", "metaDescription": "cmuxのmacOS向けデフォルトキーボードショートカット一覧。ワークスペース、サーフェス、分割ペイン、ブラウザ、通知、検索、アプリ操作を収録し、すべて設定画面または settings.json で変更できます。", "searchPlaceholder": "ショートカットを検索...", "searchLabel": "キーボードショートカットを検索", From e4f67ed1b5876610fc0e96ce8d6b317275fe50f7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:26:26 -0700 Subject: [PATCH 25/89] refactor: reorder shortcut chord actions --- Sources/cmuxApp.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f3947bdf88a4..1f2c1af356e7 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5615,16 +5615,16 @@ struct SettingsView: View { subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"].") ) { HStack(spacing: 8) { + Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) + .font(.caption) + .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { openCmuxSettingsFileInTextEdit() } .buttonStyle(.bordered) .controlSize(.small) .accessibilityIdentifier("SettingsKeyboardShortcutsOpenSettingsFileButton") - - Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) - .font(.caption) - .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") } } From ed8128ee94583a23697671d598dca5dff972f75c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:43:44 -0700 Subject: [PATCH 26/89] fix: notify after swapping settings file store --- Sources/KeyboardShortcutSettings.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 3dddcbcafe31..e468ca6344c4 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -6,7 +6,11 @@ enum KeyboardShortcutSettings { static let didChangeNotification = Notification.Name("cmux.keyboardShortcutSettingsDidChange") static let actionUserInfoKey = "action" static let settingsFileDisplayPath = "~/.config/cmux/settings.json" - static var settingsFileStore: KeyboardShortcutSettingsFileStore = .shared + static var settingsFileStore: KeyboardShortcutSettingsFileStore = .shared { + didSet { + notifySettingsFileDidChange() + } + } enum Action: String, CaseIterable, Identifiable { // App / window From 5422a4bd30ac3f65e860f6b793b5f34bde12019f Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Mon, 6 Apr 2026 15:51:17 -0700 Subject: [PATCH 27/89] Fix sidebar PR status polling rate limits (#2585) --- .../cmux-bash-integration.bash | 131 ++- .../cmux-zsh-integration.zsh | 137 ++- Sources/TabManager.swift | 967 +++++++++++++++++- Sources/TerminalController.swift | 32 + tests/test_issue_1138_sidebar_pr_polling.py | 355 ++----- 5 files changed, 1253 insertions(+), 369 deletions(-) diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index a6f7546eae24..6218299b070f 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -190,6 +190,8 @@ _CMUX_PR_POLL_INTERVAL="${_CMUX_PR_POLL_INTERVAL:-45}" _CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}" _CMUX_PR_DEBUG="${_CMUX_PR_DEBUG:-0}" _CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}" +_CMUX_LAST_PR_ACTION="${_CMUX_LAST_PR_ACTION:-}" +_CMUX_LAST_PR_TARGET="${_CMUX_LAST_PR_TARGET:-}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}" @@ -430,6 +432,95 @@ _cmux_clear_pr_for_panel() { _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } +_cmux_record_pr_command_hint() { + local cmd="$1" + _CMUX_LAST_PR_ACTION="" + _CMUX_LAST_PR_TARGET="" + + local -a words=() + read -r -a words <<< "$cmd" + + local index=0 + local word base + while (( index < ${#words[@]} )); do + word="${words[index]}" + + case "$word" in + *=*) + index=$(( index + 1 )) + continue ;; + exec|command|builtin|noglob|time) + index=$(( index + 1 )) + continue ;; + env) + index=$(( index + 1 )) + while (( index < ${#words[@]} )); do + word="${words[index]}" + case "$word" in + -*|*=*) + index=$(( index + 1 )) + continue ;; + esac + break + done + continue ;; + esac + + base="${word##*/}" + [[ "$base" == "gh" ]] || return 0 + index=$(( index + 1 )) + break + done + + (( index + 1 < ${#words[@]} )) || return 0 + [[ "${words[index]}" == "pr" ]] || return 0 + local action="${words[index + 1]}" + action="$(printf '%s' "$action" | tr '[:upper:]' '[:lower:]')" + case "$action" in + merge|close|reopen|create|checkout|ready|edit|view) + _CMUX_LAST_PR_ACTION="$action" ;; + *) + return 0 ;; + esac + + index=$(( index + 2 )) + while (( index < ${#words[@]} )); do + word="${words[index]}" + case "$word" in + --*=*) + index=$(( index + 1 )) + continue ;; + --*) + index=$(( index + 2 )) + continue ;; + -*) + index=$(( index + 1 )) + continue ;; + *) + _CMUX_LAST_PR_TARGET="$word" + break ;; + esac + done +} + +_cmux_emit_pr_command_hint() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + [[ -n "$_CMUX_LAST_PR_ACTION" ]] || return 0 + + local payload="report_pr_action $_CMUX_LAST_PR_ACTION --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + if [[ -n "$_CMUX_LAST_PR_TARGET" ]]; then + local quoted_target="${_CMUX_LAST_PR_TARGET//\"/\\\"}" + payload+=" --target=\"$quoted_target\"" + fi + { + _cmux_send "$payload" + } >/dev/null 2>&1 & disown + _CMUX_LAST_PR_ACTION="" + _CMUX_LAST_PR_TARGET="" +} + _cmux_pr_output_indicates_no_pull_request() { local output="$1" output="$(printf '%s' "$output" | tr '[:upper:]' '[:lower:]')" @@ -833,6 +924,7 @@ _cmux_preexec_command() { _cmux_socket_is_unix && cmux_has_unix_socket=1 (( cmux_has_unix_socket )) || _cmux_has_port_scan_transport || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 + _cmux_record_pr_command_hint "$cmd" if [[ -z "$_CMUX_TTY_NAME" ]]; then local t @@ -855,6 +947,7 @@ _cmux_bash_preexec_hook() { } _cmux_prompt_command() { + local last_status=$? _cmux_tmux_sync_cmux_environment local cmux_has_unix_socket=0 @@ -977,41 +1070,15 @@ _cmux_prompt_command() { _CMUX_GIT_JOB_STARTED_AT=$now fi - # Pull request metadata is remote state. Keep polling while the shell sits - # at a prompt so newly created or merged PRs appear without another command. - local should_restart_pr_poll=0 - local should_signal_pr_probe=0 - local pr_context_changed=0 - if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then - pr_context_changed=1 - elif [[ "$git_head_changed" == "1" ]]; then - pr_context_changed=1 - fi - if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" || "$git_head_changed" == "1" ]]; then - should_restart_pr_poll=1 - elif (( _CMUX_PR_FORCE )); then - if [[ -n "$_CMUX_PR_POLL_PID" ]] && kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then - should_signal_pr_probe=1 - else - should_restart_pr_poll=1 - fi - elif [[ -z "$_CMUX_PR_POLL_PID" ]] || ! kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then - should_restart_pr_poll=1 - fi - - if (( pr_context_changed )); then + if [[ "$git_head_changed" == "1" ]]; then _cmux_pr_cache_clear _cmux_clear_pr_for_panel fi - - if (( should_signal_pr_probe )); then - _CMUX_PR_FORCE=0 - _cmux_pr_request_probe - fi - - if (( should_restart_pr_poll )); then - _CMUX_PR_FORCE=0 - _cmux_start_pr_poll_loop "$pwd" 1 + if (( last_status == 0 )); then + _cmux_emit_pr_command_hint + else + _CMUX_LAST_PR_ACTION="" + _CMUX_LAST_PR_TARGET="" fi # Ports: lightweight kick to the app's batched scanner every ~10s. diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index d479451b76bb..304ddd618207 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -193,6 +193,8 @@ typeset -g _CMUX_PR_POLL_INTERVAL=45 typeset -g _CMUX_PR_FORCE=0 typeset -g _CMUX_PR_DEBUG=${_CMUX_PR_DEBUG:-0} typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20 +typeset -g _CMUX_LAST_PR_ACTION="" +typeset -g _CMUX_LAST_PR_TARGET="" typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 @@ -537,6 +539,92 @@ _cmux_report_git_branch_for_path() { fi } +_cmux_record_pr_command_hint() { + local cmd="$1" + _CMUX_LAST_PR_ACTION="" + _CMUX_LAST_PR_TARGET="" + + local -a words + words=("${(z)cmd}") + + local index=1 + local word base + while (( index <= ${#words} )); do + word="${words[index]}" + + case "$word" in + *=*) + index=$(( index + 1 )) + continue ;; + exec|command|builtin|noglob|time) + index=$(( index + 1 )) + continue ;; + env) + index=$(( index + 1 )) + while (( index <= ${#words} )); do + word="${words[index]}" + case "$word" in + -*|*=*) + index=$(( index + 1 )) + continue ;; + esac + break + done + continue ;; + esac + + base="${word:t}" + [[ "$base" == "gh" ]] || return 0 + index=$(( index + 1 )) + break + done + + (( index + 1 <= ${#words} )) || return 0 + [[ "${words[index]}" == "pr" ]] || return 0 + local action="${words[index + 1]:l}" + case "$action" in + merge|close|reopen|create|checkout|ready|edit|view) + _CMUX_LAST_PR_ACTION="$action" ;; + *) + return 0 ;; + esac + + index=$(( index + 2 )) + while (( index <= ${#words} )); do + word="${words[index]}" + case "$word" in + --*=*) + index=$(( index + 1 )) + continue ;; + --*) + index=$(( index + 2 )) + continue ;; + -*) + index=$(( index + 1 )) + continue ;; + *) + _CMUX_LAST_PR_TARGET="$word" + break ;; + esac + done +} + +_cmux_emit_pr_command_hint() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + [[ -n "$_CMUX_LAST_PR_ACTION" ]] || return 0 + + local payload="report_pr_action $_CMUX_LAST_PR_ACTION --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + if [[ -n "$_CMUX_LAST_PR_TARGET" ]]; then + local quoted_target="${_CMUX_LAST_PR_TARGET//\"/\\\"}" + payload+=" --target=\"$quoted_target\"" + fi + _cmux_send_bg "$payload" + _CMUX_LAST_PR_ACTION="" + _CMUX_LAST_PR_TARGET="" +} + _cmux_clear_pr_for_panel() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 @@ -919,11 +1007,6 @@ _cmux_start_git_head_watch() { _cmux_pr_cache_clear _cmux_report_git_branch_for_path "$watch_pwd" _cmux_clear_pr_for_panel - if [[ -n "$_CMUX_PR_POLL_PID" ]] && kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then - _cmux_pr_request_probe - else - _cmux_run_pr_probe_with_timeout "$watch_pwd" 1 || true - fi fi done } >/dev/null 2>&1 &! @@ -983,6 +1066,7 @@ _cmux_command_starts_nested_shell() { _cmux_preexec() { _cmux_restore_terminal_identity_after_startup _cmux_tmux_sync_cmux_environment + local cmd="${1## }" if [[ -z "$_CMUX_TTY_NAME" ]]; then local t @@ -993,9 +1077,9 @@ _cmux_preexec() { _CMUX_CMD_START="$(_cmux_now)" _cmux_report_shell_activity_state running + _cmux_record_pr_command_hint "$cmd" # Heuristic: commands that may change git branch/dirty state without changing $PWD. - local cmd="${1## }" case "$cmd" in git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *) _CMUX_GIT_FORCE=1 @@ -1014,6 +1098,7 @@ _cmux_preexec() { } _cmux_precmd() { + local last_status=$? _cmux_stop_git_head_watch _cmux_tmux_sync_cmux_environment @@ -1149,43 +1234,15 @@ _cmux_precmd() { _CMUX_GIT_JOB_STARTED_AT=$now fi fi - - # Pull request metadata is remote state. Keep a lightweight background poll - # alive while the shell is idle so gh-created PRs and merge status changes - # appear even without another prompt. - local should_restart_pr_poll=0 - local should_signal_pr_probe=0 - local pr_context_changed=0 - if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then - pr_context_changed=1 - elif (( git_head_changed )); then - pr_context_changed=1 - fi - if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then - should_restart_pr_poll=1 - elif (( _CMUX_PR_FORCE )); then - if [[ -n "$_CMUX_PR_POLL_PID" ]] && kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then - should_signal_pr_probe=1 - else - should_restart_pr_poll=1 - fi - elif [[ -z "$_CMUX_PR_POLL_PID" ]] || ! kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then - should_restart_pr_poll=1 - fi - - if (( pr_context_changed )); then + if (( git_head_changed )); then _cmux_pr_cache_clear _cmux_clear_pr_for_panel fi - - if (( should_signal_pr_probe )); then - _CMUX_PR_FORCE=0 - _cmux_pr_request_probe - fi - - if (( should_restart_pr_poll )); then - _CMUX_PR_FORCE=0 - _cmux_start_pr_poll_loop "$pwd" 1 + if (( last_status == 0 )); then + _cmux_emit_pr_command_hint + else + _CMUX_LAST_PR_ACTION="" + _CMUX_LAST_PR_TARGET="" fi # Ports: lightweight kick to the app's batched scanner. diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 5a793c9c3090..722e970e5eac 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -9,6 +9,570 @@ import Combine // The old Tab class is replaced by Workspace typealias Tab = Workspace +private actor GitHubPullRequestRESTPoller { + struct Candidate: Sendable { + let workspaceId: UUID + let panelId: UUID + let branch: String + let repoSlugs: [String] + let currentPullRequest: CurrentPullRequest? + } + + struct CurrentPullRequest: Sendable { + let number: Int + let urlString: String + let repoSlug: String? + let statusRawValue: String + let branch: String? + let checksRawValue: String? + } + + struct RefreshResult: Sendable { + let workspaceId: UUID + let panelId: UUID + let resolution: Resolution + } + + enum Resolution: Sendable { + case unsupportedRepository + case notFound + case resolved(ResolvedPullRequest) + case transientFailure + } + + struct ResolvedPullRequest: Sendable { + let number: Int + let urlString: String + let statusRawValue: String + let branch: String? + let checksRawValue: String? + } + + private struct WorkspacePanelKey: Hashable, Sendable { + let workspaceId: UUID + let panelId: UUID + } + + private struct PullRequestKey: Hashable, Sendable { + let repoSlug: String + let number: Int + } + + private struct CachedValue<Payload: Sendable>: Sendable { + let etag: String? + let payload: Payload + } + + private struct HTTPResponse: Sendable { + let statusCode: Int + let data: Data + let etag: String? + let rateLimitResetAt: Date? + } + + private enum RepoLookupResult: Sendable { + case success([OpenPullRequest]) + case transientFailure + } + + private struct OpenPullRequest: Decodable, Sendable { + struct PullHead: Decodable, Sendable { + let ref: String + } + + let number: Int + let state: String + let htmlURL: String + let updatedAt: String? + let head: PullHead + + enum CodingKeys: String, CodingKey { + case number + case state + case htmlURL = "html_url" + case updatedAt = "updated_at" + case head + } + } + + private struct PullRequestDetail: Decodable, Sendable { + let number: Int + let state: String + let htmlURL: String + let mergedAt: String? + + enum CodingKeys: String, CodingKey { + case number + case state + case htmlURL = "html_url" + case mergedAt = "merged_at" + } + } + + private let session: URLSession + private var authToken: String? + private var attemptedAuthTokenLookup = false + private var openPullRequestCacheByRepo: [String: CachedValue<[OpenPullRequest]>] = [:] + private var pullRequestDetailCacheByKey: [PullRequestKey: CachedValue<PullRequestDetail>] = [:] + private var resourceBackoffUntil: [String: Date] = [:] + + init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 8 + configuration.timeoutIntervalForResource = 8 + session = URLSession(configuration: configuration) + } + + func refresh(candidates: [Candidate]) async -> [RefreshResult] { + guard !candidates.isEmpty else { return [] } + + let repoSlugs = Set(candidates.flatMap(\.repoSlugs)) + let repoResults = await fetchOpenPullRequests(repoSlugs: repoSlugs) + + var resultsByKey: [WorkspacePanelKey: Resolution] = [:] + var pendingDetailLookups: [WorkspacePanelKey: PullRequestKey] = [:] + var detailTargets: Set<PullRequestKey> = [] + var detailNeedsByKey: [WorkspacePanelKey: Bool] = [:] + + for candidate in candidates { + let key = WorkspacePanelKey(workspaceId: candidate.workspaceId, panelId: candidate.panelId) + guard !candidate.repoSlugs.isEmpty else { + resultsByKey[key] = .unsupportedRepository + continue + } + + var sawTransientFailure = false + var resolvedOpenPullRequest: OpenPullRequest? + + for repoSlug in candidate.repoSlugs { + guard let repoResult = repoResults[repoSlug] else { continue } + switch repoResult { + case .success(let pullRequests): + let matchingPullRequests = pullRequests.filter { + $0.head.ref.trimmingCharacters(in: .whitespacesAndNewlines) == candidate.branch + } + if let preferred = Self.preferredOpenPullRequest(from: matchingPullRequests) { + resolvedOpenPullRequest = preferred + break + } + case .transientFailure: + sawTransientFailure = true + } + } + + if let resolvedOpenPullRequest, + let resolved = Self.resolvedPullRequest(from: resolvedOpenPullRequest, branch: candidate.branch) { + resultsByKey[key] = .resolved(resolved) + continue + } + + if let currentPullRequest = candidate.currentPullRequest { + switch currentPullRequest.statusRawValue { + case SidebarPullRequestStatus.open.rawValue: + if let repoSlug = currentPullRequest.repoSlug { + let detailKey = PullRequestKey(repoSlug: repoSlug, number: currentPullRequest.number) + pendingDetailLookups[key] = detailKey + detailTargets.insert(detailKey) + detailNeedsByKey[key] = sawTransientFailure + continue + } + resultsByKey[key] = sawTransientFailure ? .transientFailure : .notFound + case SidebarPullRequestStatus.merged.rawValue, + SidebarPullRequestStatus.closed.rawValue: + resultsByKey[key] = .resolved( + ResolvedPullRequest( + number: currentPullRequest.number, + urlString: currentPullRequest.urlString, + statusRawValue: currentPullRequest.statusRawValue, + branch: currentPullRequest.branch ?? candidate.branch, + checksRawValue: currentPullRequest.checksRawValue + ) + ) + default: + resultsByKey[key] = sawTransientFailure ? .transientFailure : .notFound + } + continue + } + + resultsByKey[key] = sawTransientFailure ? .transientFailure : .notFound + } + + let detailResults = await fetchPullRequestDetails(keys: detailTargets) + + for candidate in candidates { + let key = WorkspacePanelKey(workspaceId: candidate.workspaceId, panelId: candidate.panelId) + guard let detailKey = pendingDetailLookups[key] else { continue } + guard let detail = detailResults[detailKey] ?? nil else { + let needsTransientFailure = detailNeedsByKey[key] ?? false + resultsByKey[key] = needsTransientFailure ? .transientFailure : .notFound + continue + } + + guard let statusRawValue = Self.pullRequestStatusRawValue( + from: detail.state, + mergedAt: detail.mergedAt + ) else { + let needsTransientFailure = detailNeedsByKey[key] ?? false + resultsByKey[key] = needsTransientFailure ? .transientFailure : .notFound + continue + } + + resultsByKey[key] = .resolved( + ResolvedPullRequest( + number: detail.number, + urlString: detail.htmlURL, + statusRawValue: statusRawValue, + branch: candidate.branch, + checksRawValue: nil + ) + ) + } + + return candidates.map { candidate in + let key = WorkspacePanelKey(workspaceId: candidate.workspaceId, panelId: candidate.panelId) + return RefreshResult( + workspaceId: candidate.workspaceId, + panelId: candidate.panelId, + resolution: resultsByKey[key] ?? .notFound + ) + } + } + + private struct OpenPullRequestFetchPlan: Sendable { + let repoSlug: String + let resourceKey: String + let cached: CachedValue<[OpenPullRequest]>? + } + + private struct PullRequestDetailFetchPlan: Sendable { + let key: PullRequestKey + let resourceKey: String + let cached: CachedValue<PullRequestDetail>? + } + + private func fetchOpenPullRequests( + repoSlugs: Set<String> + ) async -> [String: RepoLookupResult] { + let now = Date() + var results: [String: RepoLookupResult] = [:] + var plansByRepo: [String: OpenPullRequestFetchPlan] = [:] + + for repoSlug in repoSlugs { + let resourceKey = "open:\(repoSlug)" + if let backoffUntil = resourceBackoffUntil[resourceKey], + backoffUntil > now { + if let cached = openPullRequestCacheByRepo[repoSlug] { + results[repoSlug] = .success(cached.payload) + } else { + results[repoSlug] = .transientFailure + } + continue + } + + plansByRepo[repoSlug] = OpenPullRequestFetchPlan( + repoSlug: repoSlug, + resourceKey: resourceKey, + cached: openPullRequestCacheByRepo[repoSlug] + ) + } + + guard !plansByRepo.isEmpty else { return results } + + let authHeader = authHeaderValue() + let session = self.session + let responses = await withTaskGroup( + of: (String, RequestResult).self, + returning: [(String, RequestResult)].self + ) { group in + for plan in plansByRepo.values { + let endpoint = "repos/\(plan.repoSlug)/pulls?state=open&sort=updated&direction=desc&per_page=100" + group.addTask { + let response = await Self.performRequest( + session: session, + endpoint: endpoint, + ifNoneMatch: plan.cached?.etag, + authHeader: authHeader + ) + return (plan.repoSlug, response) + } + } + + var collected: [(String, RequestResult)] = [] + for await response in group { + collected.append(response) + } + return collected + } + + for (repoSlug, response) in responses { + guard let plan = plansByRepo[repoSlug] else { continue } + switch response { + case .success(let httpResponse): + switch httpResponse.statusCode { + case 200: + guard let pullRequests = Self.decodeJSON([OpenPullRequest].self, from: httpResponse.data) else { + results[repoSlug] = .transientFailure + continue + } + openPullRequestCacheByRepo[repoSlug] = CachedValue( + etag: httpResponse.etag, + payload: pullRequests + ) + resourceBackoffUntil.removeValue(forKey: plan.resourceKey) + results[repoSlug] = .success(pullRequests) + case 304: + if let cached = plan.cached { + resourceBackoffUntil.removeValue(forKey: plan.resourceKey) + results[repoSlug] = .success(cached.payload) + } else { + results[repoSlug] = .transientFailure + } + default: + if let resetAt = httpResponse.rateLimitResetAt { + resourceBackoffUntil[plan.resourceKey] = resetAt + } + if let cached = plan.cached { + results[repoSlug] = .success(cached.payload) + } else { + results[repoSlug] = .transientFailure + } + } + case .failure: + if let cached = plan.cached { + results[repoSlug] = .success(cached.payload) + } else { + results[repoSlug] = .transientFailure + } + } + } + + return results + } + + private func fetchPullRequestDetails( + keys: Set<PullRequestKey> + ) async -> [PullRequestKey: PullRequestDetail?] { + let now = Date() + var results: [PullRequestKey: PullRequestDetail?] = [:] + var plansByKey: [PullRequestKey: PullRequestDetailFetchPlan] = [:] + + for key in keys { + let resourceKey = "detail:\(key.repoSlug)#\(key.number)" + if let backoffUntil = resourceBackoffUntil[resourceKey], + backoffUntil > now { + results[key] = pullRequestDetailCacheByKey[key]?.payload + continue + } + + plansByKey[key] = PullRequestDetailFetchPlan( + key: key, + resourceKey: resourceKey, + cached: pullRequestDetailCacheByKey[key] + ) + } + + guard !plansByKey.isEmpty else { return results } + + let authHeader = authHeaderValue() + let session = self.session + let responses = await withTaskGroup( + of: (PullRequestKey, RequestResult).self, + returning: [(PullRequestKey, RequestResult)].self + ) { group in + for plan in plansByKey.values { + let endpoint = "repos/\(plan.key.repoSlug)/pulls/\(plan.key.number)" + group.addTask { + let response = await Self.performRequest( + session: session, + endpoint: endpoint, + ifNoneMatch: plan.cached?.etag, + authHeader: authHeader + ) + return (plan.key, response) + } + } + + var collected: [(PullRequestKey, RequestResult)] = [] + for await response in group { + collected.append(response) + } + return collected + } + + for (key, response) in responses { + guard let plan = plansByKey[key] else { continue } + switch response { + case .success(let httpResponse): + switch httpResponse.statusCode { + case 200: + guard let detail = Self.decodeJSON(PullRequestDetail.self, from: httpResponse.data) else { + results[key] = nil + continue + } + pullRequestDetailCacheByKey[key] = CachedValue( + etag: httpResponse.etag, + payload: detail + ) + resourceBackoffUntil.removeValue(forKey: plan.resourceKey) + results[key] = detail + case 304: + resourceBackoffUntil.removeValue(forKey: plan.resourceKey) + results[key] = plan.cached?.payload + default: + if let resetAt = httpResponse.rateLimitResetAt { + resourceBackoffUntil[plan.resourceKey] = resetAt + } + results[key] = plan.cached?.payload + } + case .failure: + results[key] = plan.cached?.payload + } + } + + return results + } + + private enum RequestResult: Sendable { + case success(HTTPResponse) + case failure + } + + private nonisolated static func performRequest( + session: URLSession, + endpoint: String, + ifNoneMatch: String?, + authHeader: String? + ) async -> RequestResult { + guard let url = URL(string: "https://api.github.com/\(endpoint)") else { + return .failure + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("cmux-sidebar-pr-poller", forHTTPHeaderField: "User-Agent") + if let ifNoneMatch, !ifNoneMatch.isEmpty { + request.setValue(ifNoneMatch, forHTTPHeaderField: "If-None-Match") + } + if let authHeader { + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + } + + do { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return .failure + } + return .success( + HTTPResponse( + statusCode: httpResponse.statusCode, + data: data, + etag: httpResponse.value(forHTTPHeaderField: "ETag"), + rateLimitResetAt: Self.rateLimitResetDate(from: httpResponse) + ) + ) + } catch { + return .failure + } + } + + private func authHeaderValue() -> String? { + if let token = authToken, !token.isEmpty { + return "Bearer \(token)" + } + guard !attemptedAuthTokenLookup else { return nil } + attemptedAuthTokenLookup = true + + let environment = ProcessInfo.processInfo.environment + if let envToken = environment["GH_TOKEN"] ?? environment["GITHUB_TOKEN"] { + let trimmed = envToken.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + authToken = trimmed + return "Bearer \(trimmed)" + } + } + + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["gh", "auth", "token"] + process.standardOutput = stdout + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let tokenData = stdout.fileHandleForReading.readDataToEndOfFile() + let token = String(data: tokenData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !token.isEmpty else { return nil } + authToken = token + return "Bearer \(token)" + } catch { + return nil + } + } + + private static func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) -> T? { + try? JSONDecoder().decode(T.self, from: data) + } + + private static func rateLimitResetDate(from response: HTTPURLResponse) -> Date? { + guard response.value(forHTTPHeaderField: "X-RateLimit-Remaining") == "0", + let resetValue = response.value(forHTTPHeaderField: "X-RateLimit-Reset"), + let resetInterval = TimeInterval(resetValue) else { + return nil + } + return Date(timeIntervalSince1970: resetInterval) + } + + private static func preferredOpenPullRequest( + from pullRequests: [OpenPullRequest] + ) -> OpenPullRequest? { + pullRequests.max { lhs, rhs in + let lhsUpdatedAt = lhs.updatedAt ?? "" + let rhsUpdatedAt = rhs.updatedAt ?? "" + if lhsUpdatedAt != rhsUpdatedAt { + return lhsUpdatedAt < rhsUpdatedAt + } + return lhs.number < rhs.number + } + } + + private static func resolvedPullRequest( + from pullRequest: OpenPullRequest, + branch: String + ) -> ResolvedPullRequest? { + guard let statusRawValue = pullRequestStatusRawValue(from: pullRequest.state, mergedAt: nil) else { + return nil + } + return ResolvedPullRequest( + number: pullRequest.number, + urlString: pullRequest.htmlURL, + statusRawValue: statusRawValue, + branch: branch, + checksRawValue: nil + ) + } + + private static func pullRequestStatusRawValue( + from rawState: String, + mergedAt: String? + ) -> String? { + switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { + case "OPEN": + return SidebarPullRequestStatus.open.rawValue + case "CLOSED": + return (mergedAt?.isEmpty == false) + ? SidebarPullRequestStatus.merged.rawValue + : SidebarPullRequestStatus.closed.rawValue + default: + return nil + } + } +} + enum NewWorkspacePlacement: String, CaseIterable, Identifiable { case top case afterCurrent @@ -716,6 +1280,7 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( @MainActor class TabManager: ObservableObject { private enum WorkspacePullRequestSnapshot: Equatable { + case deferred case unsupportedRepository case notFound case resolved(SidebarPullRequestState) @@ -768,6 +1333,11 @@ class TabManager: ObservableObject { private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] private static let workspaceGitMetadataPollInterval: TimeInterval = 30 private static let selectedWorkspaceGitMetadataPollInterval: TimeInterval = 5 + private static let workspacePullRequestPollTickInterval: TimeInterval = 5 + private static let workspacePullRequestIdlePollInterval: TimeInterval = 60 + private static let selectedWorkspacePullRequestIdlePollInterval: TimeInterval = 15 + private static let workspacePullRequestBurstPollInterval: TimeInterval = 5 + private static let workspacePullRequestBurstDuration: TimeInterval = 30 private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0 @Published var selectedTabId: UUID? { willSet { @@ -855,9 +1425,14 @@ class TabManager: ObservableObject { label: "com.cmux.initial-workspace-git-probe", qos: .utility ) + private let workspacePullRequestRESTPoller = GitHubPullRequestRESTPoller() private var workspaceGitProbeGenerationByKey: [WorkspaceGitProbeKey: UUID] = [:] private var workspaceGitProbeTimersByKey: [WorkspaceGitProbeKey: [DispatchSourceTimer]] = [:] private var workspaceGitTrackedDirectoryByKey: [WorkspaceGitProbeKey: String] = [:] + private var workspacePullRequestNextPollAtByKey: [WorkspaceGitProbeKey: Date] = [:] + private var workspacePullRequestBurstUntilByKey: [WorkspaceGitProbeKey: Date] = [:] + private var workspacePullRequestPollTimer: DispatchSourceTimer? + private var workspacePullRequestRefreshTask: Task<Void, Never>? // Recent tab history for back/forward navigation (like browser history) private var tabHistory: [UUID] = [] @@ -939,6 +1514,7 @@ class TabManager: ObservableObject { startAgentPIDSweepTimer() startWorkspaceGitMetadataPollTimer() startSelectedWorkspaceGitMetadataPollTimer() + startWorkspacePullRequestPollTimer() #if DEBUG setupUITestFocusShortcutsIfNeeded() setupSplitCloseRightUITestIfNeeded() @@ -952,6 +1528,8 @@ class TabManager: ObservableObject { agentPIDSweepTimer?.cancel() workspaceGitMetadataPollTimer?.cancel() selectedWorkspaceGitMetadataPollTimer?.cancel() + workspacePullRequestPollTimer?.cancel() + workspacePullRequestRefreshTask?.cancel() } // MARK: - Agent PID Sweep @@ -1009,6 +1587,20 @@ class TabManager: ObservableObject { selectedWorkspaceGitMetadataPollTimer = timer } + private func startWorkspacePullRequestPollTimer() { + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + let interval = Self.workspacePullRequestPollTickInterval + timer.schedule(deadline: .now() + interval, repeating: interval) + timer.setEventHandler { [weak self] in + guard let self else { return } + DispatchQueue.main.async { [weak self] in + self?.refreshTrackedWorkspacePullRequestsIfNeeded(reason: "timer") + } + } + timer.resume() + workspacePullRequestPollTimer = timer + } + private func refreshTrackedWorkspaceGitMetadata() { let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys) @@ -1044,6 +1636,233 @@ class TabManager: ObservableObject { panelId: focusedPanelId, reason: "selectedPeriodicPoll" ) + + scheduleWorkspacePullRequestRefresh( + workspaceId: workspace.id, + panelId: focusedPanelId, + reason: "selectedPeriodicPoll", + burst: false + ) + } + + private func refreshTrackedWorkspacePullRequestsIfNeeded(reason: String) { + guard workspacePullRequestRefreshTask == nil else { return } + + let now = Date() + var candidates: [GitHubPullRequestRESTPoller.Candidate] = [] + var requestedKeys: [WorkspaceGitProbeKey] = [] + var validKeys: Set<WorkspaceGitProbeKey> = [] + + for workspace in tabs { + for panelId in Set(workspace.panelGitBranches.keys).union(workspace.panelPullRequests.keys) { + let key = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId) + validKeys.insert(key) + guard shouldRefreshWorkspacePullRequest(key: key, now: now) else { continue } + guard let candidate = workspacePullRequestCandidate( + workspace: workspace, + panelId: panelId + ) else { + continue + } + candidates.append(candidate) + requestedKeys.append(key) + } + } + + pruneWorkspacePullRequestTracking(validKeys: validKeys) + guard !candidates.isEmpty else { return } + + let poller = workspacePullRequestRESTPoller + workspacePullRequestRefreshTask = Task { [weak self] in + let results = await poller.refresh(candidates: candidates) + await MainActor.run { [weak self] in + guard let self else { return } + self.workspacePullRequestRefreshTask = nil + self.applyWorkspacePullRequestRefreshResults( + results, + requestedKeys: requestedKeys, + now: Date(), + reason: reason + ) + } + } + } + + private func shouldRefreshWorkspacePullRequest( + key: WorkspaceGitProbeKey, + now: Date + ) -> Bool { + let nextPollAt = workspacePullRequestNextPollAtByKey[key] ?? .distantPast + return nextPollAt <= now + } + + private func workspacePullRequestCandidate( + workspace: Workspace, + panelId: UUID + ) -> GitHubPullRequestRESTPoller.Candidate? { + let branch = Self.normalizedBranchName( + workspace.panelGitBranches[panelId]?.branch + ?? workspace.panelPullRequests[panelId]?.branch + ) + guard let branch, !Self.shouldSkipWorkspacePullRequestLookup(branch: branch) else { + return nil + } + + let directory = gitProbeDirectory(for: workspace, panelId: panelId) + let repoSlugs = directory.map(Self.githubRepositorySlugs(directory:)) ?? [] + let currentPullRequest = workspace.panelPullRequests[panelId].map { + GitHubPullRequestRESTPoller.CurrentPullRequest( + number: $0.number, + urlString: $0.url.absoluteString, + repoSlug: Self.githubRepositorySlug(fromPullRequestURL: $0.url), + statusRawValue: $0.status.rawValue, + branch: $0.branch, + checksRawValue: $0.checks?.rawValue + ) + } + + return GitHubPullRequestRESTPoller.Candidate( + workspaceId: workspace.id, + panelId: panelId, + branch: branch, + repoSlugs: repoSlugs, + currentPullRequest: currentPullRequest + ) + } + + private func scheduleWorkspacePullRequestRefresh( + workspaceId: UUID, + panelId: UUID, + reason: String, + burst: Bool + ) { + let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) + workspacePullRequestNextPollAtByKey[key] = .distantPast + if burst { + let burstUntil = Date().addingTimeInterval(Self.workspacePullRequestBurstDuration) + let existingBurstUntil = workspacePullRequestBurstUntilByKey[key] ?? .distantPast + if burstUntil > existingBurstUntil { + workspacePullRequestBurstUntilByKey[key] = burstUntil + } + } +#if DEBUG + dlog( + "workspace.prRefresh.schedule workspace=\(workspaceId.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=\(reason) burst=\(burst ? 1 : 0)" + ) +#endif + refreshTrackedWorkspacePullRequestsIfNeeded(reason: reason) + } + + private func applyWorkspacePullRequestRefreshResults( + _ results: [GitHubPullRequestRESTPoller.RefreshResult], + requestedKeys: [WorkspaceGitProbeKey], + now: Date, + reason: String + ) { + let requestedKeySet = Set(requestedKeys) + for result in results { + let key = WorkspaceGitProbeKey(workspaceId: result.workspaceId, panelId: result.panelId) + guard requestedKeySet.contains(key), + let workspace = tabs.first(where: { $0.id == result.workspaceId }), + workspace.panels[result.panelId] != nil else { + continue + } + + switch result.resolution { + case .resolved(let resolvedPullRequest): + guard let status = SidebarPullRequestStatus(rawValue: resolvedPullRequest.statusRawValue), + let url = URL(string: resolvedPullRequest.urlString) else { + continue + } + workspace.updatePanelPullRequest( + panelId: result.panelId, + number: resolvedPullRequest.number, + label: "PR", + url: url, + status: status, + branch: resolvedPullRequest.branch, + checks: resolvedPullRequest.checksRawValue.flatMap(SidebarPullRequestChecksStatus.init(rawValue:)) + ) + case .notFound: + if workspace.panelPullRequests[result.panelId] != nil { + workspace.clearPanelPullRequest(panelId: result.panelId) + } + case .unsupportedRepository, .transientFailure: + break + } + + scheduleNextWorkspacePullRequestPoll( + key: key, + workspace: workspace, + panelId: result.panelId, + now: now, + resolution: result.resolution + ) + +#if DEBUG + let label: String = { + switch result.resolution { + case .unsupportedRepository: + return "unsupported" + case .notFound: + return "none" + case .transientFailure: + return "transientFailure" + case .resolved(let resolvedPullRequest): + return "#\(resolvedPullRequest.number):\(resolvedPullRequest.statusRawValue)" + } + }() + dlog( + "workspace.prRefresh.apply workspace=\(result.workspaceId.uuidString.prefix(5)) " + + "panel=\(result.panelId.uuidString.prefix(5)) result=\(label) reason=\(reason)" + ) +#endif + } + } + + private func scheduleNextWorkspacePullRequestPoll( + key: WorkspaceGitProbeKey, + workspace: Workspace, + panelId: UUID, + now: Date, + resolution: GitHubPullRequestRESTPoller.Resolution + ) { + let isSelectedFocusedPanel = selectedWorkspace?.id == workspace.id + && selectedWorkspace?.focusedPanelId == panelId + let burstUntil = workspacePullRequestBurstUntilByKey[key] + let isBursting = (burstUntil ?? .distantPast) > now + let interval: TimeInterval + + if isBursting { + interval = Self.workspacePullRequestBurstPollInterval + } else if case .transientFailure = resolution { + interval = Self.workspacePullRequestBurstPollInterval + } else if isSelectedFocusedPanel { + interval = Self.selectedWorkspacePullRequestIdlePollInterval + } else { + interval = Self.workspacePullRequestIdlePollInterval + } + + workspacePullRequestNextPollAtByKey[key] = now.addingTimeInterval(interval) + if !isBursting { + workspacePullRequestBurstUntilByKey.removeValue(forKey: key) + } + } + + private func pruneWorkspacePullRequestTracking(validKeys: Set<WorkspaceGitProbeKey>) { + workspacePullRequestNextPollAtByKey = workspacePullRequestNextPollAtByKey.filter { validKeys.contains($0.key) } + workspacePullRequestBurstUntilByKey = workspacePullRequestBurstUntilByKey.filter { validKeys.contains($0.key) } + } + + private func clearWorkspacePullRequestTracking(for key: WorkspaceGitProbeKey) { + workspacePullRequestNextPollAtByKey.removeValue(forKey: key) + workspacePullRequestBurstUntilByKey.removeValue(forKey: key) + } + + private func clearWorkspacePullRequestTracking(workspaceId: UUID) { + workspacePullRequestNextPollAtByKey = workspacePullRequestNextPollAtByKey.filter { $0.key.workspaceId != workspaceId } + workspacePullRequestBurstUntilByKey = workspacePullRequestBurstUntilByKey.filter { $0.key.workspaceId != workspaceId } } func refreshTrackedWorkspaceGitMetadataForTesting() { @@ -1586,6 +2405,7 @@ class TabManager: ObservableObject { workspaceGitTrackedDirectoryByKey = workspaceGitTrackedDirectoryByKey.filter { key, _ in key.workspaceId != workspaceId } + clearWorkspacePullRequestTracking(workspaceId: workspaceId) } private func applyWorkspaceGitMetadataSnapshot( @@ -1666,14 +2486,25 @@ class TabManager: ObservableObject { if workspace.panelPullRequests[probeKey.panelId] != nil { workspace.clearPanelPullRequest(panelId: probeKey.panelId) } - case .unsupportedRepository, .transientFailure: + case .deferred, .unsupportedRepository, .transientFailure: break } + if snapshot.branch != nil { + scheduleWorkspacePullRequestRefresh( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId, + reason: "localGitProbe", + burst: false + ) + } + #if DEBUG let branchLabel = snapshot.branch ?? "none" let prLabel: String = { switch snapshot.pullRequest { + case .deferred: + return "deferred" case .unsupportedRepository: return "unsupported" case .notFound: @@ -1697,7 +2528,7 @@ class TabManager: ObservableObject { _ snapshot: InitialWorkspaceGitMetadataSnapshot ) -> Bool { switch snapshot.pullRequest { - case .transientFailure: + case .deferred, .transientFailure: return false case .unsupportedRepository, .notFound, .resolved: return true @@ -1718,8 +2549,11 @@ class TabManager: ObservableObject { let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - let pullRequest = workspacePullRequestSnapshot(directory: directory, branch: branch) - return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest) + return InitialWorkspaceGitMetadataSnapshot( + branch: branch, + isDirty: isDirty, + pullRequest: .deferred + ) } private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? { @@ -1746,6 +2580,8 @@ class TabManager: ObservableObject { var sawTransientFailure = false for repoSlug in repoSlugs { switch workspacePullRequestSnapshot(directory: directory, branch: branch, repoSlug: repoSlug) { + case .deferred: + continue case .resolved(let pullRequest): return .resolved(pullRequest) case .transientFailure: @@ -2261,6 +3097,14 @@ class TabManager: ObservableObject { return normalizedGitHubRepositorySlug(url.path) } + private nonisolated static func githubRepositorySlug(fromPullRequestURL url: URL) -> String? { + guard let host = url.host?.lowercased(), + host == "github.com" else { + return nil + } + return normalizedGitHubRepositorySlug(url.path) + } + private nonisolated static func normalizedGitHubRepositorySlug(_ rawPath: String) -> String? { let trimmedPath = rawPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard !trimmedPath.isEmpty else { return nil } @@ -2683,6 +3527,12 @@ class TabManager: ObservableObject { tab.updatePanelDirectory(panelId: surfaceId, directory: normalized) let nextDirectory = normalizedWorkingDirectory(normalized) if previousDirectory != nextDirectory { + scheduleWorkspacePullRequestRefresh( + workspaceId: tabId, + panelId: surfaceId, + reason: "directoryChange", + burst: true + ) scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: tabId, panelId: surfaceId, @@ -2706,6 +3556,12 @@ class TabManager: ObservableObject { let probeKey = WorkspaceGitProbeKey(workspaceId: tabId, panelId: surfaceId) workspaceGitTrackedDirectoryByKey[probeKey] = directory } + scheduleWorkspacePullRequestRefresh( + workspaceId: tabId, + panelId: surfaceId, + reason: "branchChange", + burst: true + ) scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: tabId, panelId: surfaceId, @@ -2718,6 +3574,9 @@ class TabManager: ObservableObject { let hadBranch = tab.panelGitBranches[surfaceId] != nil let hadPullRequest = tab.panelPullRequests[surfaceId] != nil guard hadBranch || hadPullRequest else { return } + clearWorkspacePullRequestTracking( + for: WorkspaceGitProbeKey(workspaceId: tabId, panelId: surfaceId) + ) tab.clearPanelGitBranch(panelId: surfaceId) tab.clearPanelPullRequest(panelId: surfaceId) scheduleWorkspaceGitMetadataRefreshIfPossible( @@ -2734,6 +3593,105 @@ class TabManager: ObservableObject { ) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } tab.updatePanelShellActivityState(panelId: surfaceId, state: state) + if state == .promptIdle { + scheduleWorkspacePullRequestRefresh( + workspaceId: tabId, + panelId: surfaceId, + reason: "shellPrompt", + burst: false + ) + } + } + + func handleWorkspacePullRequestCommandHint( + tabId: UUID, + surfaceId: UUID, + action: String, + target: String? + ) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + reconcileLocalPullRequestActionIfPossible( + workspace: tab, + panelId: surfaceId, + action: action, + target: target + ) + scheduleWorkspacePullRequestRefresh( + workspaceId: tabId, + panelId: surfaceId, + reason: "commandHint:\(action)", + burst: true + ) + } + + private func reconcileLocalPullRequestActionIfPossible( + workspace: Workspace, + panelId: UUID, + action: String, + target: String? + ) { + guard let currentPullRequest = workspace.panelPullRequests[panelId], + pullRequestCommandTargetMatchesCurrentPullRequest( + target, + currentPullRequest: currentPullRequest + ) else { + return + } + + let nextStatus: SidebarPullRequestStatus + switch action { + case "merge": + guard currentPullRequest.status == .open else { return } + nextStatus = .merged + case "close": + guard currentPullRequest.status == .open else { return } + nextStatus = .closed + case "reopen": + guard currentPullRequest.status != .open else { return } + nextStatus = .open + default: + return + } + + workspace.updatePanelPullRequest( + panelId: panelId, + number: currentPullRequest.number, + label: currentPullRequest.label, + url: currentPullRequest.url, + status: nextStatus, + branch: currentPullRequest.branch, + checks: nil + ) + } + + private func pullRequestCommandTargetMatchesCurrentPullRequest( + _ rawTarget: String?, + currentPullRequest: SidebarPullRequestState + ) -> Bool { + let trimmedTarget = rawTarget?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmedTarget.isEmpty else { return true } + + let numberToken = trimmedTarget.hasPrefix("#") ? String(trimmedTarget.dropFirst()) : trimmedTarget + if let number = Int(numberToken), number == currentPullRequest.number { + return true + } + + if let targetURL = URL(string: trimmedTarget) { + if targetURL == currentPullRequest.url { + return true + } + if let lastComponent = targetURL.pathComponents.last, + let number = Int(lastComponent), + number == currentPullRequest.number { + return true + } + } + + if Self.normalizedBranchName(trimmedTarget) == Self.normalizedBranchName(currentPullRequest.branch) { + return true + } + + return false } private func normalizeDirectory(_ directory: String) -> String { @@ -2751,6 +3709,7 @@ class TabManager: ObservableObject { guard tabs.count > 1 else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) clearWorkspaceGitProbes(workspaceId: workspace.id) + clearWorkspacePullRequestTracking(workspaceId: workspace.id) sidebarSelectedWorkspaceIds.remove(workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 94cd91e8a15c..2bd106f8eed8 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1838,6 +1838,9 @@ class TerminalController { case "report_shell_state": return reportShellState(args) + case "report_pr_action": + return reportPullRequestAction(args) + case "report_pwd": return reportPwd(args) @@ -11436,6 +11439,7 @@ class TerminalController { report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning ports_kick [--tab=X] [--panel=Y] [--reason=command|refresh] - Request batched port scan for panel report_shell_state <prompt|running> [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command + report_pr_action <merge|close|reopen|create|checkout|ready|edit|view> [--target=X] [--tab=X] [--panel=Y] - Hint that a PR-affecting command completed in the panel report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory clear_ports [--tab=X] [--panel=Y] - Clear listening ports sidebar_state [--tab=X] - Dump sidebar metadata @@ -15384,6 +15388,34 @@ class TerminalController { return result } + private func reportPullRequestAction(_ args: String) -> String { + let parsed = parseOptions(args) + guard let rawAction = parsed.positional.first, !rawAction.isEmpty else { + return "ERROR: Missing PR action — usage: report_pr_action <merge|close|reopen|create|checkout|ready|edit|view> [--target=X] [--tab=X] [--panel=Y]" + } + + let action = rawAction.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let validActions = Set(["merge", "close", "reopen", "create", "checkout", "ready", "edit", "view"]) + guard validActions.contains(action) else { + return "ERROR: Invalid PR action '\(rawAction)'" + } + + let target = normalizedOptionValue(parsed.options["target"]) + return schedulePanelMetadataMutation( + args: args, + options: parsed.options, + missingPanelUsage: "report_pr_action <merge|close|reopen|create|checkout|ready|edit|view> [--target=X] [--tab=X] [--panel=Y]" + ) { tab, surfaceId in + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: tab.id) else { return } + tabManager.handleWorkspacePullRequestCommandHint( + tabId: tab.id, + surfaceId: surfaceId, + action: action, + target: target + ) + } + } + private func clearPorts(_ args: String) -> String { let parsed = parseOptions(args) var result = "OK" diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py index 3df9fd312c3b..d2121ae09737 100644 --- a/tests/test_issue_1138_sidebar_pr_polling.py +++ b/tests/test_issue_1138_sidebar_pr_polling.py @@ -1,21 +1,12 @@ #!/usr/bin/env python3 """ -Regression coverage for issue #1138. +Regression coverage for shell-side sidebar PR refresh hints. Validates that shell integration: -1) keeps polling PR state while idle and recovers after a transient gh failure -2) resolves the current branch PR via `gh pr view` instead of repository-wide - branch-name matching -3) clears stale PR state when the branch changes and the new probe fails -4) recovers when a gh probe wedges longer than the async timeout -5) keeps polling in bash after prompt-render helper commands run -6) tears down the timed-out gh probe instead of leaking it in the background -7) falls back to explicit branch lookup when implicit gh branch resolution fails -8) does not clear an existing PR badge on the first prompt while establishing - the HEAD baseline -9) stops parent-shell PR tracking before nested shells start in the same panel -10) preserves ports polling throttling after an explicit ports kick -11) preserves cached PR results across ordinary preexec cycles +1) no longer polls GitHub directly while idle at a prompt +2) emits PR action hints after successful `gh pr ...` commands +3) does not emit PR action hints after failed commands or non-PR `gh` commands +4) clears stale PR badges when the checked-out branch changes """ from __future__ import annotations @@ -76,6 +67,11 @@ def _git_stub() -> str: esac fi + if [ "$1" = "rev-parse" ] && [ "$2" = "--git-dir" ]; then + printf '%s/.git\\n' "$repo_path" + exit 0 + fi + if [ "$1" = "branch" ] && [ "$2" = "--show-current" ]; then if [ -n "$branch" ]; then printf '%s\\n' "$branch" @@ -103,188 +99,56 @@ def _gh_stub() -> str: """\ #!/bin/sh args_log="${CMUX_TEST_GH_ARGS_LOG:?}" - count_file="${CMUX_TEST_GH_COUNT_FILE:?}" - pid_file="${CMUX_TEST_GH_PID_FILE:-}" - scenario="${CMUX_TEST_SCENARIO:?}" - head_file="${CMUX_TEST_HEAD_FILE:?}" - printf '%s\\n' "$*" >> "$args_log" - - count=0 - if [ -f "$count_file" ]; then - count="$(cat "$count_file")" - fi - count=$((count + 1)) - printf '%s\\n' "$count" > "$count_file" - - if [ "$1" != "pr" ] || [ "$2" != "view" ]; then - printf 'unexpected gh args: %s\\n' "$*" >&2 - exit 9 - fi - - requested_branch="" - if [ $# -ge 3 ]; then - case "$3" in - --*) - ;; - *) - requested_branch="$3" - ;; - esac - fi - - branch="" - if [ -f "$head_file" ]; then - head_line="$(cat "$head_file")" - case "$head_line" in - ref:\ refs/heads/*) - branch="${head_line#ref: refs/heads/}" - ;; - esac - fi - - case "$scenario" in - prompt_helper_idle) - printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' - ;; - initial_prompt_preserves_pr_badge) - printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' - ;; - transient_same_context) - if [ "$count" -eq 1 ]; then - printf 'rate limit exceeded\\n' >&2 - exit 1 - fi - printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' - ;; - branch_switch_clear) - if [ "$branch" = "feature/old" ]; then - printf '111\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/111\\n' - exit 0 - fi - if [ "$branch" = "feature/new" ]; then - printf 'network unavailable\\n' >&2 - exit 1 - fi - printf 'no pull requests found for branch "%s"\\n' "$branch" >&2 - exit 1 - ;; - timeout_recovery) - if [ "$count" -eq 1 ]; then - if [ -n "$pid_file" ]; then - printf '%s\\n' "$$" > "$pid_file" - fi - sleep "${CMUX_TEST_HANG_SECONDS:-4}" - exit 0 - fi - printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' - ;; - explicit_branch_fallback) - if [ -z "$requested_branch" ]; then - printf 'no pull requests found for branch "%s"\\n' "$branch" >&2 - exit 1 - fi - if [ "$requested_branch" = "$branch" ]; then - printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' - exit 0 - fi - printf 'unexpected branch lookup: %s\\n' "$requested_branch" >&2 - exit 8 - ;; - *) - printf 'unknown scenario: %s\\n' "$scenario" >&2 - exit 2 - ;; - esac + exit 0 """ ) def _shell_command(kind: str, scenario: str) -> str: shared = { - "prompt_helper_idle": ( + "prompt_does_not_call_gh": ( 'cd "$CMUX_TEST_REPO"\n' - '_CMUX_PR_POLL_INTERVAL=1\n' - '_cmux_prompt_entry\n' - ': "$(/bin/printf helper)"\n' - 'sleep 3\n' - '_cmux_cleanup\n' - ), - "transient_same_context": ( - 'cd "$CMUX_TEST_REPO"\n' - '_CMUX_PR_POLL_INTERVAL=1\n' - '_cmux_prompt_entry\n' - 'sleep 3\n' - '_cmux_cleanup\n' - ), - "branch_switch_clear": ( - 'cd "$CMUX_TEST_REPO"\n' - '_CMUX_PR_POLL_INTERVAL=10\n' '_cmux_prompt_entry\n' 'sleep 1\n' - 'printf \'ref: refs/heads/feature/new\\n\' > "$CMUX_TEST_HEAD_FILE"\n' - '_cmux_prompt_entry\n' - 'sleep 2\n' '_cmux_cleanup\n' ), - "timeout_recovery": ( + "merge_action": ( 'cd "$CMUX_TEST_REPO"\n' - '_CMUX_PR_POLL_INTERVAL=1\n' - '_CMUX_ASYNC_JOB_TIMEOUT=1\n' + '_cmux_pr_command_entry "gh pr merge"\n' '_cmux_prompt_entry\n' - 'sleep 4\n' - '_cmux_cleanup\n' - ), - "explicit_branch_fallback": ( - 'cd "$CMUX_TEST_REPO"\n' - '_CMUX_PR_POLL_INTERVAL=10\n' - '_cmux_prompt_entry\n' - 'sleep 2\n' + 'sleep 1\n' '_cmux_cleanup\n' ), - "initial_prompt_preserves_pr_badge": ( + "close_action_target": ( 'cd "$CMUX_TEST_REPO"\n' - '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_pr_command_entry "gh pr close 2580"\n' '_cmux_prompt_entry\n' - 'sleep 2\n' + 'sleep 1\n' '_cmux_cleanup\n' ), - "nested_shell_stops_parent_poll": ( + "failed_merge_no_action": ( 'cd "$CMUX_TEST_REPO"\n' - '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_pr_command_entry "gh pr merge"\n' + 'false\n' '_cmux_prompt_entry\n' - 'parent_pid="$_CMUX_PR_POLL_PID"\n' - '[ -n "$parent_pid" ] || { echo "missing parent poll pid" >&2; exit 41; }\n' - '_cmux_nested_shell_entry\n' 'sleep 1\n' - 'if kill -0 "$parent_pid" 2>/dev/null; then\n' - ' echo "parent poller still running" >&2\n' - ' exit 42\n' - 'fi\n' - 'parent_watch_pid="${_CMUX_GIT_HEAD_WATCH_PID:-}"\n' - 'if [ -n "$parent_watch_pid" ] && kill -0 "$parent_watch_pid" 2>/dev/null; then\n' - ' echo "parent HEAD watcher still running" >&2\n' - ' exit 43\n' - 'fi\n' '_cmux_cleanup\n' ), - "ports_kick_throttle": ( + "non_pr_gh_no_action": ( 'cd "$CMUX_TEST_REPO"\n' - '_CMUX_PR_POLL_INTERVAL=10\n' - '_cmux_ports_kick\n' + '_cmux_pr_command_entry "gh issue view 1"\n' '_cmux_prompt_entry\n' 'sleep 1\n' - '_cmux_prompt_entry\n' '_cmux_cleanup\n' ), - "preexec_preserves_pr_cache": ( + "head_change_clears_pr": ( 'cd "$CMUX_TEST_REPO"\n' - '_CMUX_PR_POLL_INTERVAL=10\n' '_cmux_prompt_entry\n' 'sleep 1\n' - '_cmux_regular_command_entry\n' + 'printf \'ref: refs/heads/feature/new\\n\' > "$CMUX_TEST_HEAD_FILE"\n' '_cmux_prompt_entry\n' - 'sleep 1\n' + 'sleep 2\n' '_cmux_cleanup\n' ), }[scenario] @@ -295,8 +159,7 @@ def _shell_command(kind: str, scenario: str) -> str: source "$CMUX_TEST_SCRIPT" _cmux_send() {{ print -r -- "$1" >> "$CMUX_TEST_SEND_LOG"; }} _cmux_prompt_entry() {{ _cmux_precmd; }} - _cmux_nested_shell_entry() {{ _cmux_preexec zsh; }} - _cmux_regular_command_entry() {{ _cmux_preexec "echo hi"; }} + _cmux_pr_command_entry() {{ _cmux_preexec "$1"; }} _cmux_cleanup() {{ _cmux_zshexit; }} {shared}""" ) @@ -307,8 +170,7 @@ def _shell_command(kind: str, scenario: str) -> str: source "$CMUX_TEST_SCRIPT" _cmux_send() {{ printf '%s\\n' "$1" >> "$CMUX_TEST_SEND_LOG"; }} _cmux_prompt_entry() {{ _cmux_prompt_command; }} - _cmux_nested_shell_entry() {{ _cmux_bash_preexec_hook bash; }} - _cmux_regular_command_entry() {{ _cmux_bash_preexec_hook "echo hi"; }} + _cmux_pr_command_entry() {{ _cmux_bash_preexec_hook "$1"; }} _cmux_cleanup() {{ type _cmux_bash_cleanup >/dev/null 2>&1 && _cmux_bash_cleanup; }} {shared}""" ) @@ -322,38 +184,18 @@ def _read_lines(path: Path) -> list[str]: return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] -def _report_line(number: int) -> str: - return ( - f"report_pr {number} https://github.com/manaflow-ai/cmux/pull/{number} " - "--state=open --tab=00000000-0000-0000-0000-000000000001 " - "--panel=00000000-0000-0000-0000-000000000002" - ) - - -def _pid_exists(pid: int) -> bool: - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except PermissionError: - return True - return True - - def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, scenario: str) -> tuple[int, str]: bindir = base / "bin" repo = base / "repo" repo_git = repo / ".git" socket_path = base / "cmux.sock" send_log = base / f"{shell}-{scenario}-send.log" - gh_count_file = base / f"{shell}-{scenario}-gh-count.txt" gh_args_log = base / f"{shell}-{scenario}-gh-args.log" - gh_pid_file = base / f"{shell}-{scenario}-gh-pid.txt" head_file = repo_git / "HEAD" bindir.mkdir(parents=True, exist_ok=True) repo_git.mkdir(parents=True, exist_ok=True) - initial_branch = "feature/old" if scenario == "branch_switch_clear" else "feature/issue-1138" + initial_branch = "feature/old" if scenario == "head_change_clears_pr" else "feature/issue-1138" head_file.write_text(f"ref: refs/heads/{initial_branch}\n", encoding="utf-8") _write_executable(bindir / "git", _git_stub()) _write_executable(bindir / "gh", _gh_stub()) @@ -366,12 +208,8 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc env["CMUX_TEST_SCRIPT"] = str(script) env["CMUX_TEST_REPO"] = str(repo) env["CMUX_TEST_SEND_LOG"] = str(send_log) - env["CMUX_TEST_GH_COUNT_FILE"] = str(gh_count_file) env["CMUX_TEST_GH_ARGS_LOG"] = str(gh_args_log) - env["CMUX_TEST_GH_PID_FILE"] = str(gh_pid_file) - env["CMUX_TEST_SCENARIO"] = scenario env["CMUX_TEST_HEAD_FILE"] = str(head_file) - env["CMUX_TEST_HANG_SECONDS"] = "4" with BoundUnixSocket(socket_path): result = subprocess.run( @@ -388,109 +226,43 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc send_lines = _read_lines(send_log) gh_args_lines = _read_lines(gh_args_log) - gh_count = int((gh_count_file.read_text(encoding="utf-8").strip() or "0")) if gh_count_file.exists() else 0 - - scenarios_requiring_gh = { - "prompt_helper_idle", - "transient_same_context", - "branch_switch_clear", - "timeout_recovery", - "explicit_branch_fallback", - "initial_prompt_preserves_pr_badge", - "ports_kick_throttle", - "preexec_preserves_pr_cache", - } - if scenario in scenarios_requiring_gh: - if not gh_args_lines: - return (1, f"{shell}/{scenario}: expected at least one gh invocation") - if any(not line.startswith("pr view ") for line in gh_args_lines): - return (1, f"{shell}/{scenario}: expected gh pr view only\n" + "\n".join(gh_args_lines)) - - if scenario == "prompt_helper_idle": - if gh_count < 2: - return (1, f"{shell}/{scenario}: expected idle polling to survive prompt helpers, saw {gh_count}") - if _report_line(1138) not in send_lines: - return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) - return (0, f"{shell}/{scenario}: ok") - - if scenario == "transient_same_context": - if gh_count < 2: - return (1, f"{shell}/{scenario}: expected at least 2 gh probes while idle, saw {gh_count}") - if any(line.startswith("clear_pr ") for line in send_lines): - return (1, f"{shell}/{scenario}: transient failure should not clear PR state\n" + "\n".join(send_lines)) - if _report_line(1138) not in send_lines: - return (1, f"{shell}/{scenario}: expected recovered report_pr payload\n" + "\n".join(send_lines)) - return (0, f"{shell}/{scenario}: ok") - - if scenario == "branch_switch_clear": - old_report = _report_line(111) - if old_report not in send_lines: - return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines)) - try: - old_index = send_lines.index(old_report) - except ValueError: - return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines)) - clear_indices = [idx for idx, line in enumerate(send_lines) if line.startswith("clear_pr ")] - if not clear_indices: - return (1, f"{shell}/{scenario}: expected clear_pr after branch change\n" + "\n".join(send_lines)) - if clear_indices[0] <= old_index: - return (1, f"{shell}/{scenario}: clear_pr happened before old report\n" + "\n".join(send_lines)) - return (0, f"{shell}/{scenario}: ok") + pr_action_lines = [line for line in send_lines if line.startswith("report_pr_action ")] - if scenario == "timeout_recovery": - if gh_count < 2: - return (1, f"{shell}/{scenario}: expected timed-out probe to be retried, saw {gh_count}") - if _report_line(1138) not in send_lines: - return (1, f"{shell}/{scenario}: missing report_pr after timeout recovery\n" + "\n".join(send_lines)) - if gh_pid_file.exists(): - gh_pid = int(gh_pid_file.read_text(encoding="utf-8").strip() or "0") - if gh_pid > 0 and _pid_exists(gh_pid): - return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}") + if scenario == "prompt_does_not_call_gh": + if gh_args_lines: + return (1, f"{shell}/{scenario}: expected no gh invocations\n" + "\n".join(gh_args_lines)) return (0, f"{shell}/{scenario}: ok") - if scenario == "explicit_branch_fallback": - if _report_line(1138) not in send_lines: - return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) - if not any(line.startswith("pr view feature/issue-1138 ") for line in gh_args_lines): - return ( - 1, - f"{shell}/{scenario}: expected explicit branch fallback\n" + "\n".join(gh_args_lines), - ) + if scenario == "merge_action": + expected = "report_pr_action merge --tab=00000000-0000-0000-0000-000000000001 --panel=00000000-0000-0000-0000-000000000002" + if expected not in pr_action_lines: + return (1, f"{shell}/{scenario}: missing merge action hint\n" + "\n".join(send_lines)) + if gh_args_lines: + return (1, f"{shell}/{scenario}: expected no gh invocations\n" + "\n".join(gh_args_lines)) return (0, f"{shell}/{scenario}: ok") - if scenario == "initial_prompt_preserves_pr_badge": - if _report_line(1138) not in send_lines: - return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) - if any(line.startswith("clear_pr ") for line in send_lines): - return ( - 1, - f"{shell}/{scenario}: initial prompt should not clear an existing PR badge\n" - + "\n".join(send_lines), - ) + if scenario == "close_action_target": + expected = "report_pr_action close --tab=00000000-0000-0000-0000-000000000001 --panel=00000000-0000-0000-0000-000000000002 --target=\"2580\"" + if expected not in pr_action_lines: + return (1, f"{shell}/{scenario}: missing close action hint\n" + "\n".join(send_lines)) return (0, f"{shell}/{scenario}: ok") - if scenario == "ports_kick_throttle": - ports_kicks = [line for line in send_lines if line.startswith("ports_kick ")] - if len(ports_kicks) != 1: - return ( - 1, - f"{shell}/{scenario}: expected exactly one ports_kick across explicit kick + throttled prompts\n" - + "\n".join(send_lines), - ) + if scenario == "failed_merge_no_action": + if pr_action_lines: + return (1, f"{shell}/{scenario}: failed command should not emit PR action hint\n" + "\n".join(send_lines)) return (0, f"{shell}/{scenario}: ok") - if scenario == "nested_shell_stops_parent_poll": + if scenario == "non_pr_gh_no_action": + if pr_action_lines: + return (1, f"{shell}/{scenario}: non-PR gh command should not emit PR action hint\n" + "\n".join(send_lines)) return (0, f"{shell}/{scenario}: ok") - if scenario == "preexec_preserves_pr_cache": - if gh_count != 1: - return ( - 1, - f"{shell}/{scenario}: expected cached PR result to survive ordinary preexec, saw {gh_count} gh calls\n" - + "\n".join(gh_args_lines), - ) - if _report_line(1138) not in send_lines: - return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) + if scenario == "head_change_clears_pr": + expected = "clear_pr --tab=00000000-0000-0000-0000-000000000001 --panel=00000000-0000-0000-0000-000000000002" + if expected not in send_lines: + return (1, f"{shell}/{scenario}: missing clear_pr after HEAD change\n" + "\n".join(send_lines)) + if gh_args_lines: + return (1, f"{shell}/{scenario}: expected no gh invocations\n" + "\n".join(gh_args_lines)) return (0, f"{shell}/{scenario}: ok") return (1, f"{shell}/{scenario}: unhandled scenario") @@ -503,15 +275,12 @@ def main() -> int: ("bash", ["--noprofile", "--norc", "-c"], root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"), ] scenarios = [ - "prompt_helper_idle", - "transient_same_context", - "branch_switch_clear", - "timeout_recovery", - "explicit_branch_fallback", - "initial_prompt_preserves_pr_badge", - "nested_shell_stops_parent_poll", - "ports_kick_throttle", - "preexec_preserves_pr_cache", + "prompt_does_not_call_gh", + "merge_action", + "close_action_target", + "failed_merge_no_action", + "non_pr_gh_no_action", + "head_change_clears_pr", ] base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}" @@ -541,7 +310,7 @@ def main() -> int: print(failure) return 1 - print("PASS: shell integrations poll PR state robustly across transient failures, branch changes, and timeouts") + print("PASS: shell integrations emit PR action hints locally and no longer poll GitHub directly") return 0 finally: shutil.rmtree(base, ignore_errors=True) From 91b48505ab311b89a27c02d6ee7222a3293d5616 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Mon, 6 Apr 2026 16:25:38 -0700 Subject: [PATCH 28/89] ci: only enforce Sparkle monotonic check on release (#2651) * ci: only enforce Sparkle monotonic check on release * release: add pre-tag Sparkle guard --- .claude/commands/release-local.md | 4 +++- .claude/commands/release.md | 4 +++- .github/workflows/ci.yml | 3 --- .github/workflows/release.yml | 27 +++------------------------ CLAUDE.md | 11 ++++++++++- scripts/release-pretag-guard.sh | 8 ++++++++ skills/release/SKILL.md | 4 +++- 7 files changed, 30 insertions(+), 31 deletions(-) create mode 100755 scripts/release-pretag-guard.sh diff --git a/.claude/commands/release-local.md b/.claude/commands/release-local.md index 1e8dcff9744d..0356ed3e6664 100644 --- a/.claude/commands/release-local.md +++ b/.claude/commands/release-local.md @@ -38,10 +38,12 @@ Full end-to-end release built locally. Bumps version, updates changelog, tags, t - Run: `./scripts/bump-version.sh` (bumps minor by default) -### 5. Commit, tag, and push +### 5. Commit, run the pre-tag guard, then tag and push - Stage: `CHANGELOG.md`, `GhosttyTabs.xcodeproj/project.pbxproj` - Commit message: `Bump version to X.Y.Z` +- Run: `./scripts/release-pretag-guard.sh` +- If it fails, run `./scripts/bump-version.sh`, commit the build-number bump, and rerun the guard - Create tag: `git tag vX.Y.Z` - Push: `git push origin main && git push origin vX.Y.Z` diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 9903627d3985..b55c69e7f60c 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -56,7 +56,9 @@ Prepare a new release for cmux. This command updates the changelog, bumps the ve - Merge: `gh pr merge --squash --delete-branch` - Switch back to main: `git checkout main && git pull` -10. **Create and push the tag** +10. **Run the pre-tag guard, then create and push the tag** + - Run: `./scripts/release-pretag-guard.sh` + - If it fails, run `./scripts/bump-version.sh`, commit the build-number bump, push/merge that change, and retry the tag - Create tag: `git tag vX.Y.Z` - Push tag: `git push origin vX.Y.Z` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b6871c79fe6..76c154342a41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,9 +38,6 @@ jobs: - name: Validate current GhosttyKit checksum pin run: ./tests/test_ci_ghosttykit_checksum_present.sh - - name: Validate Sparkle build number is monotonic - run: ./tests/test_ci_sparkle_build_monotonic.sh - remote-daemon-tests: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5451dd30c8c0..02203d76011e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,9 @@ jobs: with: submodules: recursive + - name: Validate Sparkle build number is monotonic + run: ./tests/test_ci_sparkle_build_monotonic.sh + - name: Guard immutable release assets id: guard_release_assets uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 @@ -79,30 +82,6 @@ jobs: throw error; } - - name: Guard Sparkle build number is monotonic - if: steps.guard_release_assets.outputs.skip_all != 'true' - run: | - set -euo pipefail - LOCAL_BUILD=$(grep -m1 'CURRENT_PROJECT_VERSION = ' GhosttyTabs.xcodeproj/project.pbxproj | sed 's/.*= //;s/;.*//') - if ! [[ "$LOCAL_BUILD" =~ ^[0-9]+$ ]]; then - echo "Could not parse CURRENT_PROJECT_VERSION (got '$LOCAL_BUILD')" >&2 - exit 1 - fi - echo "Local CURRENT_PROJECT_VERSION=$LOCAL_BUILD" - PUBLISHED_BUILD=$(curl -fsSL --max-time 15 \ - https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml 2>/dev/null \ - | sed -n 's#.*<sparkle:version>\([0-9][0-9]*\)</sparkle:version>.*#\1#p' \ - | head -n1 || true) - if [[ "$PUBLISHED_BUILD" =~ ^[0-9]+$ ]]; then - echo "Latest published Sparkle build=$PUBLISHED_BUILD" - if (( LOCAL_BUILD <= PUBLISHED_BUILD )); then - echo "::error::CURRENT_PROJECT_VERSION ($LOCAL_BUILD) must be > latest published Sparkle build ($PUBLISHED_BUILD). Run scripts/bump-version.sh to bump the build number before tagging." >&2 - exit 1 - fi - else - echo "Latest published appcast unavailable; skipping monotonic check" - fi - - name: Select Xcode if: steps.guard_release_assets.outputs.skip_all != 'true' run: | diff --git a/CLAUDE.md b/CLAUDE.md index 7da0cc108c00..541ee1c48c59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -235,7 +235,7 @@ Use the `/release` command to prepare a new release. This will: 2. Gather commits since the last tag and update the changelog 3. Update `CHANGELOG.md` (the docs changelog page at `web/app/docs/changelog/page.tsx` reads from it) 4. Run `./scripts/bump-version.sh` to update both versions -5. Commit, tag, and push +5. Commit, run `./scripts/release-pretag-guard.sh`, tag, and push Version bumping: @@ -248,9 +248,18 @@ Version bumping: This updates both `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` (build number). The build number is auto-incremented and is required for Sparkle auto-update to work. +Before creating a release tag, run: + +```bash +./scripts/release-pretag-guard.sh +``` + +If it fails, run `./scripts/bump-version.sh`, commit the build-number bump, then retry tagging. + Manual release steps (if not using the command): ```bash +./scripts/release-pretag-guard.sh git tag vX.Y.Z git push origin vX.Y.Z gh run watch --repo manaflow-ai/cmux diff --git a/scripts/release-pretag-guard.sh b/scripts/release-pretag-guard.sh new file mode 100755 index 000000000000..de0c8762175b --- /dev/null +++ b/scripts/release-pretag-guard.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +echo "Running release pre-tag checks..." +"$ROOT_DIR/tests/test_ci_sparkle_build_monotonic.sh" +echo "Release pre-tag checks passed." diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index d48cd0da11f1..3ab0fb1d5038 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -53,7 +53,9 @@ Run this workflow to prepare and publish a cmux release. - `gh pr merge --squash --delete-branch` - `git checkout main && git pull --ff-only` -10. Create and push tag: +10. Run the pre-tag guard, then create and push tag: +- `./scripts/release-pretag-guard.sh` +- If it fails, run `./scripts/bump-version.sh`, commit the build-number bump, push/merge that change, and retry the tag. - `git tag vX.Y.Z` - `git push origin vX.Y.Z` From 45989a0e224c44e937c905ae28cb743be94f01be Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:16:47 -0700 Subject: [PATCH 29/89] guardrail: require settings.json review for settings rows --- .../KeyboardShortcutSettingsFileStore.swift | 66 +++++++++ Sources/cmuxApp.swift | 125 +++++++++++++++++- 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 401aaa9a2c90..276754dbd1b0 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -22,6 +22,72 @@ final class CmuxSettingsFileStore { static let currentSchemaVersion = 1 static let schemaURLString = "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux-settings.schema.json" + // Keep this in sync with the parser below and the web schema/docs. Settings UI rows + // validate against this set so new persisted settings need an explicit settings.json review. + static let supportedSettingsJSONPaths: Set<String> = [ + "app.language", + "app.appearance", + "app.appIcon", + "app.newWorkspacePlacement", + "app.minimalMode", + "app.keepWorkspaceOpenWhenClosingLastSurface", + "app.focusPaneOnFirstClick", + "app.preferredEditor", + "app.reorderOnNotification", + "app.sendAnonymousTelemetry", + "app.warnBeforeQuit", + "app.renameSelectsExistingName", + "app.commandPaletteSearchesAllSurfaces", + "notifications.dockBadge", + "notifications.showInMenuBar", + "notifications.unreadPaneRing", + "notifications.paneFlash", + "notifications.sound", + "notifications.customSoundFilePath", + "notifications.command", + "sidebar.hideAllDetails", + "sidebar.branchLayout", + "sidebar.showNotificationMessage", + "sidebar.showBranchDirectory", + "sidebar.showPullRequests", + "sidebar.openPullRequestLinksInCmuxBrowser", + "sidebar.openPortLinksInCmuxBrowser", + "sidebar.showSSH", + "sidebar.showPorts", + "sidebar.showLog", + "sidebar.showProgress", + "sidebar.showCustomMetadata", + "workspaceColors.indicatorStyle", + "workspaceColors.selectionColor", + "workspaceColors.notificationBadgeColor", + "workspaceColors.colors", + "workspaceColors.paletteOverrides", + "workspaceColors.customColors", + "sidebarAppearance.matchTerminalBackground", + "sidebarAppearance.tintColor", + "sidebarAppearance.lightModeTintColor", + "sidebarAppearance.darkModeTintColor", + "sidebarAppearance.tintOpacity", + "automation.socketControlMode", + "automation.socketPassword", + "automation.claudeCodeIntegration", + "automation.claudeBinaryPath", + "automation.portBase", + "automation.portRange", + "customCommands.trustedDirectories", + "browser.defaultSearchEngine", + "browser.showSearchSuggestions", + "browser.theme", + "browser.openTerminalLinksInCmuxBrowser", + "browser.interceptTerminalOpenCommandInCmuxBrowser", + "browser.hostsToOpenInEmbeddedBrowser", + "browser.urlsToAlwaysOpenExternally", + "browser.insecureHttpHostsAllowedInEmbeddedBrowser", + "browser.showImportHintOnBlankTabs", + "browser.reactGrabVersion", + "shortcuts.showModifierHoldHints", + "shortcuts.bindings", + ] private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let backupsDefaultsKey = "cmux.settingsFile.backups.v1" diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index cc688d00bda9..43c73ac0ad9e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4627,6 +4627,8 @@ struct SettingsView: View { var body: some View { let _ = keyboardShortcutSettingsObserver.revision + let _ = SettingsConfigurationReview.json("browser.insecureHttpHostsAllowedInEmbeddedBrowser") + let _ = SettingsConfigurationReview.json("browser.showImportHintOnBlankTabs") ScrollViewReader { proxy in ZStack(alignment: .top) { ScrollView { @@ -4634,6 +4636,7 @@ struct SettingsView: View { SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) SettingsCard { SettingsCardRow( + configurationReview: .json("app.language"), String(localized: "settings.app.language", defaultValue: "Language"), subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue ? String(localized: "settings.app.language.restartSubtitle", defaultValue: "Restart cmux to apply") @@ -4665,6 +4668,7 @@ struct SettingsView: View { SettingsCardDivider() ThemePickerRow( + configurationReview: .json("app.appearance"), selectedMode: appearanceMode, onSelect: { mode in appearanceMode = mode.rawValue @@ -4674,6 +4678,7 @@ struct SettingsView: View { SettingsCardDivider() AppIconPickerRow( + configurationReview: .json("app.appIcon"), selectedMode: appIconMode, onSelect: { mode in appIconMode = mode.rawValue @@ -4684,6 +4689,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsPickerRow( + configurationReview: .json("app.newWorkspacePlacement"), String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), subtitle: selectedWorkspacePlacement.description, controlWidth: pickerColumnWidth, @@ -4697,6 +4703,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("app.minimalMode"), String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), subtitle: minimalModeSubtitle ) { @@ -4712,6 +4719,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("app.keepWorkspaceOpenWhenClosingLastSurface"), String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle ) { @@ -4723,6 +4731,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("app.focusPaneOnFirstClick"), String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"), subtitle: paneFirstClickFocusSubtitle ) { @@ -4737,6 +4746,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("app.preferredEditor"), String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), subtitle: String(localized: "settings.app.preferredEditor.subtitle", defaultValue: "Command to open files on Cmd-click. Leave empty for system default.") ) { @@ -4751,6 +4761,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("app.reorderOnNotification"), String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") ) { @@ -4762,6 +4773,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("notifications.dockBadge"), String(localized: "settings.app.dockBadge", defaultValue: "Dock Badge"), subtitle: String(localized: "settings.app.dockBadge.subtitle", defaultValue: "Show unread count on app icon (Dock and Cmd+Tab).") ) { @@ -4773,6 +4785,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("notifications.showInMenuBar"), String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"), subtitle: String(localized: "settings.app.showInMenuBar.subtitle", defaultValue: "Keep cmux in the menu bar for unread notifications and quick actions.") ) { @@ -4787,6 +4800,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("notifications.unreadPaneRing"), String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"), subtitle: String(localized: "settings.notifications.paneRing.subtitle", defaultValue: "Show a blue ring around panes with unread notifications.") ) { @@ -4801,6 +4815,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("notifications.paneFlash"), String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"), subtitle: String(localized: "settings.notifications.paneFlash.subtitle", defaultValue: "Briefly flash a blue outline when cmux highlights a pane.") ) { @@ -4815,6 +4830,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .action, "Desktop Notifications", subtitle: notificationPermissionSubtitle ) { @@ -4839,6 +4855,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("notifications.sound", "notifications.customSoundFilePath"), String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"), subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."), controlWidth: notificationSoundControlWidth @@ -4907,6 +4924,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("notifications.command"), "Notification Command", subtitle: "Run a shell command when a notification arrives. $CMUX_NOTIFICATION_TITLE, $CMUX_NOTIFICATION_SUBTITLE, $CMUX_NOTIFICATION_BODY are set." ) { @@ -4918,6 +4936,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("app.sendAnonymousTelemetry"), String(localized: "settings.app.telemetry", defaultValue: "Send anonymous telemetry"), subtitle: sendAnonymousTelemetry != telemetryValueAtLaunch ? String(localized: "settings.app.telemetry.subtitleChanged", defaultValue: "Change takes effect on next launch.") @@ -4931,6 +4950,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("app.warnBeforeQuit"), String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"), subtitle: warnBeforeQuitShortcut ? String(localized: "settings.app.warnBeforeQuit.subtitleOn", defaultValue: "Show a confirmation before quitting with Cmd+Q.") @@ -4944,6 +4964,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("app.renameSelectsExistingName"), String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), subtitle: commandPaletteRenameSelectAllOnFocus ? String(localized: "settings.app.renameSelectsName.subtitleOn", defaultValue: "Command Palette rename starts with all text selected.") @@ -4957,6 +4978,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("app.commandPaletteSearchesAllSurfaces"), String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), subtitle: commandPaletteSearchAllSurfaces ? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.") @@ -4974,6 +4996,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.hideAllDetails"), String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), subtitle: sidebarHideAllDetails ? String(localized: "settings.app.hideAllSidebarDetails.subtitleOn", defaultValue: "Show only the workspace title row. Overrides the detail toggles below.") @@ -4987,6 +5010,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsPickerRow( + configurationReview: .json("sidebar.branchLayout"), String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"), subtitle: sidebarBranchVerticalLayout ? String(localized: "settings.app.sidebarBranchLayout.subtitleVertical", defaultValue: "Vertical: each branch appears on its own line.") @@ -5002,6 +5026,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.showNotificationMessage"), String(localized: "settings.app.showNotificationMessage", defaultValue: "Show Notification Message in Sidebar"), subtitle: String(localized: "settings.app.showNotificationMessage.subtitle", defaultValue: "Display the latest notification message below the workspace title.") ) { @@ -5014,6 +5039,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.showBranchDirectory"), String(localized: "settings.app.showBranchDirectory", defaultValue: "Show Branch + Directory in Sidebar"), subtitle: String(localized: "settings.app.showBranchDirectory.subtitle", defaultValue: "Display the built-in git branch and working-directory row.") ) { @@ -5026,6 +5052,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.showPullRequests"), String(localized: "settings.app.showPullRequests", defaultValue: "Show Pull Requests in Sidebar"), subtitle: String(localized: "settings.app.showPullRequests.subtitle", defaultValue: "Display review items (PR/MR/etc.) with status, number, and clickable link.") ) { @@ -5038,6 +5065,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.openPullRequestLinksInCmuxBrowser"), String(localized: "settings.app.openSidebarPRLinks", defaultValue: "Open Sidebar PR Links in cmux Browser"), subtitle: openSidebarPullRequestLinksInCmuxBrowser ? String(localized: "settings.app.openSidebarPRLinks.subtitleOn", defaultValue: "Clicks open inside cmux browser.") @@ -5052,6 +5080,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.openPortLinksInCmuxBrowser"), String(localized: "settings.app.openSidebarPortLinks", defaultValue: "Open Sidebar Port Links in cmux Browser"), subtitle: openSidebarPortLinksInCmuxBrowser ? String(localized: "settings.app.openSidebarPortLinks.subtitleOn", defaultValue: "Port clicks open inside cmux browser.") @@ -5066,6 +5095,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.showSSH"), String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"), subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.") ) { @@ -5077,6 +5107,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.showPorts"), String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.") ) { @@ -5089,6 +5120,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.showLog"), String(localized: "settings.app.showLog", defaultValue: "Show Latest Log in Sidebar"), subtitle: String(localized: "settings.app.showLog.subtitle", defaultValue: "Display the latest imperative log/status message.") ) { @@ -5101,6 +5133,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.showProgress"), String(localized: "settings.app.showProgress", defaultValue: "Show Progress in Sidebar"), subtitle: String(localized: "settings.app.showProgress.subtitle", defaultValue: "Display the built-in progress bar from set_progress.") ) { @@ -5113,6 +5146,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebar.showCustomMetadata"), String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"), subtitle: String(localized: "settings.app.showMetadata.subtitle", defaultValue: "Display custom metadata from report_meta/set_status and report_meta_block.") ) { @@ -5126,6 +5160,7 @@ struct SettingsView: View { SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors")) SettingsCard { SettingsPickerRow( + configurationReview: .json("workspaceColors.indicatorStyle"), String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"), controlWidth: pickerColumnWidth, selection: sidebarIndicatorStyleSelection @@ -5138,6 +5173,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("workspaceColors.selectionColor"), String(localized: "settings.workspaceColors.selectionColor", defaultValue: "Selection Highlight"), subtitle: String(localized: "settings.workspaceColors.selectionColor.subtitle", defaultValue: "Background color of the selected workspace in the sidebar.") ) { @@ -5168,6 +5204,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("workspaceColors.notificationBadgeColor"), String(localized: "settings.workspaceColors.notificationBadgeColor", defaultValue: "Notification Badge"), subtitle: String(localized: "settings.workspaceColors.notificationBadgeColor.subtitle", defaultValue: "Color of the unread notification badge on workspace tabs.") ) { @@ -5217,6 +5254,7 @@ struct SettingsView: View { SettingsCardDivider() } SettingsCardRow( + configurationReview: .json("workspaceColors.colors"), entry.name, subtitle: baseTabColorHex(for: entry.name).map { String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") @@ -5254,6 +5292,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .action, String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), subtitle: String( localized: "settings.workspaceColors.resetPalette.subtitleV2", @@ -5271,6 +5310,7 @@ struct SettingsView: View { SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance")) SettingsCard { SettingsCardRow( + configurationReview: .json("sidebarAppearance.matchTerminalBackground"), String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.") ) { @@ -5283,6 +5323,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebarAppearance.lightModeTintColor"), String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"), subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.") ) { @@ -5305,6 +5346,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebarAppearance.darkModeTintColor"), String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"), subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.") ) { @@ -5327,6 +5369,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("sidebarAppearance.tintOpacity"), String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"), subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.") ) { @@ -5343,6 +5386,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .action, String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"), subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.") ) { @@ -5360,6 +5404,7 @@ struct SettingsView: View { SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation")) SettingsCard { SettingsPickerRow( + configurationReview: .json("automation.socketControlMode"), String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), subtitle: selectedSocketControlMode.description, controlWidth: pickerColumnWidth, @@ -5377,6 +5422,7 @@ struct SettingsView: View { if selectedSocketControlMode == .password { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("automation.socketPassword"), String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), subtitle: hasSocketPasswordConfigured ? String(localized: "settings.automation.socketPassword.subtitleSet", defaultValue: "Stored in Application Support.") @@ -5422,6 +5468,7 @@ struct SettingsView: View { SettingsCard { SettingsCardRow( + configurationReview: .json("automation.claudeCodeIntegration"), String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"), subtitle: claudeCodeHooksEnabled ? String(localized: "settings.automation.claudeCode.subtitleOn", defaultValue: "Sidebar shows Claude session status and notifications.") @@ -5440,6 +5487,7 @@ struct SettingsView: View { SettingsCard { SettingsCardRow( + configurationReview: .json("automation.claudeBinaryPath"), String(localized: "settings.automation.claudeCode.customPath", defaultValue: "Claude Binary Path"), subtitle: String(localized: "settings.automation.claudeCode.customPath.subtitle", defaultValue: "Custom path to the claude binary. Leave empty to use PATH.") ) { @@ -5453,7 +5501,7 @@ struct SettingsView: View { } SettingsCard { - SettingsCardRow(String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), controlWidth: pickerColumnWidth) { + SettingsCardRow(configurationReview: .json("automation.portBase"), String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), controlWidth: pickerColumnWidth) { TextField("", value: $cmuxPortBase, format: .number) .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) @@ -5461,7 +5509,7 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow(String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), subtitle: String(localized: "settings.automation.portRange.subtitle", defaultValue: "Number of ports per workspace."), controlWidth: pickerColumnWidth) { + SettingsCardRow(configurationReview: .json("automation.portRange"), String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), subtitle: String(localized: "settings.automation.portRange.subtitle", defaultValue: "Number of ports per workspace."), controlWidth: pickerColumnWidth) { TextField("", value: $cmuxPortRange, format: .number) .textFieldStyle(.roundedBorder) .multilineTextAlignment(.trailing) @@ -5476,6 +5524,7 @@ struct SettingsView: View { SettingsCard { VStack(alignment: .leading, spacing: 6) { SettingsCardRow( + configurationReview: .json("customCommands.trustedDirectories"), String(localized: "settings.customCommands.trustedDirectories", defaultValue: "Trusted Directories"), subtitle: String(localized: "settings.customCommands.trustedDirectories.subtitle", defaultValue: "Commands from cmux.json in these directories run without confirmation. One path per line.") ) { @@ -5509,6 +5558,7 @@ struct SettingsView: View { .accessibilityIdentifier("SettingsBrowserSection") SettingsCard { SettingsPickerRow( + configurationReview: .json("browser.defaultSearchEngine"), String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), subtitle: String(localized: "settings.browser.searchEngine.subtitle", defaultValue: "Used by the browser address bar when input is not a URL."), controlWidth: pickerColumnWidth, @@ -5521,7 +5571,7 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow(String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions")) { + SettingsCardRow(configurationReview: .json("browser.showSearchSuggestions"), String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions")) { Toggle("", isOn: $browserSearchSuggestionsEnabled) .labelsHidden() .controlSize(.small) @@ -5530,6 +5580,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsPickerRow( + configurationReview: .json("browser.theme"), String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), subtitle: selectedBrowserThemeMode == .system ? String(localized: "settings.browser.theme.subtitleSystem", defaultValue: "System follows app and macOS appearance.") @@ -5545,6 +5596,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("browser.openTerminalLinksInCmuxBrowser"), String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in cmux Browser"), subtitle: String(localized: "settings.browser.openTerminalLinks.subtitle", defaultValue: "When off, links clicked in terminal output open in your default browser.") ) { @@ -5556,6 +5608,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("browser.interceptTerminalOpenCommandInCmuxBrowser"), String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), subtitle: String(localized: "settings.browser.interceptOpen.subtitle", defaultValue: "When off, `open https://...` and `open http://...` always use your default browser.") ) { @@ -5569,6 +5622,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 6) { SettingsCardRow( + configurationReview: .json("browser.hostsToOpenInEmbeddedBrowser"), String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") ) { @@ -5594,6 +5648,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 6) { SettingsCardRow( + configurationReview: .json("browser.urlsToAlwaysOpenExternally"), String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage)).") ) { @@ -5752,6 +5807,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("browser.reactGrabVersion"), String(localized: "settings.browser.reactGrabVersion", defaultValue: "React Grab Version"), subtitle: String(localized: "settings.browser.reactGrabVersion.subtitle", defaultValue: "Pinned npm version of react-grab injected by the toolbar button (Cmd+Shift+G). Only versions with a known integrity hash are accepted.") ) { @@ -5764,7 +5820,7 @@ struct SettingsView: View { SettingsCardDivider() - SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { + SettingsCardRow(configurationReview: .action, String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { showClearBrowserHistoryConfirmation = true } @@ -5779,6 +5835,7 @@ struct SettingsView: View { .accessibilityIdentifier("SettingsKeyboardShortcutsSection") SettingsCard { SettingsCardRow( + configurationReview: .action, String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"].") ) { @@ -5799,6 +5856,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .json("shortcuts.showModifierHoldHints"), String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), subtitle: showShortcutHintsOnCommandHold ? String(localized: "settings.shortcuts.showHints.subtitleOn", defaultValue: "Holding Cmd (sidebar/titlebar) or Ctrl/Cmd (pane tabs) shows shortcut hint pills.") @@ -6255,17 +6313,21 @@ private struct SettingsHeaderActionButton: View { } private struct SettingsCardRow<Trailing: View>: View { + let configurationReview: SettingsConfigurationReview let title: String let subtitle: String? let controlWidth: CGFloat? @ViewBuilder let trailing: Trailing init( + configurationReview: SettingsConfigurationReview, _ title: String, subtitle: String? = nil, controlWidth: CGFloat? = nil, @ViewBuilder trailing: () -> Trailing ) { + configurationReview.validate() + self.configurationReview = configurationReview self.title = title self.subtitle = subtitle self.controlWidth = controlWidth @@ -6303,6 +6365,7 @@ private struct SettingsCardRow<Trailing: View>: View { } private struct SettingsPickerRow<SelectionValue: Hashable, PickerContent: View, ExtraTrailing: View>: View { + let configurationReview: SettingsConfigurationReview let title: String let subtitle: String? let controlWidth: CGFloat @@ -6312,6 +6375,7 @@ private struct SettingsPickerRow<SelectionValue: Hashable, PickerContent: View, let accessibilityId: String? init( + configurationReview: SettingsConfigurationReview, _ title: String, subtitle: String? = nil, controlWidth: CGFloat, @@ -6320,6 +6384,8 @@ private struct SettingsPickerRow<SelectionValue: Hashable, PickerContent: View, @ViewBuilder content: () -> PickerContent, @ViewBuilder extraTrailing: () -> ExtraTrailing ) { + configurationReview.validate() + self.configurationReview = configurationReview self.title = title self.subtitle = subtitle self.controlWidth = controlWidth @@ -6330,7 +6396,7 @@ private struct SettingsPickerRow<SelectionValue: Hashable, PickerContent: View, } var body: some View { - SettingsCardRow(title, subtitle: subtitle, controlWidth: controlWidth) { + SettingsCardRow(configurationReview: configurationReview, title, subtitle: subtitle, controlWidth: controlWidth) { HStack(spacing: 6) { Picker("", selection: $selection) { pickerContent @@ -6346,6 +6412,7 @@ private struct SettingsPickerRow<SelectionValue: Hashable, PickerContent: View, extension SettingsPickerRow where ExtraTrailing == EmptyView { init( + configurationReview: SettingsConfigurationReview, _ title: String, subtitle: String? = nil, controlWidth: CGFloat, @@ -6353,12 +6420,34 @@ extension SettingsPickerRow where ExtraTrailing == EmptyView { accessibilityId: String? = nil, @ViewBuilder content: () -> PickerContent ) { - self.init(title, subtitle: subtitle, controlWidth: controlWidth, selection: selection, accessibilityId: accessibilityId, content: content) { + self.init(configurationReview: configurationReview, title, subtitle: subtitle, controlWidth: controlWidth, selection: selection, accessibilityId: accessibilityId, content: content) { EmptyView() } } } +private enum SettingsConfigurationReview: Equatable { + case settingsFile([String]) + case settingsOnly + case action + case debugOnly + + static func json(_ paths: String...) -> Self { + .settingsFile(paths) + } + + func validate(file: StaticString = #fileID, line: UInt = #line) { + guard case .settingsFile(let paths) = self else { return } + let unknownPaths = paths.filter { !CmuxSettingsFileStore.supportedSettingsJSONPaths.contains($0) } + precondition( + unknownPaths.isEmpty, + "Unknown settings.json path(s): \(unknownPaths.joined(separator: ", "))", + file: file, + line: line + ) + } +} + private extension View { @ViewBuilder func applyIf(_ condition: Bool, transform: (Self) -> some View) -> some View { @@ -6499,12 +6588,24 @@ private struct ThemeWindowThumbnail: View { } private struct ThemePickerRow: View { + let configurationReview: SettingsConfigurationReview let selectedMode: String let onSelect: (AppearanceMode) -> Void private let thumbWidth: CGFloat = 76 private let thumbHeight: CGFloat = 50 + init( + configurationReview: SettingsConfigurationReview, + selectedMode: String, + onSelect: @escaping (AppearanceMode) -> Void + ) { + configurationReview.validate() + self.configurationReview = configurationReview + self.selectedMode = selectedMode + self.onSelect = onSelect + } + var body: some View { HStack(alignment: .top, spacing: 12) { Text(String(localized: "settings.app.theme", defaultValue: "Theme")) @@ -6583,12 +6684,24 @@ private struct ThemePickerRow: View { } private struct AppIconPickerRow: View { + let configurationReview: SettingsConfigurationReview let selectedMode: String let onSelect: (AppIconMode) -> Void private let iconSize: CGFloat = 48 private let autoIconSize: CGFloat = 36 + init( + configurationReview: SettingsConfigurationReview, + selectedMode: String, + onSelect: @escaping (AppIconMode) -> Void + ) { + configurationReview.validate() + self.configurationReview = configurationReview + self.selectedMode = selectedMode + self.onSelect = onSelect + } + var body: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 3) { From 7563c910222b9de7393ad60331329f6b60d2d88e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:23:13 -0700 Subject: [PATCH 30/89] fix: restore shortcut default and validate bypassed settings paths --- Sources/KeyboardShortcutSettings.swift | 2 +- Sources/cmuxApp.swift | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f8ca69cdba3b..4b6dbbe286cd 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -255,7 +255,7 @@ enum KeyboardShortcutSettings { // Safari default: Show JavaScript Console. return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) case .toggleReactGrab: - return StoredShortcut(key: "g", command: true, shift: false, option: true, control: false) + return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false) } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 43c73ac0ad9e..c4418e524ab0 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4627,8 +4627,7 @@ struct SettingsView: View { var body: some View { let _ = keyboardShortcutSettingsObserver.revision - let _ = SettingsConfigurationReview.json("browser.insecureHttpHostsAllowedInEmbeddedBrowser") - let _ = SettingsConfigurationReview.json("browser.showImportHintOnBlankTabs") + let _ = Self.validateBypassedSettingsConfigurationReviews() ScrollViewReader { proxy in ZStack(alignment: .top) { ScrollView { @@ -6074,6 +6073,11 @@ struct SettingsView: View { } } + private static func validateBypassedSettingsConfigurationReviews() { + SettingsConfigurationReview.json("browser.insecureHttpHostsAllowedInEmbeddedBrowser").validate() + SettingsConfigurationReview.json("browser.showImportHintOnBlankTabs").validate() + } + private func relaunchApp() { let bundlePath = Bundle.main.bundlePath let task = Process() From 0bbaa48ad84747e3fa85783758545881b837203c Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Mon, 6 Apr 2026 21:12:56 -0700 Subject: [PATCH 31/89] Configurable surface tab bar font size (#2645) * feat: configurable sidebar tab font size (#2643) * Revert "feat: configurable sidebar tab font size (#2643)" This reverts commit ded00bae024c6fd7732029b8522c27b9baeab26e. * feat: configurable surface tab bar font size * chore: update bonsplit submodule for tab font size --- Sources/GhosttyConfig.swift | 5 ++++ Sources/Workspace.swift | 53 +++++++++++++++++++++++++++++++------ vendor/bonsplit | 2 +- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 13f78f1257dd..3197c1178219 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -13,6 +13,7 @@ struct GhosttyConfig { var fontFamily: String = "Menlo" var fontSize: CGFloat = 12 + var surfaceTabBarFontSize: CGFloat = 11 var theme: String? var workingDirectory: String? var scrollbackLimit: Int = 10000 @@ -242,6 +243,10 @@ struct GhosttyConfig { if let size = Double(value) { fontSize = CGFloat(size) } + case "surface-tab-bar-font-size": + if let size = Double(value) { + surfaceTabBarFontSize = CGFloat(size) + } case "theme": theme = value case "working-directory": diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e94abe72c004..87ebe6372a2a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6755,7 +6755,8 @@ final class Workspace: Identifiable, ObservableObject { private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { bonsplitAppearance( from: config.backgroundColor, - backgroundOpacity: config.backgroundOpacity + backgroundOpacity: config.backgroundOpacity, + tabTitleFontSize: config.surfaceTabBarFontSize ) } @@ -6776,9 +6777,11 @@ final class Workspace: Identifiable, ObservableObject { private static func bonsplitAppearance( from backgroundColor: NSColor, - backgroundOpacity: Double + backgroundOpacity: Double, + tabTitleFontSize: CGFloat = 11 ) -> BonsplitConfiguration.Appearance { BonsplitConfiguration.Appearance( + tabTitleFontSize: tabTitleFontSize, splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init( @@ -6791,11 +6794,43 @@ final class Workspace: Identifiable, ObservableObject { } func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { - applyGhosttyChrome( + let nextHex = Self.bonsplitChromeHex( backgroundColor: config.backgroundColor, - backgroundOpacity: config.backgroundOpacity, - reason: reason + backgroundOpacity: config.backgroundOpacity ) + let nextTabTitleFontSize = config.surfaceTabBarFontSize + let currentAppearance = bonsplitController.configuration.appearance + let currentBackgroundHex = currentAppearance.chromeColors.backgroundHex + let currentTabTitleFontSize = currentAppearance.tabTitleFontSize + let backgroundChanged = currentBackgroundHex != nextHex + let fontSizeChanged = abs(currentTabTitleFontSize - nextTabTitleFontSize) > 0.0001 + let isNoOp = !backgroundChanged && !fontSizeChanged + + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme apply workspace=\(id.uuidString) reason=\(reason) " + + "currentBg=\(currentBackgroundHex ?? "nil") nextBg=\(nextHex) " + + "currentTabFont=\(String(format: "%.3f", currentTabTitleFontSize)) " + + "nextTabFont=\(String(format: "%.3f", nextTabTitleFontSize)) noop=\(isNoOp)" + ) + } + + guard !isNoOp else { return } + + if backgroundChanged { + bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex + } + if fontSizeChanged { + bonsplitController.configuration.appearance.tabTitleFontSize = nextTabTitleFontSize + } + + if GhosttyApp.shared.backgroundLogEnabled { + GhosttyApp.shared.logBackground( + "theme applied workspace=\(id.uuidString) reason=\(reason) " + + "resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") " + + "resultingTabFont=\(String(format: "%.3f", bonsplitController.configuration.appearance.tabTitleFontSize))" + ) + } } func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { @@ -6847,11 +6882,13 @@ final class Workspace: Identifiable, ObservableObject { // Configure bonsplit with keepAllAlive to preserve terminal state // and keep split entry instantaneous. - // Avoid re-reading/parsing Ghostty config on every new workspace; this hot path - // runs for socket/CLI workspace creation and can cause visible typing lag. + // Use the cached Ghostty config so new workspaces inherit tab-strip sizing + // without paying repeated parse costs on the workspace-creation hot path. + let initialSurfaceTabBarFontSize = GhosttyConfig.load().surfaceTabBarFontSize let appearance = Self.bonsplitAppearance( from: GhosttyApp.shared.defaultBackgroundColor, - backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity, + tabTitleFontSize: initialSurfaceTabBarFontSize ) let config = BonsplitConfiguration( allowSplits: true, diff --git a/vendor/bonsplit b/vendor/bonsplit index aff9f7073125..098d9fa00e2b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit aff9f7073125d412fed07bce1adab0e78f907b14 +Subproject commit 098d9fa00e2b1d4712f1a46b818ee7d53d4aa31f From ed019f5ce2645f55a09bfd6f7defcbd22ad6c78c Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 01:36:12 -0700 Subject: [PATCH 32/89] Add a system-wide hotkey to show and hide cmux windows (#2389) * Add system-wide app hotkey * Refine shared shortcut matching for system-wide hotkey The leftover working-tree changes are a real follow-up to the app hotkey feature, not stale WIP. They move shortcut matching into StoredShortcut so the in-app shortcut handler and the CGEvent-based system-wide hotkey path use the same caps-lock normalization, keypad enter handling, remapped command-letter behavior, and non-Latin layout fallback. The added unit coverage documents those cases. * Use Carbon APIs for the system-wide hotkey * Fix global hotkey settings row after merging main * Fix system-wide hotkey layout and fallback handling * Fix hotkey conflicts and zsh TERM handoff * Reserve more system-wide hotkey conflicts and use layout-aware matching Use the same StoredShortcut.matches API used elsewhere when checking whether a candidate global hotkey conflicts with a reserved cmux shortcut, so caps-lock and layout fallback are handled consistently. Also reserve Cmd+` and Cmd+Shift+` so users cannot bind the global hotkey to macOS' built-in window cycling shortcuts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- Resources/Localizable.xcstrings | 102 +++ Resources/shell-integration/.zshenv | 1 + .../cmux-zsh-integration.zsh | 9 +- Sources/AppDelegate.swift | 181 +--- Sources/KeyboardShortcutSettings.swift | 865 +++++++++++++++++- Sources/cmuxApp.swift | 86 +- cmuxTests/WorkspaceUnitTests.swift | 182 ++++ web/data/cmux-settings.schema.json | 1 + web/data/cmux-shortcuts.ts | 6 + 9 files changed, 1238 insertions(+), 195 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 5590e1851101..7f9c74cc0b0f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -63003,6 +63003,23 @@ } } }, + "settings.section.globalHotkey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Global Hotkey" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グローバルホットキー" + } + } + } + }, "settings.section.keyboardShortcuts": { "extractionState": "manual", "localizations": { @@ -63479,6 +63496,91 @@ } } }, + "settings.globalHotkey.enable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable System-Wide Hotkey" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム全体のホットキーを有効にする" + } + } + } + }, + "settings.globalHotkey.enable.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn this on to show or hide all cmux windows from any app." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "どのアプリからでも cmux のすべてのウィンドウを表示または非表示にするにはオンにします。" + } + } + } + }, + "settings.globalHotkey.enable.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Press the shortcut from any app to show or hide all cmux windows." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "どのアプリからでもこのショートカットで cmux のすべてのウィンドウを表示または非表示にできます。" + } + } + } + }, + "settings.globalHotkey.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Command, Option, or Control with another key. No extra macOS permission is required." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Command、Option、または Control と別のキーを組み合わせてください。追加の macOS 権限は不要です。" + } + } + } + }, + "settings.globalHotkey.shortcut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show/Hide All Windows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのウィンドウを表示/非表示" + } + } + } + }, "settings.shortcuts.managedByFile": { "extractionState": "manual", "localizations": { diff --git a/Resources/shell-integration/.zshenv b/Resources/shell-integration/.zshenv index d9be4a3bf35e..5e63444d5516 100644 --- a/Resources/shell-integration/.zshenv +++ b/Resources/shell-integration/.zshenv @@ -40,6 +40,7 @@ fi # interactive command executes. builtin export CMUX_ZSH_RESTORE_TERM="$TERM" builtin export TERM="xterm-ghostty" + builtin typeset -g _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT=1 fi } always { if [[ -o interactive ]]; then diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 304ddd618207..cd69cbffb993 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -205,6 +205,7 @@ typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 typeset -g _CMUX_WINCH_GUARD_INSTALLED=0 typeset -g _CMUX_TMUX_PUSH_SIGNATURE="" typeset -g _CMUX_TMUX_PULL_SIGNATURE="" +typeset -g _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT=${_CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT:-0} typeset -ga _CMUX_TMUX_SYNC_KEYS=( CMUX_BUNDLED_CLI_PATH CMUX_BUNDLE_ID @@ -1064,7 +1065,9 @@ _cmux_command_starts_nested_shell() { } _cmux_preexec() { - _cmux_restore_terminal_identity_after_startup + if (( ! _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT )); then + _cmux_restore_terminal_identity_after_startup + fi _cmux_tmux_sync_cmux_environment local cmd="${1## }" @@ -1099,6 +1102,9 @@ _cmux_preexec() { _cmux_precmd() { local last_status=$? + if (( _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT )); then + _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT=0 + fi _cmux_stop_git_head_watch _cmux_tmux_sync_cmux_environment @@ -1277,6 +1283,7 @@ _cmux_restore_terminal_identity_after_startup() { builtin export TERM="$CMUX_ZSH_RESTORE_TERM" builtin unset CMUX_ZSH_RESTORE_TERM fi + _CMUX_DELAY_TERM_RESTORE_UNTIL_FIRST_PROMPT=0 } _cmux_zshexit() { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9d04fe352c0d..3fe3705dc836 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2503,6 +2503,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make // key-equivalent routing flaky. Force defaults for deterministic tests. if isRunningUnderXCTest { + SystemWideHotkeySettings.reset() KeyboardShortcutSettings.resetAll() } #endif @@ -2590,6 +2591,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installBrowserAddressBarFocusObservers() installShortcutMonitor() installShortcutDefaultsObserver() + SystemWideHotkeyController.shared.start() NSApp.servicesProvider = self #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) @@ -10271,8 +10273,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func refreshConfiguredShortcutChordActions() { - configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { - KeyboardShortcutSettings.shortcut(for: $0).hasChord + configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { action in + // showHideAllWindows is dispatched via Carbon RegisterEventHotKey + // (SystemWideHotkeyController) and never routed through AppKit's + // local key handler. If a managed settings.json entry happened to + // store it as a chord, arming the prefix here would swallow the + // first stroke and leave the second one orphaned, breaking that + // keystroke for the focused terminal/browser input. + guard action != .showHideAllWindows else { return false } + return KeyboardShortcutSettings.shortcut(for: action).hasChord } } @@ -12271,90 +12280,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent /// Match a shortcut stroke against an event, handling normal keys. private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { - // Some keys can include extra flags (e.g. .function) depending on the responder chain. - // Strip those for consistent matching across first responders (terminal, WebKit, etc). - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags == stroke.modifierFlags else { return false } - - let shortcutKey = stroke.key.lowercased() - if shortcutKey == "\r" { - return event.keyCode == 36 || event.keyCode == 76 - } - - let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers - if shortcutCharacterMatches( - eventCharacter: eventCharsIgnoringModifiers, - shortcutKey: shortcutKey, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { - return true - } - - // For command-based shortcuts, trust AppKit's layout-aware characters when present. - // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, - // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. - // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), - // charactersIgnoringModifiers returns non-ASCII characters that can never match - // a Latin shortcut key — skip this guard and fall through to layout-based matching. - let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) - let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true - let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true - if shortcutKeyIsDigit, - hasEventChars, - eventCharsAreASCII, - digitForNumberKeyCode(event.keyCode) == nil { - return false - } - if hasEventChars, - eventCharsAreASCII, - flags.contains(.command), - !flags.contains(.control), - shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { - return false - } - - // Match using the current keyboard layout so Command shortcuts stay character-based - // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if shortcutCharacterMatches( - eventCharacter: layoutCharacter, - shortcutKey: shortcutKey, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { - return true - } - - // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), - // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for - // command punctuation shortcuts, since some non-US layouts report different characters - // for the same physical key even when menu-equivalent semantics should still apply. - // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), - // event chars carry no usable Latin key identity. Always allow keyCode fallback as a - // safety net — even when the layout-based translation resolved a character, the - // physical key code is the definitive identifier for the intended shortcut. - // For empty-character events (synthetic/browser key equivalents), preserve the original - // behavior: only fall back when the layout translation also failed. - let hasUsableEventChars = hasEventChars && eventCharsAreASCII - let allowANSIKeyCodeFallback = flags.contains(.control) - || (flags.contains(.command) - && !flags.contains(.control) - && ( - !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) - || (hasEventChars && !eventCharsAreASCII) - || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) - )) - if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { - return event.keyCode == expectedKeyCode - } - return false + stroke.matches(event: event, layoutCharacterProvider: shortcutLayoutCharacterProvider) } private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { - guard !shortcut.hasChord else { return false } - return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + shortcut.matches(event: event, layoutCharacterProvider: shortcutLayoutCharacterProvider) } private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { @@ -12408,30 +12338,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return digit } - private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { - guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { - return false - } - return CharacterSet.letters.contains(scalar) - } - - private func shortcutCharacterMatches( - eventCharacter: String?, - shortcutKey: String, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> Bool { - guard let eventCharacter, !eventCharacter.isEmpty else { return false } - if normalizedShortcutEventCharacter( - eventCharacter, - applyShiftSymbolNormalization: applyShiftSymbolNormalization, - eventKeyCode: eventKeyCode - ) == shortcutKey { - return true - } - return false - } - private func normalizedShortcutEventCharacter( _ eventCharacter: String, applyShiftSymbolNormalization: Bool, @@ -12466,67 +12372,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func keyCodeForShortcutKey(_ key: String) -> UInt16? { - // Matches macOS ANSI key codes. This is intentionally limited to keys we - // support in StoredShortcut/ghostty trigger translation. - switch key { - case "a": return 0 // kVK_ANSI_A - case "s": return 1 // kVK_ANSI_S - case "d": return 2 // kVK_ANSI_D - case "f": return 3 // kVK_ANSI_F - case "h": return 4 // kVK_ANSI_H - case "g": return 5 // kVK_ANSI_G - case "z": return 6 // kVK_ANSI_Z - case "x": return 7 // kVK_ANSI_X - case "c": return 8 // kVK_ANSI_C - case "v": return 9 // kVK_ANSI_V - case "b": return 11 // kVK_ANSI_B - case "q": return 12 // kVK_ANSI_Q - case "w": return 13 // kVK_ANSI_W - case "e": return 14 // kVK_ANSI_E - case "r": return 15 // kVK_ANSI_R - case "y": return 16 // kVK_ANSI_Y - case "t": return 17 // kVK_ANSI_T - case "1": return 18 // kVK_ANSI_1 - case "2": return 19 // kVK_ANSI_2 - case "3": return 20 // kVK_ANSI_3 - case "4": return 21 // kVK_ANSI_4 - case "6": return 22 // kVK_ANSI_6 - case "5": return 23 // kVK_ANSI_5 - case "=": return 24 // kVK_ANSI_Equal - case "9": return 25 // kVK_ANSI_9 - case "7": return 26 // kVK_ANSI_7 - case "-": return 27 // kVK_ANSI_Minus - case "8": return 28 // kVK_ANSI_8 - case "0": return 29 // kVK_ANSI_0 - case "]": return 30 // kVK_ANSI_RightBracket - case "o": return 31 // kVK_ANSI_O - case "u": return 32 // kVK_ANSI_U - case "[": return 33 // kVK_ANSI_LeftBracket - case "i": return 34 // kVK_ANSI_I - case "p": return 35 // kVK_ANSI_P - case "l": return 37 // kVK_ANSI_L - case "j": return 38 // kVK_ANSI_J - case "'": return 39 // kVK_ANSI_Quote - case "k": return 40 // kVK_ANSI_K - case ";": return 41 // kVK_ANSI_Semicolon - case "\\": return 42 // kVK_ANSI_Backslash - case ",": return 43 // kVK_ANSI_Comma - case "/": return 44 // kVK_ANSI_Slash - case "n": return 45 // kVK_ANSI_N - case "m": return 46 // kVK_ANSI_M - case ".": return 47 // kVK_ANSI_Period - case "`": return 50 // kVK_ANSI_Grave - case "\r": return 36 // kVK_Return - case "←": return 123 // kVK_LeftArrow - case "→": return 124 // kVK_RightArrow - case "↓": return 125 // kVK_DownArrow - case "↑": return 126 // kVK_UpArrow - default: - return nil - } - } - private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { switch keyCode { case 18: return 1 // kVK_ANSI_1 diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 4b6dbbe286cd..155f2e12379e 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -1,4 +1,6 @@ import AppKit +import Bonsplit +import Carbon import SwiftUI /// Stores customizable keyboard shortcuts (definitions + persistence). @@ -16,6 +18,7 @@ enum KeyboardShortcutSettings { // App / window case openSettings case reloadConfiguration + case showHideAllWindows case newWindow case closeWindow case toggleFullScreen @@ -84,6 +87,7 @@ enum KeyboardShortcutSettings { switch self { case .openSettings: return String(localized: "menu.app.settings", defaultValue: "Settings…") case .reloadConfiguration: return String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration") + case .showHideAllWindows: return String(localized: "settings.globalHotkey.shortcut", defaultValue: "Show/Hide All Windows") case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window") case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window") case .toggleFullScreen: return String(localized: "command.toggleFullScreen.title", defaultValue: "Toggle Full Screen") @@ -148,6 +152,12 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: ",", command: true, shift: false, option: false, control: false) case .reloadConfiguration: return StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) + case .showHideAllWindows: + // Avoid AppKit-reserved keystrokes such as Cmd+. (modal + // cancel). Default to Ctrl+Option+Cmd+. so the global hotkey + // does not collide with the standard cancel keystroke that + // NSAlert/NSOpenPanel use. + return StoredShortcut(key: ".", command: true, shift: false, option: true, control: true) case .newWindow: return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false) case .closeWindow: @@ -280,21 +290,97 @@ enum KeyboardShortcutSettings { } func normalizedRecordedShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { - guard usesNumberedDigitMatching else { return shortcut } - let digitSource = shortcut.secondStroke ?? shortcut.firstStroke - guard let digit = Int(digitSource.key), (1...9).contains(digit) else { - return nil + switch self { + case .showHideAllWindows: + return KeyboardShortcutSettings.normalizedSystemWideHotkeyShortcut(shortcut) + case .selectSurfaceByNumber, .selectWorkspaceByNumber: + let digitSource = shortcut.secondStroke ?? shortcut.firstStroke + guard let digit = Int(digitSource.key), (1...9).contains(digit) else { + return nil + } + var normalized = shortcut + if shortcut.hasChord { + normalized.chordKey = "1" + } else { + normalized.key = "1" + } + return normalized + default: + return shortcut } - var normalized = shortcut + } + } + + private static func normalizedSystemWideHotkeyShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { + guard !shortcut.hasChord, + shortcut.hasPrimaryModifier, + shortcut.carbonHotKeyRegistration != nil, + !systemWideHotkeyConflicts(with: shortcut) else { + return nil + } + return shortcut + } + + private static func systemWideHotkeyConflicts(with shortcut: StoredShortcut) -> Bool { + guard let registration = shortcut.carbonHotKeyRegistration else { return false } + let keyCode = UInt16(registration.keyCode) + let modifierFlags = shortcut.modifierFlags + // Validate against the keystroke AppKit shortcuts would see for the + // registered Carbon hotkey under the current input source. + let eventCharacter = KeyboardLayout.character(forKeyCode: keyCode) + + return reservedSystemWideHotkeyShortcuts().contains { reserved in + reserved.matches( + keyCode: keyCode, + modifierFlags: modifierFlags, + eventCharacter: eventCharacter + ) + } + } + + private static func reservedSystemWideHotkeyShortcuts() -> [StoredShortcut] { + var reserved: [StoredShortcut] = [] + + for action in Action.allCases where action != .showHideAllWindows { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) if shortcut.hasChord { - normalized.chordKey = "1" - } else { - normalized.key = "1" + reserved.append(StoredShortcut(first: shortcut.firstStroke)) + continue + } + if action.usesNumberedDigitMatching { + let stroke = shortcut.firstStroke + reserved.append( + contentsOf: (1...9).map { digit in + StoredShortcut( + key: String(digit), + command: stroke.command, + shift: stroke.shift, + option: stroke.option, + control: stroke.control + ) + } + ) + continue } - return normalized + reserved.append(shortcut) } + + reserved.append(contentsOf: hardcodedSystemWideHotkeyConflicts) + return reserved } + private static let hardcodedSystemWideHotkeyConflicts: [StoredShortcut] = [ + StoredShortcut(key: "d", command: true, shift: false, option: false, control: false), + StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true), + StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true), + StoredShortcut(key: "`", command: true, shift: false, option: false, control: false), + StoredShortcut(key: "`", command: true, shift: true, option: false, control: false), + // Cmd+. is AppKit's standard cancel keystroke for modal alerts and + // open/save panels. Refuse to register it as the global hotkey so the + // first instinctive "cancel" press never hides the whole app. + StoredShortcut(key: ".", command: true, shift: false, option: false, control: false), + ] + static func shortcut(for action: Action) -> StoredShortcut { if let managedShortcut = settingsFileStore.override(for: action) { return managedShortcut @@ -321,7 +407,7 @@ enum KeyboardShortcutSettings { let storedShortcut: StoredShortcut if let normalizedShortcut = action.normalizedRecordedShortcut(shortcut) { storedShortcut = normalizedShortcut - } else if action.usesNumberedDigitMatching { + } else if action.usesNumberedDigitMatching || action == .showHideAllWindows { return } else { storedShortcut = shortcut @@ -409,12 +495,370 @@ enum KeyboardShortcutSettings { static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) } } +enum SystemWideHotkeySettings { + static let enabledKey = "systemWideHotkey.enabled" + static let legacyShortcutKey = "systemWideHotkey.shortcut" + static let defaultEnabled = false + static let action: KeyboardShortcutSettings.Action = .showHideAllWindows + + static var defaultShortcut: StoredShortcut { action.defaultShortcut } + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled + } + + static func setEnabled(_ enabled: Bool, defaults: UserDefaults = .standard) { + defaults.set(enabled, forKey: enabledKey) + } + + static func shortcut() -> StoredShortcut { + migrateLegacyShortcutIfNeeded() + return storedShortcut() ?? defaultShortcut + } + + static func setShortcut(_ shortcut: StoredShortcut) { + migrateLegacyShortcutIfNeeded() + KeyboardShortcutSettings.setShortcut(shortcut, for: action) + } + + static func normalizedRecordedShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { + action.normalizedRecordedShortcut(shortcut) + } + + static func isManagedBySettingsFile() -> Bool { + KeyboardShortcutSettings.isManagedBySettingsFile(action) + } + + static func settingsFileManagedSubtitle() -> String? { + KeyboardShortcutSettings.settingsFileManagedSubtitle(for: action) + } + + static func reset(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: enabledKey) + defaults.removeObject(forKey: legacyShortcutKey) + defaults.removeObject(forKey: action.defaultsKey) + } + + private static func migrateLegacyShortcutIfNeeded(defaults: UserDefaults = .standard) { + guard defaults.object(forKey: legacyShortcutKey) != nil else { return } + defer { defaults.removeObject(forKey: legacyShortcutKey) } + + guard defaults.object(forKey: action.defaultsKey) == nil, + let data = defaults.data(forKey: legacyShortcutKey), + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return + } + + let migratedShortcut = normalizedRecordedShortcut(shortcut) ?? shortcut + guard let migratedData = try? JSONEncoder().encode(migratedShortcut) else { return } + defaults.set(migratedData, forKey: action.defaultsKey) + } + + private static func storedShortcut(defaults: UserDefaults = .standard) -> StoredShortcut? { + if let managedShortcut = KeyboardShortcutSettings.settingsFileStore.override(for: action) { + return managedShortcut + } + guard let data = defaults.data(forKey: action.defaultsKey), + let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { + return nil + } + return shortcut + } +} + +struct CarbonHotKeyRegistration: Equatable { + let keyCode: UInt32 + let modifiers: UInt32 +} + +final class SystemWideHotkeyController { + static let shared = SystemWideHotkeyController() + private static let hotKeySignature: OSType = 0x434D484B // "CMHK" + private static let hotKeyID: UInt32 = 1 + + private var hotKeyRef: EventHotKeyRef? + private var hotKeyHandler: EventHandlerRef? + private var defaultsObserver: NSObjectProtocol? + private var shortcutObserver: NSObjectProtocol? + private var recorderObserver: NSObjectProtocol? + private var inputSourceObserver: NSObjectProtocol? + private var appHideObserver: NSObjectProtocol? + private var isEnabled = SystemWideHotkeySettings.defaultEnabled + private var shortcut = SystemWideHotkeySettings.defaultShortcut + private var registeredShortcut: StoredShortcut? + private var registeredHotKeyRegistration: CarbonHotKeyRegistration? + private var hiddenWindowRestoreTargets: [NSWindow] = [] + + private init() {} + + func start() { + guard defaultsObserver == nil else { return } + + installHotKeyHandlerIfNeeded() + + defaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshRegistration() + } + shortcutObserver = NotificationCenter.default.addObserver( + forName: KeyboardShortcutSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshRegistration() + } + recorderObserver = NotificationCenter.default.addObserver( + forName: KeyboardShortcutRecorderActivity.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshRegistration() + } + inputSourceObserver = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name(rawValue: kTISNotifySelectedKeyboardInputSourceChanged as String), + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshRegistration() + } + appHideObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willHideNotification, + object: NSApp, + queue: .main + ) { [weak self] _ in + self?.captureHiddenWindowRestoreTargets() + } + + refreshRegistration() + } + + private func refreshRegistration() { + isEnabled = SystemWideHotkeySettings.isEnabled() + shortcut = SystemWideHotkeySettings.shortcut() + let isShortcutRecordingActive = KeyboardShortcutRecorderActivity.isAnyRecorderActive + + guard isEnabled, !isShortcutRecordingActive else { + unregisterHotKey() + return + } + + guard let normalizedShortcut = SystemWideHotkeySettings.action.normalizedRecordedShortcut(shortcut), + let registration = normalizedShortcut.carbonHotKeyRegistration else { + unregisterHotKey() + return + } + + if registeredShortcut == normalizedShortcut, + registeredHotKeyRegistration == registration, + hotKeyRef != nil { + return + } + + unregisterHotKey() + installHotKeyHandlerIfNeeded() + + let hotKeyID = EventHotKeyID(signature: Self.hotKeySignature, id: Self.hotKeyID) + var hotKeyRef: EventHotKeyRef? + let status = RegisterEventHotKey( + registration.keyCode, + registration.modifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &hotKeyRef + ) + + guard status == noErr, let hotKeyRef else { +#if DEBUG + dlog( + "globalHotkey.register failed shortcut=\(normalizedShortcut.displayString) " + + "keyCode=\(registration.keyCode) modifiers=\(registration.modifiers) status=\(status)" + ) +#endif + return + } + + self.hotKeyRef = hotKeyRef + registeredShortcut = normalizedShortcut + registeredHotKeyRegistration = registration + +#if DEBUG + dlog( + "globalHotkey.register success shortcut=\(normalizedShortcut.displayString) " + + "keyCode=\(registration.keyCode) modifiers=\(registration.modifiers)" + ) +#endif + } + + private func installHotKeyHandlerIfNeeded() { + guard hotKeyHandler == nil else { return } + + var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + let userInfo = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + + let status = InstallEventHandler( + GetApplicationEventTarget(), + Self.hotKeyEventHandler, + 1, + &eventType, + userInfo, + &hotKeyHandler + ) + +#if DEBUG + if status != noErr { + dlog("globalHotkey.handlerInstall failed status=\(status)") + } +#endif + } + + private func unregisterHotKey() { + if let hotKeyRef { + UnregisterEventHotKey(hotKeyRef) + self.hotKeyRef = nil + } + registeredShortcut = nil + registeredHotKeyRegistration = nil + } + + private static let hotKeyEventHandler: EventHandlerUPP = { _, event, userInfo in + guard let userInfo else { return OSStatus(eventNotHandledErr) } + let controller = Unmanaged<SystemWideHotkeyController> + .fromOpaque(userInfo) + .takeUnretainedValue() + return controller.handleHotKeyEvent(event) + } + + private func handleHotKeyEvent(_ event: EventRef?) -> OSStatus { + guard let event else { return OSStatus(eventNotHandledErr) } + + var hotKeyID = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout<EventHotKeyID>.size, + nil, + &hotKeyID + ) + + guard status == noErr, + hotKeyID.signature == Self.hotKeySignature, + hotKeyID.id == Self.hotKeyID else { + return OSStatus(eventNotHandledErr) + } + +#if DEBUG + dlog("globalHotkey.fire shortcut=\(shortcut.displayString) active=\(NSApp.isActive ? 1 : 0)") +#endif + + DispatchQueue.main.async { [weak self] in + self?.toggleApplicationVisibility() + } + return OSStatus(noErr) + } + + private func toggleApplicationVisibility() { + // Only treat the hotkey as a "hide" toggle when cmux itself is the + // frontmost app and has at least one visible window. If the user + // pressed the hotkey from another app, cmux is not frontmost (even if + // some of its windows are still on screen) and the expected behavior + // is to bring cmux forward, not hide it. + let isFrontmost = NSApp.isActive && !NSApp.isHidden + let hasVisibleWindow = NSApp.windows.contains { $0.isVisible && !$0.isMiniaturized } + if isFrontmost && hasVisibleWindow { + captureHiddenWindowRestoreTargets() + NSApp.hide(nil) + return + } + + showAllApplicationWindows() + } + + private func captureHiddenWindowRestoreTargets() { + hiddenWindowRestoreTargets = NSApp.windows.filter { $0.isVisible || $0.isMiniaturized } + } + + private func showAllApplicationWindows() { + let allWindows = NSApp.windows + let revealTargets: [NSWindow] + + if NSApp.isHidden { + NSApp.unhide(nil) + let capturedTargets = hiddenWindowRestoreTargets.filter { window in + allWindows.contains { $0 === window } + } + hiddenWindowRestoreTargets.removeAll() + revealTargets = capturedTargets.isEmpty + ? allWindows.filter(\.isMiniaturized) + : capturedTargets + } else { + revealTargets = allWindows.filter { $0.isVisible || $0.isMiniaturized } + } + + guard !revealTargets.isEmpty else { return } + + for window in revealTargets where window.isMiniaturized { + window.deminiaturize(nil) + } + + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + + let focusWindow = preferredFocusWindow(from: revealTargets) + focusWindow?.makeKeyAndOrderFront(nil) + + for window in revealTargets where window !== focusWindow { + window.orderFrontRegardless() + } + } + + private func preferredFocusWindow(from windows: [NSWindow]) -> NSWindow? { + if let keyWindow = NSApp.keyWindow, + windows.contains(where: { $0 === keyWindow }) { + return keyWindow + } + + if let mainWindow = NSApp.mainWindow, + windows.contains(where: { $0 === mainWindow }) { + return mainWindow + } + + return windows.first(where: \.canBecomeMain) + ?? windows.first(where: \.canBecomeKey) + ?? windows.first + } +} + struct ShortcutStroke: Equatable { var key: String var command: Bool var shift: Bool var option: Bool var control: Bool + var keyCode: UInt16? + + init( + key: String, + command: Bool, + shift: Bool, + option: Bool, + control: Bool, + keyCode: UInt16? = nil + ) { + self.key = key + self.command = command + self.shift = shift + self.option = option + self.control = control + self.keyCode = keyCode + } var displayString: String { modifierDisplayString + keyDisplayString @@ -449,6 +893,10 @@ struct ShortcutStroke: Equatable { return flags } + var hasPrimaryModifier: Bool { + command || option || control + } + var keyEquivalent: KeyEquivalent? { switch key { case "←": @@ -470,8 +918,8 @@ struct ShortcutStroke: Equatable { } } - var eventModifiers: EventModifiers { - var modifiers: EventModifiers = [] + var eventModifiers: SwiftUI.EventModifiers { + var modifiers: SwiftUI.EventModifiers = [] if command { modifiers.insert(.command) } @@ -537,16 +985,15 @@ struct ShortcutStroke: Equatable { guard !isEscapeCancelEvent(event), let key = storedKey(from: event) else { return nil } - // Some keys include extra flags depending on the responder chain. - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + let flags = normalizedModifierFlags(from: event.modifierFlags) let stroke = ShortcutStroke( key: key, command: flags.contains(.command), shift: flags.contains(.shift), option: flags.contains(.option), - control: flags.contains(.control) + control: flags.contains(.control), + keyCode: event.keyCode ) if requireModifier, @@ -556,9 +1003,102 @@ struct ShortcutStroke: Equatable { return stroke } + static func normalizedModifierFlags(from flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { + flags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + } + + func matches( + event: NSEvent, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) + ) -> Bool { + matches( + keyCode: event.keyCode, + modifierFlags: event.modifierFlags, + eventCharacter: event.charactersIgnoringModifiers, + layoutCharacterProvider: layoutCharacterProvider + ) + } + + func matches( + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags, + eventCharacter: String?, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) + ) -> Bool { + let flags = Self.normalizedModifierFlags(from: modifierFlags) + guard flags == self.modifierFlags else { return false } + + let shortcutKey = key.lowercased() + if shortcutKey == "\r" { + return keyCode == 36 || keyCode == 76 + } + + if Self.shortcutCharacterMatches( + eventCharacter: eventCharacter, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: keyCode + ) { + return true + } + + let hasEventChars = !(eventCharacter?.isEmpty ?? true) + let eventCharsAreASCII = eventCharacter?.allSatisfy(\.isASCII) ?? true + let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true + if shortcutKeyIsDigit, + hasEventChars, + eventCharsAreASCII, + Self.digitForNumberKeyCode(keyCode) == nil { + return false + } + if hasEventChars, + eventCharsAreASCII, + flags.contains(.command), + !flags.contains(.control), + Self.shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { + return false + } + + let layoutCharacter = layoutCharacterProvider(keyCode, modifierFlags) + if Self.shortcutCharacterMatches( + eventCharacter: layoutCharacter, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: false, + eventKeyCode: keyCode + ) { + return true + } + + let allowANSIKeyCodeFallback = flags.contains(.control) + || (flags.contains(.command) + && !flags.contains(.control) + && ( + !Self.shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) + || (hasEventChars && !eventCharsAreASCII) + || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) + )) + if allowANSIKeyCodeFallback, + let expectedKeyCode = Self.keyCodeForShortcutKey(shortcutKey) { + return keyCode == expectedKeyCode + } + + return false + } + private static func storedKey(from event: NSEvent) -> String? { + storedKey( + keyCode: event.keyCode, + charactersIgnoringModifiers: event.charactersIgnoringModifiers + ) + } + + private static func storedKey( + keyCode: UInt16, + charactersIgnoringModifiers: String? + ) -> String? { // Prefer keyCode mapping so shifted symbol keys (e.g. "}") record as "]". - switch event.keyCode { + switch keyCode { case 123: return "←" // left arrow case 124: return "→" // right arrow case 125: return "↓" // down arrow @@ -580,7 +1120,7 @@ struct ShortcutStroke: Equatable { break } - guard let chars = event.charactersIgnoringModifiers?.lowercased(), + guard let chars = charactersIgnoringModifiers?.lowercased(), let char = chars.first else { return nil } @@ -590,6 +1130,184 @@ struct ShortcutStroke: Equatable { } return nil } + + static func normalizedShortcutEventCharacter( + _ eventCharacter: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> String { + let lowered = eventCharacter.lowercased() + guard applyShiftSymbolNormalization else { return lowered } + + switch lowered { + case "{": return "[" + case "}": return "]" + case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma + case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period + case "?": return "/" + case ":": return ";" + case "\"": return "'" + case "|": return "\\" + case "~": return "`" + case "+": return "=" + case "_": return "-" + case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 + case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 + case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 + case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 + case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 + case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 + case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 + case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 + case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 + case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 + default: return lowered + } + } + + private static func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { + guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { + return false + } + return CharacterSet.letters.contains(scalar) + } + + private static func shortcutCharacterMatches( + eventCharacter: String?, + shortcutKey: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Bool { + guard let eventCharacter, !eventCharacter.isEmpty else { return false } + return normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) == shortcutKey + } + + private static func keyCodeForShortcutKey(_ key: String) -> UInt16? { + switch key { + case "a": return 0 + case "s": return 1 + case "d": return 2 + case "f": return 3 + case "h": return 4 + case "g": return 5 + case "z": return 6 + case "x": return 7 + case "c": return 8 + case "v": return 9 + case "b": return 11 + case "q": return 12 + case "w": return 13 + case "e": return 14 + case "r": return 15 + case "y": return 16 + case "t": return 17 + case "1": return 18 + case "2": return 19 + case "3": return 20 + case "4": return 21 + case "6": return 22 + case "5": return 23 + case "=": return 24 + case "9": return 25 + case "7": return 26 + case "-": return 27 + case "8": return 28 + case "0": return 29 + case "]": return 30 + case "o": return 31 + case "u": return 32 + case "[": return 33 + case "i": return 34 + case "p": return 35 + case "l": return 37 + case "j": return 38 + case "'": return 39 + case "k": return 40 + case ";": return 41 + case "\\": return 42 + case ",": return 43 + case "/": return 44 + case "n": return 45 + case "m": return 46 + case ".": return 47 + case "\t": return 48 + case "`": return 50 + case "\r": return 36 + case "←": return 123 + case "→": return 124 + case "↓": return 125 + case "↑": return 126 + default: + return nil + } + } + + private static func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { + switch keyCode { + case 18: return 1 + case 19: return 2 + case 20: return 3 + case 21: return 4 + case 23: return 5 + case 22: return 6 + case 26: return 7 + case 28: return 8 + case 25: return 9 + default: + return nil + } + } + + var carbonModifiers: UInt32 { + var modifiers: UInt32 = 0 + if command { modifiers |= UInt32(cmdKey) } + if shift { modifiers |= UInt32(shiftKey) } + if option { modifiers |= UInt32(optionKey) } + if control { modifiers |= UInt32(controlKey) } + return modifiers + } + + func resolvedKeyCode( + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) + ) -> UInt16? { + if let keyCode { + return keyCode + } + + let shortcutKey = key.lowercased() + let flags = modifierFlags + let applyShiftNormalization = flags.contains(.shift) + + for candidateKeyCode in Self.supportedShortcutKeyCodes { + let candidateCharacter = layoutCharacterProvider(candidateKeyCode, flags) + if Self.shortcutCharacterMatches( + eventCharacter: candidateCharacter, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: applyShiftNormalization, + eventKeyCode: candidateKeyCode + ) { + return candidateKeyCode + } + } + + return Self.keyCodeForShortcutKey(shortcutKey) + } + + var carbonHotKeyRegistration: CarbonHotKeyRegistration? { + guard let keyCode = resolvedKeyCode() else { return nil } + return CarbonHotKeyRegistration(keyCode: UInt32(keyCode), modifiers: carbonModifiers) + } + + private static let supportedShortcutKeyCodes: [UInt16] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17, + 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, + 50, 123, 124, 125, 126, + ] } /// A keyboard shortcut that can be stored in UserDefaults @@ -599,11 +1317,13 @@ struct StoredShortcut: Codable, Equatable { var shift: Bool var option: Bool var control: Bool + var keyCode: UInt16? var chordKey: String? var chordCommand: Bool var chordShift: Bool var chordOption: Bool var chordControl: Bool + var chordKeyCode: UInt16? init( key: String, @@ -611,22 +1331,26 @@ struct StoredShortcut: Codable, Equatable { shift: Bool, option: Bool, control: Bool, + keyCode: UInt16? = nil, chordKey: String? = nil, chordCommand: Bool = false, chordShift: Bool = false, chordOption: Bool = false, - chordControl: Bool = false + chordControl: Bool = false, + chordKeyCode: UInt16? = nil ) { self.key = key self.command = command self.shift = shift self.option = option self.control = control + self.keyCode = keyCode self.chordKey = chordKey?.isEmpty == true ? nil : chordKey self.chordCommand = chordCommand self.chordShift = chordShift self.chordOption = chordOption self.chordControl = chordControl + self.chordKeyCode = chordKeyCode } init(first: ShortcutStroke, second: ShortcutStroke? = nil) { @@ -636,11 +1360,13 @@ struct StoredShortcut: Codable, Equatable { shift: first.shift, option: first.option, control: first.control, + keyCode: first.keyCode, chordKey: second?.key, chordCommand: second?.command ?? false, chordShift: second?.shift ?? false, chordOption: second?.option ?? false, - chordControl: second?.control ?? false + chordControl: second?.control ?? false, + chordKeyCode: second?.keyCode ) } @@ -650,11 +1376,13 @@ struct StoredShortcut: Codable, Equatable { case shift case option case control + case keyCode case chordKey case chordCommand case chordShift case chordOption case chordControl + case chordKeyCode } init(from decoder: Decoder) throws { @@ -665,11 +1393,13 @@ struct StoredShortcut: Codable, Equatable { shift: try container.decode(Bool.self, forKey: .shift), option: try container.decode(Bool.self, forKey: .option), control: try container.decode(Bool.self, forKey: .control), + keyCode: try container.decodeIfPresent(UInt16.self, forKey: .keyCode), chordKey: try container.decodeIfPresent(String.self, forKey: .chordKey), chordCommand: try container.decodeIfPresent(Bool.self, forKey: .chordCommand) ?? false, chordShift: try container.decodeIfPresent(Bool.self, forKey: .chordShift) ?? false, chordOption: try container.decodeIfPresent(Bool.self, forKey: .chordOption) ?? false, - chordControl: try container.decodeIfPresent(Bool.self, forKey: .chordControl) ?? false + chordControl: try container.decodeIfPresent(Bool.self, forKey: .chordControl) ?? false, + chordKeyCode: try container.decodeIfPresent(UInt16.self, forKey: .chordKeyCode) ) } @@ -679,7 +1409,8 @@ struct StoredShortcut: Codable, Equatable { command: command, shift: shift, option: option, - control: control + control: control, + keyCode: keyCode ) } @@ -690,7 +1421,8 @@ struct StoredShortcut: Codable, Equatable { command: chordCommand, shift: chordShift, option: chordOption, - control: chordControl + control: chordControl, + keyCode: chordKeyCode ) } @@ -731,12 +1463,16 @@ struct StoredShortcut: Codable, Equatable { firstStroke.modifierFlags } + var hasPrimaryModifier: Bool { + firstStroke.hasPrimaryModifier + } + var keyEquivalent: KeyEquivalent? { guard !hasChord else { return nil } return firstStroke.keyEquivalent } - var eventModifiers: EventModifiers { + var eventModifiers: SwiftUI.EventModifiers { firstStroke.eventModifiers } @@ -749,6 +1485,60 @@ struct StoredShortcut: Codable, Equatable { guard let stroke = ShortcutStroke.from(event: event) else { return nil } return StoredShortcut(first: stroke) } + + func matches( + event: NSEvent, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) + ) -> Bool { + guard !hasChord else { return false } + return firstStroke.matches(event: event, layoutCharacterProvider: layoutCharacterProvider) + } + + func matches( + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags, + eventCharacter: String?, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) + ) -> Bool { + guard !hasChord else { return false } + return firstStroke.matches( + keyCode: keyCode, + modifierFlags: modifierFlags, + eventCharacter: eventCharacter, + layoutCharacterProvider: layoutCharacterProvider + ) + } + + var carbonHotKeyRegistration: CarbonHotKeyRegistration? { + guard !hasChord else { return nil } + return firstStroke.carbonHotKeyRegistration + } +} + +private enum KeyboardShortcutRecorderActivity { + static let didChangeNotification = Notification.Name("cmux.keyboardShortcutRecorderActivityDidChange") + private static var activeRecorderCount = 0 + + static var isAnyRecorderActive: Bool { + activeRecorderCount > 0 + } + + static func beginRecording(center: NotificationCenter = .default) { + let wasActive = isAnyRecorderActive + activeRecorderCount += 1 + if wasActive != isAnyRecorderActive { + center.post(name: didChangeNotification, object: nil) + } + } + + static func endRecording(center: NotificationCenter = .default) { + guard activeRecorderCount > 0 else { return } + let wasActive = isAnyRecorderActive + activeRecorderCount -= 1 + if wasActive != isAnyRecorderActive { + center.post(name: didChangeNotification, object: nil) + } + } } /// View for recording a keyboard shortcut @@ -759,6 +1549,7 @@ struct KeyboardShortcutRecorder: View { var displayString: (StoredShortcut) -> String = { $0.displayString } var transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? = { $0 } var isDisabled: Bool = false + var onRecordingChanged: (Bool) -> Void = { _ in } @State private var isRecording = false var body: some View { @@ -778,7 +1569,8 @@ struct KeyboardShortcutRecorder: View { shortcut: $shortcut, isRecording: $isRecording, displayString: displayString, - transformRecordedShortcut: transformRecordedShortcut + transformRecordedShortcut: transformRecordedShortcut, + onRecordingChanged: onRecordingChanged ) .frame(width: 160) .disabled(isDisabled) @@ -791,6 +1583,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable { @Binding var isRecording: Bool let displayString: (StoredShortcut) -> String let transformRecordedShortcut: (StoredShortcut) -> StoredShortcut? + let onRecordingChanged: (Bool) -> Void func makeNSView(context: Context) -> ShortcutRecorderNSButton { let button = ShortcutRecorderNSButton() @@ -803,6 +1596,7 @@ private struct ShortcutRecorderButton: NSViewRepresentable { } button.onRecordingChanged = { recording in isRecording = recording + onRecordingChanged(recording) } return button } @@ -811,6 +1605,10 @@ private struct ShortcutRecorderButton: NSViewRepresentable { nsView.shortcut = shortcut nsView.displayString = displayString nsView.transformRecordedShortcut = transformRecordedShortcut + nsView.onRecordingChanged = { recording in + isRecording = recording + onRecordingChanged(recording) + } nsView.updateTitle() } } @@ -824,6 +1622,7 @@ final class ShortcutRecorderNSButton: NSButton { private var isRecording = false private var eventMonitor: Any? private var pendingChordStart: ShortcutStroke? + private var hasRegisteredRecordingActivity = false override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -875,8 +1674,10 @@ final class ShortcutRecorderNSButton: NSButton { } private func startRecording() { + guard !isRecording else { return } isRecording = true pendingChordStart = nil + registerRecordingActivityIfNeeded() onRecordingChanged?(true) updateTitle() @@ -927,8 +1728,10 @@ final class ShortcutRecorderNSButton: NSButton { } private func stopRecording() { + guard isRecording else { return } isRecording = false pendingChordStart = nil + unregisterRecordingActivityIfNeeded() onRecordingChanged?(false) updateTitle() @@ -944,6 +1747,18 @@ final class ShortcutRecorderNSButton: NSButton { stopRecording() } + private func registerRecordingActivityIfNeeded() { + guard !hasRegisteredRecordingActivity else { return } + hasRegisteredRecordingActivity = true + KeyboardShortcutRecorderActivity.beginRecording() + } + + private func unregisterRecordingActivityIfNeeded() { + guard hasRegisteredRecordingActivity else { return } + hasRegisteredRecordingActivity = false + KeyboardShortcutRecorderActivity.endRecording() + } + #if DEBUG var debugIsRecording: Bool { isRecording diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index c4418e524ab0..8ca1b26cfd58 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5829,6 +5829,8 @@ struct SettingsView: View { } } + GlobalHotkeySection() + SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts")) .id(SettingsNavigationTarget.keyboardShortcuts) .accessibilityIdentifier("SettingsKeyboardShortcutsSection") @@ -5868,7 +5870,9 @@ struct SettingsView: View { SettingsCardDivider() - let actions = KeyboardShortcutSettings.Action.allCases + let actions = KeyboardShortcutSettings.Action.allCases.filter { + $0 != SystemWideHotkeySettings.action + } ForEach(Array(actions.enumerated()), id: \.element.id) { index, action in ShortcutSettingRow(action: action) .padding(.horizontal, 14) @@ -6172,6 +6176,7 @@ struct SettingsView: View { socketPasswordStatusMessage = nil socketPasswordStatusIsError = false refreshDetectedImportBrowsers() + SystemWideHotkeySettings.reset() KeyboardShortcutSettings.resetAll() WorkspaceTabColorSettings.reset() reloadWorkspaceTabColorSettings() @@ -6811,6 +6816,85 @@ private struct ShortcutSettingRow: View { } } +private struct GlobalHotkeySection: View { + @AppStorage(SystemWideHotkeySettings.enabledKey) private var isEnabled = SystemWideHotkeySettings.defaultEnabled + @State private var shortcut = KeyboardShortcutSettings.shortcut(for: SystemWideHotkeySettings.action) + + private var enabledBinding: Binding<Bool> { + Binding( + get: { isEnabled }, + set: { newValue in + isEnabled = newValue + } + ) + } + + private var enableSubtitle: String { + if isEnabled { + return String( + localized: "settings.globalHotkey.enable.subtitleOn", + defaultValue: "Press the shortcut from any app to show or hide all cmux windows." + ) + } + return String( + localized: "settings.globalHotkey.enable.subtitleOff", + defaultValue: "Turn this on to show or hide all cmux windows from any app." + ) + } + + var body: some View { + SettingsSectionHeader(title: String(localized: "settings.section.globalHotkey", defaultValue: "Global Hotkey")) + .accessibilityIdentifier("SettingsGlobalHotkeySection") + + SettingsCard { + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.globalHotkey.enable", defaultValue: "Enable System-Wide Hotkey"), + subtitle: enableSubtitle + ) { + Toggle("", isOn: enabledBinding) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsGlobalHotkeyToggle") + } + + SettingsCardDivider() + + KeyboardShortcutRecorder( + label: String(localized: "settings.globalHotkey.shortcut", defaultValue: "Show/Hide All Windows"), + subtitle: KeyboardShortcutSettings.settingsFileManagedSubtitle(for: SystemWideHotkeySettings.action), + shortcut: $shortcut, + transformRecordedShortcut: { SystemWideHotkeySettings.action.normalizedRecordedShortcut($0) }, + isDisabled: KeyboardShortcutSettings.isManagedBySettingsFile(SystemWideHotkeySettings.action) + ) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .accessibilityIdentifier("SettingsGlobalHotkeyRecorder") + } + .onChange(of: shortcut) { newValue in + KeyboardShortcutSettings.setShortcut(newValue, for: SystemWideHotkeySettings.action) + } + .onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutSettings.didChangeNotification)) { _ in + syncFromDefaults() + } + + SettingsCardNote( + String( + localized: "settings.globalHotkey.note", + defaultValue: "Use Command, Option, or Control with another key. No extra macOS permission is required." + ) + ) + .accessibilityIdentifier("SettingsGlobalHotkeyNote") + } + + private func syncFromDefaults() { + let latestShortcut = KeyboardShortcutSettings.shortcut(for: SystemWideHotkeySettings.action) + if latestShortcut != shortcut { + shortcut = latestShortcut + } + } +} + private struct SettingsRootView: View { var body: some View { SettingsView() diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index cb6146269b98..8c891a91f0ca 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -576,6 +576,73 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .newTab), persistedShortcut) } + func testSystemWideHotkeySettingsPreserveInvalidManagedShortcutWithoutFallingBackToDefault() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + try writeSettingsFile( + """ + { + "shortcuts": { + "showHideAllWindows": ["ctrl+b", "c"] + } + } + """, + to: settingsFileURL + ) + + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ) + + let invalidShortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "c" + ) + + XCTAssertEqual( + KeyboardShortcutSettings.settingsFileStore.override(for: .showHideAllWindows), + invalidShortcut + ) + XCTAssertTrue(SystemWideHotkeySettings.isManagedBySettingsFile()) + XCTAssertEqual(SystemWideHotkeySettings.shortcut(), invalidShortcut) + XCTAssertNotEqual(SystemWideHotkeySettings.shortcut(), SystemWideHotkeySettings.defaultShortcut) + XCTAssertNil(SystemWideHotkeySettings.shortcut().carbonHotKeyRegistration) + } + + func testSystemWideHotkeyLegacyMigrationPreservesInvalidShortcut() throws { + let invalidShortcut = StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "c" + ) + let encodedShortcut = try XCTUnwrap(try? JSONEncoder().encode(invalidShortcut)) + let defaults = UserDefaults.standard + defaults.set(encodedShortcut, forKey: SystemWideHotkeySettings.legacyShortcutKey) + + let migratedShortcut = SystemWideHotkeySettings.shortcut() + + XCTAssertEqual(migratedShortcut, invalidShortcut) + XCTAssertNil(defaults.object(forKey: SystemWideHotkeySettings.legacyShortcutKey)) + + let migratedData = try XCTUnwrap( + defaults.data(forKey: KeyboardShortcutSettings.Action.showHideAllWindows.defaultsKey) + ) + let storedShortcut = try XCTUnwrap(try? JSONDecoder().decode(StoredShortcut.self, from: migratedData)) + XCTAssertEqual(storedShortcut, invalidShortcut) + XCTAssertNil(storedShortcut.carbonHotKeyRegistration) + } + func testBootstrapCreatesCommentedTemplateWhenPrimaryAndFallbackAreMissing() throws { let directoryURL = try makeTemporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } @@ -1079,6 +1146,121 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { } } +final class StoredShortcutMatchingTests: XCTestCase { + func testMatchingIgnoresCapsLock() { + let shortcut = StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) + + XCTAssertTrue( + shortcut.matches( + keyCode: 12, + modifierFlags: [.command, .capsLock], + eventCharacter: "q", + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testMatchingUsesRecordedCharacterForRemappedCommandLetter() { + let shortcut = StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) + + XCTAssertTrue( + shortcut.matches( + keyCode: 13, + modifierFlags: [.command], + eventCharacter: "q", + layoutCharacterProvider: { _, _ in nil } + ) + ) + XCTAssertFalse( + StoredShortcut(key: "w", command: true, shift: false, option: false, control: false).matches( + keyCode: 13, + modifierFlags: [.command], + eventCharacter: "q", + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testMatchingTreatsKeypadEnterAsReturn() { + let shortcut = StoredShortcut(key: "\r", command: true, shift: false, option: false, control: false) + + XCTAssertTrue( + shortcut.matches( + keyCode: 76, + modifierFlags: [.command], + eventCharacter: "\r", + layoutCharacterProvider: { _, _ in nil } + ) + ) + } + + func testMatchingFallsBackToLayoutCharacterForNonLatinInput() { + let shortcut = StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) + + XCTAssertTrue( + shortcut.matches( + keyCode: 17, + modifierFlags: [.command], + eventCharacter: "е", + layoutCharacterProvider: { keyCode, _ in + keyCode == 17 ? "t" : nil + } + ) + ) + } + + func testResolvedKeyCodeUsesCurrentLayoutWhenShortcutWasStoredByCharacter() { + let stroke = ShortcutStroke(key: "q", command: true, shift: false, option: false, control: false) + + XCTAssertEqual( + stroke.resolvedKeyCode( + layoutCharacterProvider: { keyCode, flags in + guard flags == [.command] else { return nil } + switch keyCode { + case 12: + return "'" + case 13: + return "q" + default: + return nil + } + } + ), + 13 + ) + } + + func testResolvedKeyCodePrefersRecordedPhysicalKeyOverLayoutLookup() { + let stroke = ShortcutStroke(key: "q", command: true, shift: false, option: false, control: false, keyCode: 13) + + XCTAssertEqual( + stroke.resolvedKeyCode( + layoutCharacterProvider: { keyCode, _ in + keyCode == 12 ? "q" : nil + } + ), + 13 + ) + XCTAssertEqual(stroke.carbonHotKeyRegistration?.keyCode, 13) + } + + func testSystemWideHotkeyNormalizationRejectsReservedShortcutByRecordedPhysicalKey() { + KeyboardShortcutSettings.resetAll() + defer { KeyboardShortcutSettings.resetAll() } + + let shortcut = StoredShortcut( + key: "q", + command: true, + shift: false, + option: false, + control: false, + keyCode: 13 + ) + + XCTAssertNil(KeyboardShortcutSettings.Action.showHideAllWindows.normalizedRecordedShortcut(shortcut)) + } +} + final class WorkspaceShortcutMapperTests: XCTestCase { func testCommandNineMapsToLastWorkspaceIndex() { diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index a2b4f483755c..c5bc5e915b2f 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -444,6 +444,7 @@ "enum": [ "openSettings", "reloadConfiguration", + "showHideAllWindows", "newWindow", "closeWindow", "toggleFullScreen", diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 41a33cd6bd6f..8458b069c257 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -25,6 +25,12 @@ export const shortcutCategories: ShortcutCategory[] = [ shortcuts: [ { id: "openSettings", combos: [["⌘", ","]], description: { en: "Settings", ja: "設定" } }, { id: "reloadConfiguration", combos: [["⌘", "⇧", ","]], description: { en: "Reload configuration", ja: "構成を再読み込み" } }, + { + id: "showHideAllWindows", + combos: [["⌃", "⌥", "⌘", "."]], + description: { en: "Show/hide all cmux windows", ja: "すべてのcmuxウインドウを表示/非表示" }, + note: { en: "system-wide hotkey", ja: "システム全体のホットキー" }, + }, { id: "commandPalette", combos: [["⌘", "⇧", "P"]], description: { en: "Command palette", ja: "コマンドパレット" } }, { id: "newWindow", combos: [["⌘", "⇧", "N"]], description: { en: "New window", ja: "新規ウインドウ" } }, { id: "closeWindow", combos: [["⌃", "⌘", "W"]], description: { en: "Close window", ja: "ウインドウを閉じる" } }, From f550206f2bd0935f5d26a739893601324f3567d1 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 01:41:13 -0700 Subject: [PATCH 33/89] Add passkey, WebAuthn, and FIDO2 support to browser pane (#2660) * Add passkey, WebAuthn, and FIDO2 support to browser pane * Fix popup access to shared passkey authorization manager * Fallback tagged reload signing when no Apple Development identity exists * Fix passkey signing order and entitlement checks * Fix CI regressions and address PR review feedback - Skip the system passkey authorization prompt when running under automated UI tests; the dialog blocked the main thread and caused testCmdFFocusesBrowserFindFieldAfterCmdDCmdLNavigation to time out. - Treat the cmux-loopback.localtest.me alias (and subdomains) as a loopback host so WebAuthn works in remote workspaces where local pages are proxied through that hostname. - Redact passkey authorization debug logs to log scheme/host only, avoiding leaks of OIDC state, auth codes, or user identifiers. - Add the broader privacy-prompt locale set for NSBluetoothAlwaysUsageDescription so non-English builds do not fall back to English in the macOS Bluetooth permission dialog. - Update BrowserPasskeyAuthorizationSupport tests to cover the new loopback alias and the URL-redaction helper. --- .github/workflows/nightly.yml | 10 +- .github/workflows/release.yml | 10 +- Resources/Info.plist | 2 + Resources/InfoPlist.xcstrings | 113 ++++++++++++ Sources/Panels/BrowserPanel.swift | 166 ++++++++++++++++++ .../Panels/BrowserPopupWindowController.swift | 5 + cmux.embedded.entitlements | 18 ++ cmux.entitlements | 2 + cmuxTests/BrowserConfigTests.swift | 138 +++++++++++++++ scripts/assert-passkey-entitlement.sh | 41 +++++ scripts/build-sign-upload.sh | 11 +- scripts/reload.sh | 28 ++- 12 files changed, 531 insertions(+), 13 deletions(-) create mode 100644 cmux.embedded.entitlements create mode 100755 scripts/assert-passkey-entitlement.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0e3a892c40bf..9d2e4602b3d1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -393,20 +393,22 @@ jobs: echo "Missing APPLE_SIGNING_IDENTITY secret" >&2 exit 1 fi - ENTITLEMENTS="cmux.entitlements" + APP_ENTITLEMENTS="cmux.entitlements" + EMBEDDED_ENTITLEMENTS="cmux.embedded.entitlements" for APP_PATH in \ "build-universal/Build/Products/Release/cmux NIGHTLY.app" do CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" if [ -f "$CLI_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$CLI_PATH" fi if [ -f "$HELPER_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" + ./scripts/assert-passkey-entitlement.sh "$APP_PATH" done - name: Notarize apps and dmgs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02203d76011e..c23e3026e333 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,17 +257,19 @@ jobs: exit 1 fi APP_PATH="build-universal/Build/Products/Release/cmux.app" - ENTITLEMENTS="cmux.entitlements" + APP_ENTITLEMENTS="cmux.entitlements" + EMBEDDED_ENTITLEMENTS="cmux.embedded.entitlements" CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" if [ -f "$CLI_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$CLI_PATH" fi if [ -f "$HELPER_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" + ./scripts/assert-passkey-entitlement.sh "$APP_PATH" - name: Notarize app if: steps.guard_release_assets.outputs.skip_all != 'true' diff --git a/Resources/Info.plist b/Resources/Info.plist index f41ba036881d..3b99f6ed615e 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -47,6 +47,8 @@ <string>A program running within cmux would like to use your microphone.</string> <key>NSCameraUsageDescription</key> <string>A program running within cmux would like to use your camera.</string> + <key>NSBluetoothAlwaysUsageDescription</key> + <string>cmux uses Bluetooth for cross-device passkey sign-in in embedded browser authentication flows.</string> <key>CFBundleURLTypes</key> <array> <dict> diff --git a/Resources/InfoPlist.xcstrings b/Resources/InfoPlist.xcstrings index baeee7089db6..cb7e83d383e0 100644 --- a/Resources/InfoPlist.xcstrings +++ b/Resources/InfoPlist.xcstrings @@ -2,6 +2,119 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "NSBluetoothAlwaysUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux uses Bluetooth for cross-device passkey sign-in in embedded browser authentication flows." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux は内蔵ブラウザの認証フローでクロスデバイスのパスキーサインインに Bluetooth を使用します。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 在嵌入式浏览器身份验证流程中使用蓝牙进行跨设备通行密钥登录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 在內嵌瀏覽器身份驗證流程中使用藍牙進行跨裝置密碼金鑰登入。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux는 내장 브라우저 인증 흐름에서 기기 간 패스키 로그인에 Bluetooth를 사용합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux verwendet Bluetooth für die geräteübergreifende Passkey-Anmeldung in den Authentifizierungsabläufen des integrierten Browsers." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux usa Bluetooth para el inicio de sesión con claves de acceso entre dispositivos en los flujos de autenticación del navegador integrado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux utilise Bluetooth pour la connexion par clé d'accès entre appareils dans les flux d'authentification du navigateur intégré." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux usa il Bluetooth per l'accesso con passkey tra dispositivi nei flussi di autenticazione del browser integrato." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux bruger Bluetooth til adgangsnøgle-login på tværs af enheder i den integrerede browsers godkendelsesforløb." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux używa Bluetooth do logowania za pomocą kluczy dostępu między urządzeniami w procesach uwierzytelniania wbudowanej przeglądarki." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux использует Bluetooth для входа с помощью ключей доступа между устройствами в потоках аутентификации встроенного браузера." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux koristi Bluetooth za prijavu pomoću pristupnog ključa između uređaja u tokovima autentifikacije ugrađenog preglednika." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستخدم cmux البلوتوث لتسجيل الدخول باستخدام مفاتيح المرور عبر الأجهزة في تدفقات المصادقة بالمتصفح المدمج." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux bruker Bluetooth for tilgangsnøkkel-pålogging på tvers av enheter i den innebygde nettleserens autentiseringsflyter." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux usa Bluetooth para login com chaves de acesso entre dispositivos nos fluxos de autenticação do navegador integrado." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ใช้บลูทูธสำหรับการลงชื่อเข้าใช้ด้วย passkey ข้ามอุปกรณ์ในขั้นตอนการตรวจสอบสิทธิ์ของเบราว์เซอร์ในตัว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux, gömülü tarayıcının kimlik doğrulama akışlarında cihazlar arası geçiş anahtarı oturum açma için Bluetooth kullanır." + } + } + } + }, "NSCameraUsageDescription": { "extractionState": "manual", "localizations": { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 64ec6cad492e..e238ef353ea4 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1,5 +1,6 @@ import Foundation import Combine +import AuthenticationServices import WebKit import AppKit import Bonsplit @@ -1728,6 +1729,164 @@ final class BrowserPortalAnchorView: NSView { } } +// WKWebView handles WebAuthn itself for browser apps; this support code only +// determines when cmux should request browser passkey access from macOS. +enum BrowserPasskeyAuthorizationSupport { + static let remoteLoopbackAliasHost = "cmux-loopback.localtest.me" + + static func isPotentiallyTrustworthy(url: URL?) -> Bool { + guard let url, + let scheme = url.scheme?.lowercased() else { + return false + } + if scheme == "https" { + return true + } + guard scheme == "http", + let host = url.host else { + return false + } + return isLoopbackHost(host) + } + + static func shouldRequestAuthorization( + for url: URL?, + authorizationState: ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, + hasPendingRequest: Bool, + didPromptThisSession: Bool + ) -> Bool { + guard isPotentiallyTrustworthy(url: url) else { return false } + guard authorizationState == .notDetermined else { return false } + guard !hasPendingRequest else { return false } + guard !didPromptThisSession else { return false } + return true + } + + /// Returns a host/origin string suitable for debug logs without leaking + /// query parameters, paths, OIDC state, or auth codes. + static func redactedURLDescription(for url: URL?) -> String { + guard let url else { return "nil" } + let scheme = url.scheme?.lowercased() ?? "?" + let host = url.host ?? "?" + return "\(scheme)://\(host)" + } + + private static func isLoopbackHost(_ host: String) -> Bool { + let normalized = normalizedHost(host) + let octets = normalized.split(separator: ".", omittingEmptySubsequences: false) + let is127Loopback = octets.count == 4 + && octets[0] == "127" + && octets.allSatisfy { component in + guard let value = Int(component) else { return false } + return (0...255).contains(value) + } + + return normalized == "localhost" + || normalized.hasSuffix(".localhost") + || normalized == "::1" + || is127Loopback + || normalized == remoteLoopbackAliasHost + || normalized.hasSuffix("." + remoteLoopbackAliasHost) + } + + private static func normalizedHost(_ host: String) -> String { + var normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized.hasPrefix("[") && normalized.hasSuffix("]") { + normalized.removeFirst() + normalized.removeLast() + } + if normalized.hasSuffix(".") { + normalized.removeLast() + } + return normalized + } +} + +private extension ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState { + var cmuxDebugName: String { + switch self { + case .authorized: + return "authorized" + case .denied: + return "denied" + case .notDetermined: + return "notDetermined" + @unknown default: + return "unknown" + } + } +} + +@MainActor +final class BrowserPasskeyAuthorizationManager { + static let shared = BrowserPasskeyAuthorizationManager() + + private let credentialManager = ASAuthorizationWebBrowserPublicKeyCredentialManager() + private var authorizationTask: Task<ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, Never>? + private var didPromptThisSession = false + + private init() {} + + func requestAuthorizationIfNeeded(for url: URL?, source: String) { + // Skip the system passkey authorization prompt when running under + // automated UI tests; the dialog blocks the main thread and breaks + // unrelated keyboard regressions on CI. + if SessionRestorePolicy.isRunningUnderAutomatedTests() { + return + } + + let state = credentialManager.authorizationStateForPlatformCredentials + let redactedURL = BrowserPasskeyAuthorizationSupport.redactedURLDescription(for: url) + guard BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( + for: url, + authorizationState: state, + hasPendingRequest: authorizationTask != nil, + didPromptThisSession: didPromptThisSession + ) else { +#if DEBUG + dlog( + "browser.passkey.authorization.skip " + + "source=\(source) origin=\(redactedURL) state=\(state.cmuxDebugName) " + + "pending=\(authorizationTask == nil ? 0 : 1) prompted=\(didPromptThisSession ? 1 : 0)" + ) +#endif + return + } + + didPromptThisSession = true +#if DEBUG + dlog( + "browser.passkey.authorization.begin " + + "source=\(source) origin=\(redactedURL) state=\(state.cmuxDebugName)" + ) +#endif + + let task = Task<ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, Never> { [credentialManager] in + await withCheckedContinuation { continuation in + credentialManager.requestAuthorizationForPublicKeyCredentials { authorizationState in + continuation.resume(returning: authorizationState) + } + } + } + authorizationTask = task + + Task { @MainActor [weak self] in + guard let self else { return } + let result = await task.value + self.authorizationTask = nil + if result == .notDetermined { + self.didPromptThisSession = false + } +#if DEBUG + dlog( + "browser.passkey.authorization.end " + + "source=\(source) origin=\(redactedURL) result=\(result.cmuxDebugName)" + ) +#endif + } + } +} + @MainActor final class BrowserPanel: Panel, ObservableObject { private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" @@ -6189,6 +6348,13 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } + if navigationAction.targetFrame?.isMainFrame != false { + BrowserPasskeyAuthorizationManager.shared.requestAuthorizationIfNeeded( + for: navigationAction.request.url, + source: "browser.navigation.mainFrame" + ) + } + #if DEBUG let targetURL = navigationAction.request.url?.absoluteString ?? "nil" dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 623a9bc997d5..4571818b6e5c 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -577,6 +577,11 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { return } + BrowserPasskeyAuthorizationManager.shared.requestAuthorizationIfNeeded( + for: url, + source: "browser.popup.navigation.mainFrame" + ) + decisionHandler(.allow) } diff --git a/cmux.embedded.entitlements b/cmux.embedded.entitlements new file mode 100644 index 000000000000..09e191a56f65 --- /dev/null +++ b/cmux.embedded.entitlements @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.cs.disable-library-validation</key> + <true/> + <key>com.apple.security.cs.allow-unsigned-executable-memory</key> + <true/> + <key>com.apple.security.cs.allow-jit</key> + <true/> + <key>com.apple.security.device.camera</key> + <true/> + <key>com.apple.security.device.audio-input</key> + <true/> + <key>com.apple.security.automation.apple-events</key> + <true/> +</dict> +</plist> diff --git a/cmux.entitlements b/cmux.entitlements index 09e191a56f65..2544d749d59e 100644 --- a/cmux.entitlements +++ b/cmux.entitlements @@ -8,6 +8,8 @@ <true/> <key>com.apple.security.cs.allow-jit</key> <true/> + <key>com.apple.developer.web-browser.public-key-credential</key> + <true/> <key>com.apple.security.device.camera</key> <true/> <key>com.apple.security.device.audio-input</key> diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 7ee49c5733a6..a5d5fb7349d5 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -1,6 +1,7 @@ import XCTest import Combine import AppKit +import AuthenticationServices import SwiftUI import UniformTypeIdentifiers import WebKit @@ -1224,6 +1225,143 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { } } +final class BrowserPasskeyAuthorizationSupportTests: XCTestCase { + func testPotentiallyTrustworthyWebAuthnURLAllowsHTTPSAndLoopbackHTTP() { + XCTAssertTrue( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "https://github.com/login") + ) + ) + XCTAssertTrue( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "http://localhost:3000/login") + ) + ) + XCTAssertTrue( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "http://auth.localhost/challenge") + ) + ) + XCTAssertTrue( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "http://127.0.0.1:8080") + ) + ) + XCTAssertTrue( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "http://127.42.99.1/login") + ) + ) + XCTAssertTrue( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "http://[::1]:8443") + ) + ) + // BrowserPanel.remoteProxyLoopbackAliasURL rewrites local pages on + // remote workspaces to this host, so it must be treated as loopback. + XCTAssertTrue( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "http://cmux-loopback.localtest.me/login") + ) + ) + XCTAssertTrue( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "http://app.cmux-loopback.localtest.me/login") + ) + ) + } + + func testPotentiallyTrustworthyWebAuthnURLRejectsUnsupportedOrigins() { + XCTAssertFalse( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "http://example.com/login") + ) + ) + XCTAssertFalse( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "http://0.0.0.0:3000") + ) + ) + XCTAssertFalse( + BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( + url: URL(string: "file:///tmp/login.html") + ) + ) + XCTAssertFalse(BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy(url: nil)) + } + + func testShouldRequestAuthorizationRequiresTrustworthyPendingFreeNotDeterminedState() { + XCTAssertTrue( + BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( + for: URL(string: "https://accounts.google.com"), + authorizationState: .notDetermined, + hasPendingRequest: false, + didPromptThisSession: false + ) + ) + + XCTAssertFalse( + BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( + for: URL(string: "https://accounts.google.com"), + authorizationState: .authorized, + hasPendingRequest: false, + didPromptThisSession: false + ) + ) + XCTAssertFalse( + BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( + for: URL(string: "https://accounts.google.com"), + authorizationState: .denied, + hasPendingRequest: false, + didPromptThisSession: false + ) + ) + XCTAssertFalse( + BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( + for: URL(string: "https://accounts.google.com"), + authorizationState: .notDetermined, + hasPendingRequest: true, + didPromptThisSession: false + ) + ) + XCTAssertFalse( + BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( + for: URL(string: "https://accounts.google.com"), + authorizationState: .notDetermined, + hasPendingRequest: false, + didPromptThisSession: true + ) + ) + XCTAssertFalse( + BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( + for: URL(string: "http://example.com"), + authorizationState: .notDetermined, + hasPendingRequest: false, + didPromptThisSession: false + ) + ) + } + + func testRedactedURLDescriptionStripsPathAndQuery() { + XCTAssertEqual( + BrowserPasskeyAuthorizationSupport.redactedURLDescription( + for: URL(string: "https://login.example.com/oauth?code=secret&state=token") + ), + "https://login.example.com" + ) + XCTAssertEqual( + BrowserPasskeyAuthorizationSupport.redactedURLDescription( + for: URL(string: "http://localhost:3000/path?token=abc") + ), + "http://localhost" + ) + XCTAssertEqual( + BrowserPasskeyAuthorizationSupport.redactedURLDescription(for: nil), + "nil" + ) + } +} + @MainActor final class BrowserDeveloperToolsConfigurationTests: XCTestCase { diff --git a/scripts/assert-passkey-entitlement.sh b/scripts/assert-passkey-entitlement.sh new file mode 100755 index 000000000000..1d480c350300 --- /dev/null +++ b/scripts/assert-passkey-entitlement.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "usage: $0 /path/to/app.app" >&2 + exit 64 +fi + +APP_PATH="$1" +EXPECTED_KEY="com.apple.developer.web-browser.public-key-credential" + +if [ ! -d "$APP_PATH" ]; then + echo "App bundle not found: $APP_PATH" >&2 + exit 66 +fi + +RAW_OUTPUT="$(mktemp)" +PLIST_OUTPUT="$(mktemp)" +cleanup() { + rm -f "$RAW_OUTPUT" "$PLIST_OUTPUT" +} +trap cleanup EXIT + +/usr/bin/codesign -d --entitlements :- "$APP_PATH" >"$RAW_OUTPUT" 2>&1 +awk 'found { print } /^<\?xml/ { found = 1; print }' "$RAW_OUTPUT" >"$PLIST_OUTPUT" + +if [ ! -s "$PLIST_OUTPUT" ]; then + echo "Could not extract entitlements from signed app: $APP_PATH" >&2 + cat "$RAW_OUTPUT" >&2 + exit 1 +fi + +VALUE="$(/usr/libexec/PlistBuddy -c "Print :$EXPECTED_KEY" "$PLIST_OUTPUT" 2>/dev/null || true)" +if [ "$VALUE" != "true" ]; then + echo "Missing expected entitlement '$EXPECTED_KEY' on: $APP_PATH" >&2 + cat "$PLIST_OUTPUT" >&2 + exit 1 +fi + +echo "Verified entitlement '$EXPECTED_KEY' on: $APP_PATH" diff --git a/scripts/build-sign-upload.sh b/scripts/build-sign-upload.sh index 86ec511ea099..f0e6e5812f56 100755 --- a/scripts/build-sign-upload.sh +++ b/scripts/build-sign-upload.sh @@ -47,7 +47,8 @@ fi TAG="$1" SIGN_HASH="A050CC7E193C8221BDBA204E731B046CDCCC1B30" -ENTITLEMENTS="cmux.entitlements" +APP_ENTITLEMENTS="cmux.entitlements" +EMBEDDED_ENTITLEMENTS="cmux.embedded.entitlements" APP_PATH="build/Build/Products/Release/cmux.app" # --- Pre-flight --- @@ -93,14 +94,16 @@ echo "Sparkle keys injected" # --- Codesign --- echo "Codesigning..." CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" +echo "Applying app entitlements from $APP_ENTITLEMENTS" if [ -f "$CLI_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$CLI_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$EMBEDDED_ENTITLEMENTS" "$CLI_PATH" fi if [ -f "$HELPER_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi -/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" +/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" +./scripts/assert-passkey-entitlement.sh "$APP_PATH" echo "Codesign verified" # --- Notarize app --- diff --git a/scripts/reload.sh b/scripts/reload.sh index 136c676b1208..fdc9921ee7f6 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -36,6 +36,20 @@ should_skip_ghostty_cli_helper_zig_build() { return 1 } +find_codesigning_identity_sha() { + local label_prefix="$1" + security find-identity -v -p codesigning 2>/dev/null \ + | awk -v prefix="$label_prefix" 'index($0, "\"" prefix) { print $2; exit }' +} + +find_apple_development_identity_sha() { + find_codesigning_identity_sha "Apple Development:" +} + +find_developer_id_application_identity_sha() { + find_codesigning_identity_sha "Developer ID Application:" +} + write_dev_cli_shim() { local target="$1" local fallback_bin="$2" @@ -430,7 +444,8 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then rm -f "$CMUX_SOCKET" fi fi - /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement-der "$TAG_APP_PATH" >/dev/null 2>&1 || true + APPLE_DEVELOPMENT_IDENTITY_SHA="$(find_apple_development_identity_sha || true)" + DEVELOPER_ID_IDENTITY_SHA="$(find_developer_id_application_identity_sha || true)" fi APP_PATH="$TAG_APP_PATH" fi @@ -475,6 +490,17 @@ if [[ -x "$GHOSTTY_HELPER_SRC" ]]; then cp "$GHOSTTY_HELPER_SRC" "$BIN_DIR/ghostty" chmod +x "$BIN_DIR/ghostty" fi +if [[ -n "$TAG" && -d "$APP_PATH" ]]; then + if [[ -n "${APPLE_DEVELOPMENT_IDENTITY_SHA:-}" ]]; then + /usr/bin/codesign --force --sign "$APPLE_DEVELOPMENT_IDENTITY_SHA" --timestamp=none --generate-entitlement-der --entitlements "$PWD/cmux.entitlements" "$APP_PATH" + elif [[ -n "${DEVELOPER_ID_IDENTITY_SHA:-}" ]]; then + echo "Only Developer ID signing identities were found. Unnotarized Developer ID-tagged builds fail local launch assessment, so this build stays ad-hoc and will not have passkey entitlements. Install an Apple Development identity to test passkeys locally." + /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement-der "$APP_PATH" + else + echo "No Apple Development or Developer ID signing identity found; local tagged app will launch without passkey entitlements." + /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement-der "$APP_PATH" + fi +fi CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" if [[ -x "$CLI_PATH" ]]; then echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true From 9d4220b174cf73a6f527f9b5fdc58c8d5860eee2 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 02:04:59 -0700 Subject: [PATCH 34/89] Fix workspace color picker context menu blinking (#2566) * Fix workspace color picker menu redraw * Update clipboard callback for current GhosttyKit * Freeze tab row invalidations while workspace color menu is open * Limit workspace menu freeze to background row fields --- Sources/ContentView.swift | 96 +++++++++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2e7a2d99a814..606dbac2eb3e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9887,6 +9887,13 @@ private final class SidebarTabItemSettingsStore: ObservableObject { } } +private struct SidebarTabItemPresentationSnapshot: Equatable { + let tabId: UUID + let unreadCount: Int + let latestNotificationText: String? + let showsModifierShortcutHints: Bool +} + struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel let onSendFeedback: () -> Void @@ -9902,6 +9909,7 @@ struct VerticalTabsSidebar: View { @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State private var draggedTabId: UUID? @State private var dropIndicator: SidebarDropIndicator? + @State private var frozenTabItemPresentation: SidebarTabItemPresentationSnapshot? @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @@ -9967,6 +9975,26 @@ struct VerticalTabsSidebar: View { let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets ? allSelectedRemoteContextMenuTargetsDisconnected : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) + let liveUnreadCount = notificationStore.unreadCount(forTabId: tab.id) + let liveLatestNotificationText: String? = { + guard showsSidebarNotificationMessage, + let notification = notificationStore.latestNotification(forTabId: tab.id) else { + return nil + } + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }() + let liveShowsModifierShortcutHints = modifierKeyMonitor.isModifierPressed + let livePresentation = SidebarTabItemPresentationSnapshot( + tabId: tab.id, + unreadCount: liveUnreadCount, + latestNotificationText: liveLatestNotificationText, + showsModifierShortcutHints: liveShowsModifierShortcutHints + ) + let frozenPresentation = frozenTabItemPresentation?.tabId == tab.id + ? frozenTabItemPresentation + : nil TabItemView( tabManager: tabManager, notificationStore: notificationStore, @@ -9980,21 +10008,13 @@ struct VerticalTabsSidebar: View { workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, canCloseWorkspace: canCloseWorkspace, accessibilityWorkspaceCount: workspaceCount, - unreadCount: notificationStore.unreadCount(forTabId: tab.id), - latestNotificationText: { - guard showsSidebarNotificationMessage, - let notification = notificationStore.latestNotification(forTabId: tab.id) else { - return nil - } - let text = notification.body.isEmpty ? notification.title : notification.body - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }(), + unreadCount: frozenPresentation?.unreadCount ?? liveUnreadCount, + latestNotificationText: frozenPresentation?.latestNotificationText ?? liveLatestNotificationText, rowSpacing: tabRowSpacing, setSelectionToTabs: { selection = .tabs }, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, + showsModifierShortcutHints: frozenPresentation?.showsModifierShortcutHints ?? liveShowsModifierShortcutHints, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, dropIndicator: $dropIndicator, @@ -10002,7 +10022,9 @@ struct VerticalTabsSidebar: View { remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, - settings: tabItemSettings + settings: tabItemSettings, + livePresentation: livePresentation, + frozenPresentation: $frozenTabItemPresentation ) .equatable() } @@ -10103,6 +10125,11 @@ struct VerticalTabsSidebar: View { dragAutoScrollController.stop() dropIndicator = nil } + .onChange(of: tabs.map(\.id)) { tabIds in + guard let frozenTabItemPresentation, + !tabIds.contains(frozenTabItemPresentation.tabId) else { return } + self.frozenTabItemPresentation = nil + } .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in guard draggedTabId != nil else { return } let reason = SidebarDragLifecycleNotification.reason(from: notification) @@ -12377,6 +12404,11 @@ enum SidebarTrailingAccessoryWidthPolicy { // and bridge only sidebar-visible workspace changes into local state. // Do NOT add @EnvironmentObject or new @Binding without updating ==. // Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. +private final class SidebarTabItemContextMenuState: ObservableObject { + var isVisible = false + var hasDeferredWorkspaceObservationInvalidation = false +} + private struct TabItemView: View, Equatable { private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) @@ -12429,7 +12461,10 @@ private struct TabItemView: View, Equatable { let allRemoteContextMenuTargetsConnecting: Bool let allRemoteContextMenuTargetsDisconnected: Bool let settings: SidebarTabItemSettingsSnapshot + let livePresentation: SidebarTabItemPresentationSnapshot + @Binding var frozenPresentation: SidebarTabItemPresentationSnapshot? @State private var workspaceObservationGeneration: UInt64 = 0 + @StateObject private var contextMenuState = SidebarTabItemContextMenuState() @State private var isHovering = false @State private var rowHeight: CGFloat = 1 @@ -13027,7 +13062,7 @@ private struct TabItemView: View, Equatable { "desc=\"\(debugCommandPaletteTextPreview(description))\"" ) #endif - workspaceObservationGeneration &+= 1 + scheduleWorkspaceObservationInvalidation() } .onReceive( tab.sidebarObservationPublisher @@ -13047,7 +13082,7 @@ private struct TabItemView: View, Equatable { "desc=\"\(debugCommandPaletteTextPreview(description))\"" ) #endif - workspaceObservationGeneration &+= 1 + scheduleWorkspaceObservationInvalidation() } .onDrag { #if DEBUG @@ -13078,6 +13113,7 @@ private struct TabItemView: View, Equatable { updateSelection() } .onHover { hovering in + guard !contextMenuState.isVisible else { return } isHovering = hovering } .accessibilityElement(children: .combine) @@ -13089,7 +13125,37 @@ private struct TabItemView: View, Equatable { .accessibilityAction(named: Text(moveDownActionText)) { moveBy(1) } - .contextMenu { workspaceContextMenu } + .contextMenu { + workspaceContextMenu + .onAppear { + contextMenuState.isVisible = true + contextMenuState.hasDeferredWorkspaceObservationInvalidation = false + frozenPresentation = livePresentation + } + .onDisappear { + contextMenuState.isVisible = false + frozenPresentation = nil + if isHovering { + isHovering = false + } + flushDeferredWorkspaceObservationInvalidation() + } + } + } + + private func scheduleWorkspaceObservationInvalidation() { + // Keep the context menu stable while background workspace telemetry keeps arriving. + if contextMenuState.isVisible { + contextMenuState.hasDeferredWorkspaceObservationInvalidation = true + return + } + workspaceObservationGeneration &+= 1 + } + + private func flushDeferredWorkspaceObservationInvalidation() { + guard contextMenuState.hasDeferredWorkspaceObservationInvalidation else { return } + contextMenuState.hasDeferredWorkspaceObservationInvalidation = false + workspaceObservationGeneration &+= 1 } private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { From fe7e292018250a157e00f88561789b38084939f7 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 02:10:29 -0700 Subject: [PATCH 35/89] Hide workspace portal content before teardown (#2658) --- Sources/Workspace.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 87ebe6372a2a..d65b8ead29d4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9296,6 +9296,12 @@ final class Workspace: Identifiable, ObservableObject { /// Called before the workspace is removed from TabManager to ensure child /// processes receive SIGHUP even if ARC deallocation is delayed. func teardownAllPanels() { + // Hide portal-hosted content up front so a workspace being torn down + // cannot keep drawing above the next selected/restored workspace while + // panel close work is still unwinding. + hideAllTerminalPortalViews() + hideAllBrowserPortalViews() + let panelEntries = Array(panels) for (panelId, panel) in panelEntries { panelSubscriptions.removeValue(forKey: panelId) From 266a0429d691b11324924b08f8e4a7dd1fabc282 Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 02:10:51 -0700 Subject: [PATCH 36/89] Coalesce sidebar PR polling per-repo, drop checks fetch, state-machine the probe queue (#2662) * Coalesce sidebar PR polling per repo * Fix PR refresh regressions and CI compile break * Back off unsupported repos and cap PR history fetch * Address PR review follow-up comments * Fix PR lookup fallbacks and report_pr contract * Address PR review follow-up comments - Remove dead GitHubPullRequestRESTPoller actor that became unreferenced after the refactor. - Drop vestigial `burst:` parameter from scheduleWorkspacePullRequestRefresh and update all call sites; it only fed a debug log string. - Stop the applyWorkspacePullRequestRefreshResults defer from always re-triggering a follow-up pass: the refresh task is nil'd before this method runs, so the OR clause was always true. - Fix applyWorkspaceGitMetadataSnapshot defer resurrecting cleared probe state when the snapshot arrives after the probe was torn down. Capture wasInFlight at function entry and skip the defer when not in-flight. - Tighten orderedUniquePullRequests fresh-over-stale test to actually exercise the freshness priority branch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- Sources/ContentView.swift | 12 +- Sources/TabManager.swift | 1831 +++++++++-------- Sources/TerminalController.swift | 46 +- Sources/Workspace.swift | 44 +- cmuxTests/SidebarOrderingTests.swift | 50 +- cmuxTests/TabManagerUnitTests.swift | 36 + .../WorkspacePullRequestSidebarTests.swift | 2 +- 7 files changed, 1051 insertions(+), 970 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 606dbac2eb3e..da5491e65839 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12961,12 +12961,13 @@ private struct TabItemView: View, Equatable { .underline() .lineLimit(1) .truncationMode(.tail) - Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) + Text(pullRequestStatusLabel(pullRequest.status)) .lineLimit(1) Spacer(minLength: 0) } .font(.system(size: 10, weight: .semibold)) .foregroundColor(pullRequestForegroundColor) + .opacity(pullRequest.isStale ? 0.5 : 1) } .buttonStyle(.plain) .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) @@ -13769,7 +13770,7 @@ private struct TabItemView: View, Equatable { let label: String let url: URL let status: SidebarPullRequestStatus - let checks: SidebarPullRequestChecksStatus? + let isStale: Bool } private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { @@ -13780,7 +13781,7 @@ private struct TabItemView: View, Equatable { label: pullRequest.label, url: pullRequest.url, status: pullRequest.status, - checks: pullRequest.checks + isStale: pullRequest.isStale ) } } @@ -13822,10 +13823,7 @@ private struct TabItemView: View, Equatable { NSWorkspace.shared.open(url) } - private func pullRequestStatusLabel( - _ status: SidebarPullRequestStatus, - checks _: SidebarPullRequestChecksStatus? - ) -> String { + private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String { switch status { case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 722e970e5eac..9e9a94378d9a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -9,570 +9,6 @@ import Combine // The old Tab class is replaced by Workspace typealias Tab = Workspace -private actor GitHubPullRequestRESTPoller { - struct Candidate: Sendable { - let workspaceId: UUID - let panelId: UUID - let branch: String - let repoSlugs: [String] - let currentPullRequest: CurrentPullRequest? - } - - struct CurrentPullRequest: Sendable { - let number: Int - let urlString: String - let repoSlug: String? - let statusRawValue: String - let branch: String? - let checksRawValue: String? - } - - struct RefreshResult: Sendable { - let workspaceId: UUID - let panelId: UUID - let resolution: Resolution - } - - enum Resolution: Sendable { - case unsupportedRepository - case notFound - case resolved(ResolvedPullRequest) - case transientFailure - } - - struct ResolvedPullRequest: Sendable { - let number: Int - let urlString: String - let statusRawValue: String - let branch: String? - let checksRawValue: String? - } - - private struct WorkspacePanelKey: Hashable, Sendable { - let workspaceId: UUID - let panelId: UUID - } - - private struct PullRequestKey: Hashable, Sendable { - let repoSlug: String - let number: Int - } - - private struct CachedValue<Payload: Sendable>: Sendable { - let etag: String? - let payload: Payload - } - - private struct HTTPResponse: Sendable { - let statusCode: Int - let data: Data - let etag: String? - let rateLimitResetAt: Date? - } - - private enum RepoLookupResult: Sendable { - case success([OpenPullRequest]) - case transientFailure - } - - private struct OpenPullRequest: Decodable, Sendable { - struct PullHead: Decodable, Sendable { - let ref: String - } - - let number: Int - let state: String - let htmlURL: String - let updatedAt: String? - let head: PullHead - - enum CodingKeys: String, CodingKey { - case number - case state - case htmlURL = "html_url" - case updatedAt = "updated_at" - case head - } - } - - private struct PullRequestDetail: Decodable, Sendable { - let number: Int - let state: String - let htmlURL: String - let mergedAt: String? - - enum CodingKeys: String, CodingKey { - case number - case state - case htmlURL = "html_url" - case mergedAt = "merged_at" - } - } - - private let session: URLSession - private var authToken: String? - private var attemptedAuthTokenLookup = false - private var openPullRequestCacheByRepo: [String: CachedValue<[OpenPullRequest]>] = [:] - private var pullRequestDetailCacheByKey: [PullRequestKey: CachedValue<PullRequestDetail>] = [:] - private var resourceBackoffUntil: [String: Date] = [:] - - init() { - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 8 - configuration.timeoutIntervalForResource = 8 - session = URLSession(configuration: configuration) - } - - func refresh(candidates: [Candidate]) async -> [RefreshResult] { - guard !candidates.isEmpty else { return [] } - - let repoSlugs = Set(candidates.flatMap(\.repoSlugs)) - let repoResults = await fetchOpenPullRequests(repoSlugs: repoSlugs) - - var resultsByKey: [WorkspacePanelKey: Resolution] = [:] - var pendingDetailLookups: [WorkspacePanelKey: PullRequestKey] = [:] - var detailTargets: Set<PullRequestKey> = [] - var detailNeedsByKey: [WorkspacePanelKey: Bool] = [:] - - for candidate in candidates { - let key = WorkspacePanelKey(workspaceId: candidate.workspaceId, panelId: candidate.panelId) - guard !candidate.repoSlugs.isEmpty else { - resultsByKey[key] = .unsupportedRepository - continue - } - - var sawTransientFailure = false - var resolvedOpenPullRequest: OpenPullRequest? - - for repoSlug in candidate.repoSlugs { - guard let repoResult = repoResults[repoSlug] else { continue } - switch repoResult { - case .success(let pullRequests): - let matchingPullRequests = pullRequests.filter { - $0.head.ref.trimmingCharacters(in: .whitespacesAndNewlines) == candidate.branch - } - if let preferred = Self.preferredOpenPullRequest(from: matchingPullRequests) { - resolvedOpenPullRequest = preferred - break - } - case .transientFailure: - sawTransientFailure = true - } - } - - if let resolvedOpenPullRequest, - let resolved = Self.resolvedPullRequest(from: resolvedOpenPullRequest, branch: candidate.branch) { - resultsByKey[key] = .resolved(resolved) - continue - } - - if let currentPullRequest = candidate.currentPullRequest { - switch currentPullRequest.statusRawValue { - case SidebarPullRequestStatus.open.rawValue: - if let repoSlug = currentPullRequest.repoSlug { - let detailKey = PullRequestKey(repoSlug: repoSlug, number: currentPullRequest.number) - pendingDetailLookups[key] = detailKey - detailTargets.insert(detailKey) - detailNeedsByKey[key] = sawTransientFailure - continue - } - resultsByKey[key] = sawTransientFailure ? .transientFailure : .notFound - case SidebarPullRequestStatus.merged.rawValue, - SidebarPullRequestStatus.closed.rawValue: - resultsByKey[key] = .resolved( - ResolvedPullRequest( - number: currentPullRequest.number, - urlString: currentPullRequest.urlString, - statusRawValue: currentPullRequest.statusRawValue, - branch: currentPullRequest.branch ?? candidate.branch, - checksRawValue: currentPullRequest.checksRawValue - ) - ) - default: - resultsByKey[key] = sawTransientFailure ? .transientFailure : .notFound - } - continue - } - - resultsByKey[key] = sawTransientFailure ? .transientFailure : .notFound - } - - let detailResults = await fetchPullRequestDetails(keys: detailTargets) - - for candidate in candidates { - let key = WorkspacePanelKey(workspaceId: candidate.workspaceId, panelId: candidate.panelId) - guard let detailKey = pendingDetailLookups[key] else { continue } - guard let detail = detailResults[detailKey] ?? nil else { - let needsTransientFailure = detailNeedsByKey[key] ?? false - resultsByKey[key] = needsTransientFailure ? .transientFailure : .notFound - continue - } - - guard let statusRawValue = Self.pullRequestStatusRawValue( - from: detail.state, - mergedAt: detail.mergedAt - ) else { - let needsTransientFailure = detailNeedsByKey[key] ?? false - resultsByKey[key] = needsTransientFailure ? .transientFailure : .notFound - continue - } - - resultsByKey[key] = .resolved( - ResolvedPullRequest( - number: detail.number, - urlString: detail.htmlURL, - statusRawValue: statusRawValue, - branch: candidate.branch, - checksRawValue: nil - ) - ) - } - - return candidates.map { candidate in - let key = WorkspacePanelKey(workspaceId: candidate.workspaceId, panelId: candidate.panelId) - return RefreshResult( - workspaceId: candidate.workspaceId, - panelId: candidate.panelId, - resolution: resultsByKey[key] ?? .notFound - ) - } - } - - private struct OpenPullRequestFetchPlan: Sendable { - let repoSlug: String - let resourceKey: String - let cached: CachedValue<[OpenPullRequest]>? - } - - private struct PullRequestDetailFetchPlan: Sendable { - let key: PullRequestKey - let resourceKey: String - let cached: CachedValue<PullRequestDetail>? - } - - private func fetchOpenPullRequests( - repoSlugs: Set<String> - ) async -> [String: RepoLookupResult] { - let now = Date() - var results: [String: RepoLookupResult] = [:] - var plansByRepo: [String: OpenPullRequestFetchPlan] = [:] - - for repoSlug in repoSlugs { - let resourceKey = "open:\(repoSlug)" - if let backoffUntil = resourceBackoffUntil[resourceKey], - backoffUntil > now { - if let cached = openPullRequestCacheByRepo[repoSlug] { - results[repoSlug] = .success(cached.payload) - } else { - results[repoSlug] = .transientFailure - } - continue - } - - plansByRepo[repoSlug] = OpenPullRequestFetchPlan( - repoSlug: repoSlug, - resourceKey: resourceKey, - cached: openPullRequestCacheByRepo[repoSlug] - ) - } - - guard !plansByRepo.isEmpty else { return results } - - let authHeader = authHeaderValue() - let session = self.session - let responses = await withTaskGroup( - of: (String, RequestResult).self, - returning: [(String, RequestResult)].self - ) { group in - for plan in plansByRepo.values { - let endpoint = "repos/\(plan.repoSlug)/pulls?state=open&sort=updated&direction=desc&per_page=100" - group.addTask { - let response = await Self.performRequest( - session: session, - endpoint: endpoint, - ifNoneMatch: plan.cached?.etag, - authHeader: authHeader - ) - return (plan.repoSlug, response) - } - } - - var collected: [(String, RequestResult)] = [] - for await response in group { - collected.append(response) - } - return collected - } - - for (repoSlug, response) in responses { - guard let plan = plansByRepo[repoSlug] else { continue } - switch response { - case .success(let httpResponse): - switch httpResponse.statusCode { - case 200: - guard let pullRequests = Self.decodeJSON([OpenPullRequest].self, from: httpResponse.data) else { - results[repoSlug] = .transientFailure - continue - } - openPullRequestCacheByRepo[repoSlug] = CachedValue( - etag: httpResponse.etag, - payload: pullRequests - ) - resourceBackoffUntil.removeValue(forKey: plan.resourceKey) - results[repoSlug] = .success(pullRequests) - case 304: - if let cached = plan.cached { - resourceBackoffUntil.removeValue(forKey: plan.resourceKey) - results[repoSlug] = .success(cached.payload) - } else { - results[repoSlug] = .transientFailure - } - default: - if let resetAt = httpResponse.rateLimitResetAt { - resourceBackoffUntil[plan.resourceKey] = resetAt - } - if let cached = plan.cached { - results[repoSlug] = .success(cached.payload) - } else { - results[repoSlug] = .transientFailure - } - } - case .failure: - if let cached = plan.cached { - results[repoSlug] = .success(cached.payload) - } else { - results[repoSlug] = .transientFailure - } - } - } - - return results - } - - private func fetchPullRequestDetails( - keys: Set<PullRequestKey> - ) async -> [PullRequestKey: PullRequestDetail?] { - let now = Date() - var results: [PullRequestKey: PullRequestDetail?] = [:] - var plansByKey: [PullRequestKey: PullRequestDetailFetchPlan] = [:] - - for key in keys { - let resourceKey = "detail:\(key.repoSlug)#\(key.number)" - if let backoffUntil = resourceBackoffUntil[resourceKey], - backoffUntil > now { - results[key] = pullRequestDetailCacheByKey[key]?.payload - continue - } - - plansByKey[key] = PullRequestDetailFetchPlan( - key: key, - resourceKey: resourceKey, - cached: pullRequestDetailCacheByKey[key] - ) - } - - guard !plansByKey.isEmpty else { return results } - - let authHeader = authHeaderValue() - let session = self.session - let responses = await withTaskGroup( - of: (PullRequestKey, RequestResult).self, - returning: [(PullRequestKey, RequestResult)].self - ) { group in - for plan in plansByKey.values { - let endpoint = "repos/\(plan.key.repoSlug)/pulls/\(plan.key.number)" - group.addTask { - let response = await Self.performRequest( - session: session, - endpoint: endpoint, - ifNoneMatch: plan.cached?.etag, - authHeader: authHeader - ) - return (plan.key, response) - } - } - - var collected: [(PullRequestKey, RequestResult)] = [] - for await response in group { - collected.append(response) - } - return collected - } - - for (key, response) in responses { - guard let plan = plansByKey[key] else { continue } - switch response { - case .success(let httpResponse): - switch httpResponse.statusCode { - case 200: - guard let detail = Self.decodeJSON(PullRequestDetail.self, from: httpResponse.data) else { - results[key] = nil - continue - } - pullRequestDetailCacheByKey[key] = CachedValue( - etag: httpResponse.etag, - payload: detail - ) - resourceBackoffUntil.removeValue(forKey: plan.resourceKey) - results[key] = detail - case 304: - resourceBackoffUntil.removeValue(forKey: plan.resourceKey) - results[key] = plan.cached?.payload - default: - if let resetAt = httpResponse.rateLimitResetAt { - resourceBackoffUntil[plan.resourceKey] = resetAt - } - results[key] = plan.cached?.payload - } - case .failure: - results[key] = plan.cached?.payload - } - } - - return results - } - - private enum RequestResult: Sendable { - case success(HTTPResponse) - case failure - } - - private nonisolated static func performRequest( - session: URLSession, - endpoint: String, - ifNoneMatch: String?, - authHeader: String? - ) async -> RequestResult { - guard let url = URL(string: "https://api.github.com/\(endpoint)") else { - return .failure - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - request.setValue("cmux-sidebar-pr-poller", forHTTPHeaderField: "User-Agent") - if let ifNoneMatch, !ifNoneMatch.isEmpty { - request.setValue(ifNoneMatch, forHTTPHeaderField: "If-None-Match") - } - if let authHeader { - request.setValue(authHeader, forHTTPHeaderField: "Authorization") - } - - do { - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - return .failure - } - return .success( - HTTPResponse( - statusCode: httpResponse.statusCode, - data: data, - etag: httpResponse.value(forHTTPHeaderField: "ETag"), - rateLimitResetAt: Self.rateLimitResetDate(from: httpResponse) - ) - ) - } catch { - return .failure - } - } - - private func authHeaderValue() -> String? { - if let token = authToken, !token.isEmpty { - return "Bearer \(token)" - } - guard !attemptedAuthTokenLookup else { return nil } - attemptedAuthTokenLookup = true - - let environment = ProcessInfo.processInfo.environment - if let envToken = environment["GH_TOKEN"] ?? environment["GITHUB_TOKEN"] { - let trimmed = envToken.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - authToken = trimmed - return "Bearer \(trimmed)" - } - } - - let process = Process() - let stdout = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["gh", "auth", "token"] - process.standardOutput = stdout - process.standardError = Pipe() - - do { - try process.run() - process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } - let tokenData = stdout.fileHandleForReading.readDataToEndOfFile() - let token = String(data: tokenData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !token.isEmpty else { return nil } - authToken = token - return "Bearer \(token)" - } catch { - return nil - } - } - - private static func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) -> T? { - try? JSONDecoder().decode(T.self, from: data) - } - - private static func rateLimitResetDate(from response: HTTPURLResponse) -> Date? { - guard response.value(forHTTPHeaderField: "X-RateLimit-Remaining") == "0", - let resetValue = response.value(forHTTPHeaderField: "X-RateLimit-Reset"), - let resetInterval = TimeInterval(resetValue) else { - return nil - } - return Date(timeIntervalSince1970: resetInterval) - } - - private static func preferredOpenPullRequest( - from pullRequests: [OpenPullRequest] - ) -> OpenPullRequest? { - pullRequests.max { lhs, rhs in - let lhsUpdatedAt = lhs.updatedAt ?? "" - let rhsUpdatedAt = rhs.updatedAt ?? "" - if lhsUpdatedAt != rhsUpdatedAt { - return lhsUpdatedAt < rhsUpdatedAt - } - return lhs.number < rhs.number - } - } - - private static func resolvedPullRequest( - from pullRequest: OpenPullRequest, - branch: String - ) -> ResolvedPullRequest? { - guard let statusRawValue = pullRequestStatusRawValue(from: pullRequest.state, mergedAt: nil) else { - return nil - } - return ResolvedPullRequest( - number: pullRequest.number, - urlString: pullRequest.htmlURL, - statusRawValue: statusRawValue, - branch: branch, - checksRawValue: nil - ) - } - - private static func pullRequestStatusRawValue( - from rawState: String, - mergedAt: String? - ) -> String? { - switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { - case "OPEN": - return SidebarPullRequestStatus.open.rawValue - case "CLOSED": - return (mergedAt?.isEmpty == false) - ? SidebarPullRequestStatus.merged.rawValue - : SidebarPullRequestStatus.closed.rawValue - default: - return nil - } - } -} - enum NewWorkspacePlacement: String, CaseIterable, Identifiable { case top case afterCurrent @@ -1301,21 +737,127 @@ class TabManager: ObservableObject { let executionError: String? } - private struct WorkspaceGitProbeKey: Hashable { + private struct WorkspaceGitProbeKey: Hashable, Sendable { + let workspaceId: UUID + let panelId: UUID + } + + private enum WorkspaceGitProbeState: Equatable { + case idle + case inFlight(rerunPending: Bool) + } + + private struct WorkspacePullRequestCandidate: Sendable { let workspaceId: UUID let panelId: UUID + let branch: String + let repoSlugs: [String] } - struct GitHubPullRequestProbeItem: Decodable, Equatable { + private struct WorkspacePullRequestResolvedItem: Sendable { + let number: Int + let urlString: String + let statusRawValue: String + let branch: String + } + + private struct WorkspacePullRequestRefreshResult: Sendable { + enum Resolution: Sendable { + case unsupportedRepository + case notFound + case resolved(WorkspacePullRequestResolvedItem) + case transientFailure + } + + let workspaceId: UUID + let panelId: UUID + let resolution: Resolution + let usedCachedRepoData: Bool + } + + private struct WorkspacePullRequestRepoCacheEntry: Sendable { + let fetchedAt: Date + let pullRequestsByBranch: [String: GitHubPullRequestProbeItem] + let knownAbsentBranches: Set<String> + + init( + fetchedAt: Date, + pullRequestsByBranch: [String: GitHubPullRequestProbeItem], + knownAbsentBranches: Set<String> = [] + ) { + self.fetchedAt = fetchedAt + self.pullRequestsByBranch = pullRequestsByBranch + self.knownAbsentBranches = knownAbsentBranches + } + } + + private enum WorkspacePullRequestRepoFetchResult: Sendable { + case success( + WorkspacePullRequestRepoCacheEntry, + usedCache: Bool, + transientBranches: Set<String> + ) + case transientFailure + } + + private enum WorkspacePullRequestBranchFetchResult: Sendable { + case found(GitHubPullRequestProbeItem) + case notFound + case transientFailure + } + + private struct WorkspacePullRequestBranchLookupOutcome: Sendable { + let cacheEntry: WorkspacePullRequestRepoCacheEntry + let transientBranches: Set<String> + } + + private struct WorkspacePullRequestHTTPResponse: Sendable { + let statusCode: Int + let data: Data + } + + private struct WorkspacePullRequestRESTItem: Decodable, Sendable { + struct Head: Decodable, Sendable { + let ref: String + } + let number: Int let state: String - let url: String + let htmlURL: String let updatedAt: String? + let mergedAt: String? + let head: Head + + enum CodingKeys: String, CodingKey { + case number + case state + case htmlURL = "html_url" + case updatedAt = "updated_at" + case mergedAt = "merged_at" + case head + } } - private struct GitHubPullRequestCheckItem: Decodable { - let bucket: String? - let state: String? + struct GitHubPullRequestProbeItem: Decodable, Equatable, Sendable { + let number: Int + let state: String + let url: String + let updatedAt: String? + let headRefName: String? + + init( + number: Int, + state: String, + url: String, + updatedAt: String?, + headRefName: String? = nil + ) { + self.number = number + self.state = state + self.url = url + self.updatedAt = updatedAt + self.headRefName = headRefName + } } /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). @@ -1330,14 +872,16 @@ class TabManager: ObservableObject { /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). private static var nextPortOrdinal: Int = 0 - private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] - private static let workspaceGitMetadataPollInterval: TimeInterval = 30 - private static let selectedWorkspaceGitMetadataPollInterval: TimeInterval = 5 - private static let workspacePullRequestPollTickInterval: TimeInterval = 5 - private static let workspacePullRequestIdlePollInterval: TimeInterval = 60 - private static let selectedWorkspacePullRequestIdlePollInterval: TimeInterval = 15 - private static let workspacePullRequestBurstPollInterval: TimeInterval = 5 - private static let workspacePullRequestBurstDuration: TimeInterval = 30 + private nonisolated static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] + private nonisolated static let backgroundPollInterval: TimeInterval = 60 + private nonisolated static let selectedPollInterval: TimeInterval = 10 + private nonisolated static let workspacePullRequestPollTickInterval: TimeInterval = 1 + private nonisolated static let workspacePullRequestRepoCacheLifetime: TimeInterval = 15 + private nonisolated static let workspacePullRequestRepoCachePruneLifetime: TimeInterval = 60 + private nonisolated static let workspacePullRequestRepoPageSize = 100 + private nonisolated static let workspacePullRequestRepoPageLimit = 2 + private nonisolated static let workspacePullRequestTerminalStateSweepInterval: TimeInterval = 15 * 60 + private nonisolated static let workspacePullRequestPollJitterFraction = 0.10 private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0 @Published var selectedTabId: UUID? { willSet { @@ -1425,14 +969,17 @@ class TabManager: ObservableObject { label: "com.cmux.initial-workspace-git-probe", qos: .utility ) - private let workspacePullRequestRESTPoller = GitHubPullRequestRESTPoller() - private var workspaceGitProbeGenerationByKey: [WorkspaceGitProbeKey: UUID] = [:] + private var workspaceGitProbeStateByKey: [WorkspaceGitProbeKey: WorkspaceGitProbeState] = [:] private var workspaceGitProbeTimersByKey: [WorkspaceGitProbeKey: [DispatchSourceTimer]] = [:] private var workspaceGitTrackedDirectoryByKey: [WorkspaceGitProbeKey: String] = [:] + private var workspacePullRequestProbeStateByKey: [WorkspaceGitProbeKey: WorkspaceGitProbeState] = [:] private var workspacePullRequestNextPollAtByKey: [WorkspaceGitProbeKey: Date] = [:] - private var workspacePullRequestBurstUntilByKey: [WorkspaceGitProbeKey: Date] = [:] + private var workspacePullRequestLastTerminalStateRefreshAtByKey: [WorkspaceGitProbeKey: Date] = [:] + private var workspacePullRequestTransientFailureCountByKey: [WorkspaceGitProbeKey: Int] = [:] + private var workspacePullRequestRepoCacheBySlug: [String: WorkspacePullRequestRepoCacheEntry] = [:] private var workspacePullRequestPollTimer: DispatchSourceTimer? private var workspacePullRequestRefreshTask: Task<Void, Never>? + private var workspacePullRequestFollowUpShouldBypassRepoCache = false // Recent tab history for back/forward navigation (like browser history) private var tabHistory: [UUID] = [] @@ -1556,7 +1103,7 @@ class TabManager: ObservableObject { /// even when the local branch/directory does not change. private func startWorkspaceGitMetadataPollTimer() { let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) - let interval = Self.workspaceGitMetadataPollInterval + let interval = Self.backgroundPollInterval timer.schedule(deadline: .now() + interval, repeating: interval) timer.setEventHandler { [weak self] in guard let self else { return } @@ -1574,7 +1121,7 @@ class TabManager: ObservableObject { /// background sweep across every tracked workspace. private func startSelectedWorkspaceGitMetadataPollTimer() { let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) - let interval = Self.selectedWorkspaceGitMetadataPollInterval + let interval = Self.selectedPollInterval timer.schedule(deadline: .now() + interval, repeating: interval) timer.setEventHandler { [weak self] in guard let self else { return } @@ -1602,7 +1149,7 @@ class TabManager: ObservableObject { } private func refreshTrackedWorkspaceGitMetadata() { - let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys) + let activeProbeKeys = activeWorkspaceGitProbeKeys for workspace in tabs { for panelId in trackedWorkspaceGitMetadataPollCandidatePanelIds( @@ -1624,7 +1171,7 @@ class TabManager: ObservableObject { return } - let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys) + let activeProbeKeys = activeWorkspaceGitProbeKeys let candidatePanelIds = trackedWorkspaceGitMetadataPollCandidatePanelIds( in: workspace, activeProbeKeys: activeProbeKeys @@ -1637,19 +1184,16 @@ class TabManager: ObservableObject { reason: "selectedPeriodicPoll" ) - scheduleWorkspacePullRequestRefresh( - workspaceId: workspace.id, - panelId: focusedPanelId, - reason: "selectedPeriodicPoll", - burst: false - ) } - private func refreshTrackedWorkspacePullRequestsIfNeeded(reason: String) { - guard workspacePullRequestRefreshTask == nil else { return } - + private func refreshTrackedWorkspacePullRequestsIfNeeded( + reason: String, + allowCachedResultsOverride: Bool? = nil + ) { let now = Date() - var candidates: [GitHubPullRequestRESTPoller.Candidate] = [] + var candidates: [WorkspacePullRequestCandidate] = [] + var candidateBranchesByRepo: [String: Set<String>] = [:] + var repoDirectoriesBySlug: [String: String] = [:] var requestedKeys: [WorkspaceGitProbeKey] = [] var validKeys: Set<WorkspaceGitProbeKey> = [] @@ -1657,29 +1201,84 @@ class TabManager: ObservableObject { for panelId in Set(workspace.panelGitBranches.keys).union(workspace.panelPullRequests.keys) { let key = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId) validKeys.insert(key) - guard shouldRefreshWorkspacePullRequest(key: key, now: now) else { continue } - guard let candidate = workspacePullRequestCandidate( - workspace: workspace, - panelId: panelId + let branch = Self.normalizedBranchName( + workspace.panelGitBranches[panelId]?.branch + ?? workspace.panelPullRequests[panelId]?.branch + ) + guard let branch else { + clearWorkspacePullRequestTracking(for: key) + continue + } + + if Self.shouldSkipWorkspacePullRequestLookup(branch: branch) { + workspace.clearPanelPullRequest(panelId: panelId) + clearWorkspacePullRequestTracking(for: key) + continue + } + + guard shouldRefreshWorkspacePullRequest( + key: key, + now: now, + currentPullRequest: workspace.panelPullRequests[panelId] ) else { continue } + + if case .inFlight = workspacePullRequestProbeStateByKey[key] { + markWorkspacePullRequestProbeRerunPending( + for: key, + bypassRepoCache: !Self.workspacePullRequestRefreshAllowsRepoCache(reason: reason) + ) + continue + } + + let candidate = workspacePullRequestCandidate( + workspace: workspace, + panelId: panelId, + branch: branch + ) candidates.append(candidate) requestedKeys.append(key) + for repoSlug in candidate.repoSlugs { + candidateBranchesByRepo[repoSlug, default: []].insert(candidate.branch) + } + if let directory = gitProbeDirectory(for: workspace, panelId: panelId) { + for repoSlug in candidate.repoSlugs where repoDirectoriesBySlug[repoSlug] == nil { + repoDirectoriesBySlug[repoSlug] = directory + } + } } } pruneWorkspacePullRequestTracking(validKeys: validKeys) - guard !candidates.isEmpty else { return } + guard !candidates.isEmpty, workspacePullRequestRefreshTask == nil else { return } + for key in requestedKeys { + workspacePullRequestProbeStateByKey[key] = .inFlight(rerunPending: false) + } - let poller = workspacePullRequestRESTPoller + let cacheBySlug = workspacePullRequestRepoCacheBySlug + let allowCachedResults = allowCachedResultsOverride + ?? Self.workspacePullRequestRefreshAllowsRepoCache(reason: reason) workspacePullRequestRefreshTask = Task { [weak self] in - let results = await poller.refresh(candidates: candidates) + let repoResults = await Self.fetchWorkspacePullRequestRepoResults( + repoDirectoriesBySlug: repoDirectoriesBySlug, + candidateBranchesByRepo: candidateBranchesByRepo, + cacheBySlug: cacheBySlug, + now: now, + allowCachedResults: allowCachedResults + ) + let results = Self.resolveWorkspacePullRequestRefreshResults( + candidates: candidates, + repoResults: repoResults + ) + guard !Task.isCancelled else { return } await MainActor.run { [weak self] in guard let self else { return } + guard !Task.isCancelled else { return } self.workspacePullRequestRefreshTask = nil self.applyWorkspacePullRequestRefreshResults( results, + repoResults: repoResults, requestedKeys: requestedKeys, now: Date(), reason: reason @@ -1690,87 +1289,124 @@ class TabManager: ObservableObject { private func shouldRefreshWorkspacePullRequest( key: WorkspaceGitProbeKey, - now: Date + now: Date, + currentPullRequest: SidebarPullRequestState? ) -> Bool { - let nextPollAt = workspacePullRequestNextPollAtByKey[key] ?? .distantPast - return nextPollAt <= now + Self.shouldRefreshWorkspacePullRequest( + now: now, + nextPollAt: workspacePullRequestNextPollAtByKey[key], + lastTerminalStateRefreshAt: workspacePullRequestLastTerminalStateRefreshAtByKey[key], + currentPullRequestStatus: currentPullRequest?.status + ) } private func workspacePullRequestCandidate( workspace: Workspace, - panelId: UUID - ) -> GitHubPullRequestRESTPoller.Candidate? { - let branch = Self.normalizedBranchName( - workspace.panelGitBranches[panelId]?.branch - ?? workspace.panelPullRequests[panelId]?.branch - ) - guard let branch, !Self.shouldSkipWorkspacePullRequestLookup(branch: branch) else { - return nil - } - + panelId: UUID, + branch: String + ) -> WorkspacePullRequestCandidate { let directory = gitProbeDirectory(for: workspace, panelId: panelId) let repoSlugs = directory.map(Self.githubRepositorySlugs(directory:)) ?? [] - let currentPullRequest = workspace.panelPullRequests[panelId].map { - GitHubPullRequestRESTPoller.CurrentPullRequest( - number: $0.number, - urlString: $0.url.absoluteString, - repoSlug: Self.githubRepositorySlug(fromPullRequestURL: $0.url), - statusRawValue: $0.status.rawValue, - branch: $0.branch, - checksRawValue: $0.checks?.rawValue - ) - } - - return GitHubPullRequestRESTPoller.Candidate( + return WorkspacePullRequestCandidate( workspaceId: workspace.id, panelId: panelId, branch: branch, - repoSlugs: repoSlugs, - currentPullRequest: currentPullRequest + repoSlugs: repoSlugs ) } private func scheduleWorkspacePullRequestRefresh( workspaceId: UUID, panelId: UUID, - reason: String, - burst: Bool + reason: String ) { let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) - workspacePullRequestNextPollAtByKey[key] = .distantPast - if burst { - let burstUntil = Date().addingTimeInterval(Self.workspacePullRequestBurstDuration) - let existingBurstUntil = workspacePullRequestBurstUntilByKey[key] ?? .distantPast - if burstUntil > existingBurstUntil { - workspacePullRequestBurstUntilByKey[key] = burstUntil - } + let shouldBypassRepoCache = !Self.workspacePullRequestRefreshAllowsRepoCache(reason: reason) + if shouldBypassRepoCache, workspacePullRequestRefreshTask != nil { + workspacePullRequestFollowUpShouldBypassRepoCache = true + } + if case .inFlight = workspacePullRequestProbeStateByKey[key] { + markWorkspacePullRequestProbeRerunPending( + for: key, + bypassRepoCache: shouldBypassRepoCache + ) + } else { + workspacePullRequestNextPollAtByKey[key] = .distantPast } #if DEBUG dlog( "workspace.prRefresh.schedule workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) reason=\(reason) burst=\(burst ? 1 : 0)" + "panel=\(panelId.uuidString.prefix(5)) reason=\(reason)" ) #endif refreshTrackedWorkspacePullRequestsIfNeeded(reason: reason) } private func applyWorkspacePullRequestRefreshResults( - _ results: [GitHubPullRequestRESTPoller.RefreshResult], + _ results: [WorkspacePullRequestRefreshResult], + repoResults: [String: WorkspacePullRequestRepoFetchResult], requestedKeys: [WorkspaceGitProbeKey], now: Date, reason: String ) { + for (repoSlug, repoResult) in repoResults { + guard case .success(let cacheEntry, let usedCache, _) = repoResult, + !usedCache else { + continue + } + workspacePullRequestRepoCacheBySlug[repoSlug] = cacheEntry + } + let requestedKeySet = Set(requestedKeys) - for result in results { - let key = WorkspaceGitProbeKey(workspaceId: result.workspaceId, panelId: result.panelId) + let resultsByKey = Dictionary( + uniqueKeysWithValues: results.map { + (WorkspaceGitProbeKey(workspaceId: $0.workspaceId, panelId: $0.panelId), $0) + } + ) + var needsFollowUpPass = false + + defer { + if needsFollowUpPass { + let shouldBypassRepoCache = workspacePullRequestFollowUpShouldBypassRepoCache + workspacePullRequestFollowUpShouldBypassRepoCache = false + refreshTrackedWorkspacePullRequestsIfNeeded( + reason: "\(reason).followUp", + allowCachedResultsOverride: shouldBypassRepoCache ? false : nil + ) + } + } + + for key in requestedKeys { + let rerunPending = workspacePullRequestProbeRerunPending(for: key) + workspacePullRequestProbeStateByKey[key] = .idle + if rerunPending { + workspacePullRequestNextPollAtByKey[key] = .distantPast + needsFollowUpPass = true + } + guard requestedKeySet.contains(key), - let workspace = tabs.first(where: { $0.id == result.workspaceId }), + let result = resultsByKey[key] else { + continue + } + + if rerunPending, + workspacePullRequestFollowUpShouldBypassRepoCache, + result.usedCachedRepoData { + continue + } + + guard let workspace = tabs.first(where: { $0.id == result.workspaceId }), workspace.panels[result.panelId] != nil else { + clearWorkspacePullRequestTracking(for: key) continue } + let priorPullRequest = workspace.panelPullRequests[result.panelId] + let countsAsTerminalSweep = priorPullRequest.map { $0.status != .open } ?? false + switch result.resolution { case .resolved(let resolvedPullRequest): + workspacePullRequestTransientFailureCountByKey[key] = 0 guard let status = SidebarPullRequestStatus(rawValue: resolvedPullRequest.statusRawValue), let url = URL(string: resolvedPullRequest.urlString) else { continue @@ -1782,14 +1418,35 @@ class TabManager: ObservableObject { url: url, status: status, branch: resolvedPullRequest.branch, - checks: resolvedPullRequest.checksRawValue.flatMap(SidebarPullRequestChecksStatus.init(rawValue:)) + isStale: false ) case .notFound: + workspacePullRequestTransientFailureCountByKey[key] = 0 + workspacePullRequestLastTerminalStateRefreshAtByKey.removeValue(forKey: key) if workspace.panelPullRequests[result.panelId] != nil { workspace.clearPanelPullRequest(panelId: result.panelId) } - case .unsupportedRepository, .transientFailure: - break + case .unsupportedRepository: + workspacePullRequestTransientFailureCountByKey[key] = 0 + workspacePullRequestLastTerminalStateRefreshAtByKey.removeValue(forKey: key) + if workspace.panelPullRequests[result.panelId] != nil { + workspace.clearPanelPullRequest(panelId: result.panelId) + } + case .transientFailure: + let nextFailureCount = (workspacePullRequestTransientFailureCountByKey[key] ?? 0) + 1 + workspacePullRequestTransientFailureCountByKey[key] = nextFailureCount + if nextFailureCount >= 3, + let currentPullRequest = workspace.panelPullRequests[result.panelId] { + workspace.updatePanelPullRequest( + panelId: result.panelId, + number: currentPullRequest.number, + label: currentPullRequest.label, + url: currentPullRequest.url, + status: currentPullRequest.status, + branch: currentPullRequest.branch, + isStale: true + ) + } } scheduleNextWorkspacePullRequestPoll( @@ -1797,8 +1454,12 @@ class TabManager: ObservableObject { workspace: workspace, panelId: result.panelId, now: now, - resolution: result.resolution + resolution: result.resolution, + countsAsTerminalSweep: countsAsTerminalSweep ) + if rerunPending { + workspacePullRequestNextPollAtByKey[key] = .distantPast + } #if DEBUG let label: String = { @@ -1826,43 +1487,160 @@ class TabManager: ObservableObject { workspace: Workspace, panelId: UUID, now: Date, - resolution: GitHubPullRequestRESTPoller.Resolution + resolution: WorkspacePullRequestRefreshResult.Resolution, + countsAsTerminalSweep: Bool ) { - let isSelectedFocusedPanel = selectedWorkspace?.id == workspace.id - && selectedWorkspace?.focusedPanelId == panelId - let burstUntil = workspacePullRequestBurstUntilByKey[key] - let isBursting = (burstUntil ?? .distantPast) > now - let interval: TimeInterval - - if isBursting { - interval = Self.workspacePullRequestBurstPollInterval - } else if case .transientFailure = resolution { - interval = Self.workspacePullRequestBurstPollInterval - } else if isSelectedFocusedPanel { - interval = Self.selectedWorkspacePullRequestIdlePollInterval - } else { - interval = Self.workspacePullRequestIdlePollInterval + if countsAsTerminalSweep { + workspacePullRequestLastTerminalStateRefreshAtByKey[key] = now + } + + if case .resolved(let resolvedPullRequest) = resolution, + let status = SidebarPullRequestStatus(rawValue: resolvedPullRequest.statusRawValue), + status != .open { + workspacePullRequestLastTerminalStateRefreshAtByKey[key] = now + workspacePullRequestNextPollAtByKey[key] = now.addingTimeInterval(Self.workspacePullRequestTerminalStateSweepInterval) + return + } + + if case .transientFailure = resolution, + workspacePullRequestLastTerminalStateRefreshAtByKey[key] != nil { + workspacePullRequestNextPollAtByKey[key] = now.addingTimeInterval(Self.workspacePullRequestTerminalStateSweepInterval) + return } - workspacePullRequestNextPollAtByKey[key] = now.addingTimeInterval(interval) - if !isBursting { - workspacePullRequestBurstUntilByKey.removeValue(forKey: key) + if case .unsupportedRepository = resolution { + workspacePullRequestLastTerminalStateRefreshAtByKey.removeValue(forKey: key) + workspacePullRequestNextPollAtByKey[key] = now.addingTimeInterval(Self.jitteredPollInterval(base: Self.backgroundPollInterval)) + return } + + workspacePullRequestLastTerminalStateRefreshAtByKey.removeValue(forKey: key) + let baseInterval = isSelectedFocusedPanel(workspace: workspace, panelId: panelId) + ? Self.selectedPollInterval + : Self.backgroundPollInterval + workspacePullRequestNextPollAtByKey[key] = now.addingTimeInterval(Self.jitteredPollInterval(base: baseInterval)) } private func pruneWorkspacePullRequestTracking(validKeys: Set<WorkspaceGitProbeKey>) { workspacePullRequestNextPollAtByKey = workspacePullRequestNextPollAtByKey.filter { validKeys.contains($0.key) } - workspacePullRequestBurstUntilByKey = workspacePullRequestBurstUntilByKey.filter { validKeys.contains($0.key) } + workspacePullRequestProbeStateByKey = workspacePullRequestProbeStateByKey.filter { validKeys.contains($0.key) } + workspacePullRequestLastTerminalStateRefreshAtByKey = workspacePullRequestLastTerminalStateRefreshAtByKey.filter { validKeys.contains($0.key) } + workspacePullRequestTransientFailureCountByKey = workspacePullRequestTransientFailureCountByKey.filter { validKeys.contains($0.key) } + let repoCacheCutoff = Date().addingTimeInterval(-Self.workspacePullRequestRepoCachePruneLifetime) + workspacePullRequestRepoCacheBySlug = workspacePullRequestRepoCacheBySlug.filter { + $0.value.fetchedAt >= repoCacheCutoff + } } private func clearWorkspacePullRequestTracking(for key: WorkspaceGitProbeKey) { workspacePullRequestNextPollAtByKey.removeValue(forKey: key) - workspacePullRequestBurstUntilByKey.removeValue(forKey: key) + workspacePullRequestProbeStateByKey.removeValue(forKey: key) + workspacePullRequestLastTerminalStateRefreshAtByKey.removeValue(forKey: key) + workspacePullRequestTransientFailureCountByKey.removeValue(forKey: key) } private func clearWorkspacePullRequestTracking(workspaceId: UUID) { workspacePullRequestNextPollAtByKey = workspacePullRequestNextPollAtByKey.filter { $0.key.workspaceId != workspaceId } - workspacePullRequestBurstUntilByKey = workspacePullRequestBurstUntilByKey.filter { $0.key.workspaceId != workspaceId } + workspacePullRequestProbeStateByKey = workspacePullRequestProbeStateByKey.filter { $0.key.workspaceId != workspaceId } + workspacePullRequestLastTerminalStateRefreshAtByKey = workspacePullRequestLastTerminalStateRefreshAtByKey.filter { $0.key.workspaceId != workspaceId } + workspacePullRequestTransientFailureCountByKey = workspacePullRequestTransientFailureCountByKey.filter { $0.key.workspaceId != workspaceId } + } + + private func resetWorkspacePullRequestRefreshState() { + workspacePullRequestRefreshTask?.cancel() + workspacePullRequestRefreshTask = nil + workspacePullRequestProbeStateByKey.removeAll() + workspacePullRequestNextPollAtByKey.removeAll() + workspacePullRequestLastTerminalStateRefreshAtByKey.removeAll() + workspacePullRequestTransientFailureCountByKey.removeAll() + workspacePullRequestRepoCacheBySlug.removeAll() + workspacePullRequestFollowUpShouldBypassRepoCache = false + } + + private var activeWorkspaceGitProbeKeys: Set<WorkspaceGitProbeKey> { + Set(workspaceGitProbeStateByKey.compactMap { key, state in + guard case .inFlight = state else { return nil } + return key + }) + } + + private func markWorkspaceGitProbeRerunPending(for key: WorkspaceGitProbeKey) { + guard case .inFlight(let rerunPending) = workspaceGitProbeStateByKey[key], + !rerunPending else { + return + } + workspaceGitProbeStateByKey[key] = .inFlight(rerunPending: true) + } + + private func workspaceGitProbeRerunPending(for key: WorkspaceGitProbeKey) -> Bool { + guard case .inFlight(let rerunPending) = workspaceGitProbeStateByKey[key] else { + return false + } + return rerunPending + } + + private func markWorkspacePullRequestProbeRerunPending( + for key: WorkspaceGitProbeKey, + bypassRepoCache: Bool + ) { + guard case .inFlight(let rerunPending) = workspacePullRequestProbeStateByKey[key], + !rerunPending else { + if bypassRepoCache { + workspacePullRequestFollowUpShouldBypassRepoCache = true + } + return + } + workspacePullRequestProbeStateByKey[key] = .inFlight(rerunPending: true) + if bypassRepoCache { + workspacePullRequestFollowUpShouldBypassRepoCache = true + } + } + + private func workspacePullRequestProbeRerunPending(for key: WorkspaceGitProbeKey) -> Bool { + guard case .inFlight(let rerunPending) = workspacePullRequestProbeStateByKey[key] else { + return false + } + return rerunPending + } + + private func isSelectedFocusedPanel(workspace: Workspace, panelId: UUID) -> Bool { + selectedWorkspace?.id == workspace.id && selectedWorkspace?.focusedPanelId == panelId + } + + private nonisolated static func jitteredPollInterval(base: TimeInterval) -> TimeInterval { + let jitter = base * Self.workspacePullRequestPollJitterFraction + return base + Double.random(in: -jitter...jitter) + } + + nonisolated static func workspacePullRequestRefreshAllowsRepoCache(reason: String) -> Bool { + let periodicPrefixes = [ + "periodicPoll", + "selectedPeriodicPoll", + "timer", + ] + return periodicPrefixes.contains { prefix in + reason == prefix || reason.hasPrefix("\(prefix).") + } + } + + nonisolated static func shouldRefreshWorkspacePullRequest( + now: Date, + nextPollAt: Date?, + lastTerminalStateRefreshAt: Date?, + currentPullRequestStatus: SidebarPullRequestStatus? + ) -> Bool { + let nextPollAt = nextPollAt ?? .distantPast + if nextPollAt <= now { + return true + } + + guard let currentPullRequestStatus, + currentPullRequestStatus != .open else { + return false + } + + let lastTerminalRefreshAt = lastTerminalStateRefreshAt ?? .distantPast + return now.timeIntervalSince(lastTerminalRefreshAt) >= Self.workspacePullRequestTerminalStateSweepInterval } func refreshTrackedWorkspaceGitMetadataForTesting() { @@ -1870,7 +1648,7 @@ class TabManager: ObservableObject { } func trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: UUID) -> Set<UUID> { - let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys) + let activeProbeKeys = activeWorkspaceGitProbeKeys guard let workspace = tabs.first(where: { $0.id == workspaceId }) else { return [] } @@ -1881,7 +1659,7 @@ class TabManager: ObservableObject { } func activeWorkspaceGitProbePanelIdsForTesting(workspaceId: UUID) -> Set<UUID> { - let probeKeys = Set(workspaceGitProbeGenerationByKey.keys.filter { $0.workspaceId == workspaceId }) + let probeKeys = Set(workspaceGitProbeStateByKey.keys.filter { $0.workspaceId == workspaceId }) .union(workspaceGitProbeTimersByKey.keys.filter { $0.workspaceId == workspaceId }) return Set(probeKeys.map(\.panelId)) } @@ -2347,9 +2125,10 @@ class TabManager: ObservableObject { ) { let normalizedDirectory = normalizeDirectory(directory) let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId) - let generation = UUID() cancelWorkspaceGitProbeTimers(for: key) - workspaceGitProbeGenerationByKey[key] = generation + if workspaceGitProbeStateByKey[key] == nil { + workspaceGitProbeStateByKey[key] = .idle + } #if DEBUG dlog( @@ -2364,21 +2143,44 @@ class TabManager: ObservableObject { let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue) timer.schedule(deadline: .now() + delay, repeating: .never) timer.setEventHandler { [weak self] in - let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory) Task { @MainActor [weak self] in - self?.applyWorkspaceGitMetadataSnapshot( - snapshot, - generation: generation, + self?.beginWorkspaceGitMetadataProbeAttempt( probeKey: key, expectedDirectory: normalizedDirectory, isLastAttempt: isLastAttempt ) } } - timers.append(timer) - timer.resume() + timers.append(timer) + timer.resume() + } + workspaceGitProbeTimersByKey[key] = timers + } + + private func beginWorkspaceGitMetadataProbeAttempt( + probeKey: WorkspaceGitProbeKey, + expectedDirectory: String, + isLastAttempt: Bool + ) { + switch workspaceGitProbeStateByKey[probeKey] ?? .idle { + case .idle: + workspaceGitProbeStateByKey[probeKey] = .inFlight(rerunPending: false) + case .inFlight: + markWorkspaceGitProbeRerunPending(for: probeKey) + return + } + + initialWorkspaceGitProbeQueue.async { [weak self] in + let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: expectedDirectory) + Task { @MainActor [weak self] in + self?.applyWorkspaceGitMetadataSnapshot( + snapshot, + probeKey: probeKey, + expectedDirectory: expectedDirectory, + isLastAttempt: isLastAttempt + ) + } } - workspaceGitProbeTimersByKey[key] = timers } private func cancelWorkspaceGitProbeTimers(for key: WorkspaceGitProbeKey) { @@ -2392,12 +2194,12 @@ class TabManager: ObservableObject { } private func clearWorkspaceGitProbe(_ key: WorkspaceGitProbeKey) { - workspaceGitProbeGenerationByKey.removeValue(forKey: key) + workspaceGitProbeStateByKey.removeValue(forKey: key) cancelWorkspaceGitProbeTimers(for: key) } private func clearWorkspaceGitProbes(workspaceId: UUID) { - let keys = Set(workspaceGitProbeGenerationByKey.keys.filter { $0.workspaceId == workspaceId }) + let keys = Set(workspaceGitProbeStateByKey.keys.filter { $0.workspaceId == workspaceId }) .union(workspaceGitProbeTimersByKey.keys.filter { $0.workspaceId == workspaceId }) for key in keys { clearWorkspaceGitProbe(key) @@ -2410,34 +2212,57 @@ class TabManager: ObservableObject { private func applyWorkspaceGitMetadataSnapshot( _ snapshot: InitialWorkspaceGitMetadataSnapshot, - generation: UUID, probeKey: WorkspaceGitProbeKey, expectedDirectory: String, isLastAttempt: Bool ) { + let wasInFlight: Bool = { + if case .inFlight = workspaceGitProbeStateByKey[probeKey] { return true } + return false + }() + let shouldClearProbe = shouldStopWorkspaceGitMetadataRefresh(snapshot) || isLastAttempt + var didClearProbe = false defer { - if shouldStopWorkspaceGitMetadataRefresh(snapshot) || isLastAttempt, - workspaceGitProbeGenerationByKey[probeKey] == generation { - clearWorkspaceGitProbe(probeKey) + if wasInFlight, !didClearProbe { + let rerunPending = workspaceGitProbeRerunPending(for: probeKey) + if rerunPending { + workspaceGitProbeStateByKey[probeKey] = .idle + if shouldClearProbe { + cancelWorkspaceGitProbeTimers(for: probeKey) + } + scheduleWorkspaceGitMetadataRefreshIfPossible( + workspaceId: probeKey.workspaceId, + panelId: probeKey.panelId, + reason: "rerunPending" + ) + } else if shouldClearProbe { + clearWorkspaceGitProbe(probeKey) + } else { + workspaceGitProbeStateByKey[probeKey] = .idle + } } } - guard workspaceGitProbeGenerationByKey[probeKey] == generation else { return } + guard wasInFlight else { return } guard let workspace = tabs.first(where: { $0.id == probeKey.workspaceId }) else { clearWorkspaceGitProbe(probeKey) + didClearProbe = true return } guard workspace.panels[probeKey.panelId] != nil else { clearWorkspaceGitProbe(probeKey) + didClearProbe = true return } guard let currentDirectory = gitProbeDirectory(for: workspace, panelId: probeKey.panelId) else { clearWorkspaceGitProbe(probeKey) + didClearProbe = true return } if currentDirectory != expectedDirectory { clearWorkspaceGitProbe(probeKey) + didClearProbe = true #if DEBUG dlog( "workspace.gitProbe.skip workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " + @@ -2480,7 +2305,8 @@ class TabManager: ObservableObject { label: pullRequest.label, url: pullRequest.url, status: pullRequest.status, - checks: pullRequest.checks + branch: pullRequest.branch, + isStale: false ) case .notFound: if workspace.panelPullRequests[probeKey.panelId] != nil { @@ -2494,8 +2320,7 @@ class TabManager: ObservableObject { scheduleWorkspacePullRequestRefresh( workspaceId: probeKey.workspaceId, panelId: probeKey.panelId, - reason: "localGitProbe", - burst: false + reason: "localGitProbe" ) } @@ -2512,8 +2337,7 @@ class TabManager: ObservableObject { case .transientFailure: return "transientFailure" case .resolved(let pullRequest): - let checks = pullRequest.checks?.rawValue ?? "none" - return "#\(pullRequest.number):\(pullRequest.status.rawValue):\(checks)" + return "#\(pullRequest.number):\(pullRequest.status.rawValue)" } }() dlog( @@ -2564,147 +2388,473 @@ class TabManager: ObservableObject { ) } - private nonisolated static func workspacePullRequestSnapshot( - directory: String, - branch: String - ) -> WorkspacePullRequestSnapshot { - guard !shouldSkipWorkspacePullRequestLookup(branch: branch) else { - return .notFound - } + private nonisolated static func fetchWorkspacePullRequestRepoResults( + repoDirectoriesBySlug: [String: String], + candidateBranchesByRepo: [String: Set<String>], + cacheBySlug: [String: WorkspacePullRequestRepoCacheEntry], + now: Date, + allowCachedResults: Bool + ) async -> [String: WorkspacePullRequestRepoFetchResult] { + guard !repoDirectoriesBySlug.isEmpty else { return [:] } - let repoSlugs = githubRepositorySlugs(directory: directory) - guard !repoSlugs.isEmpty else { - return .unsupportedRepository - } + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = max(Self.workspacePullRequestProbeTimeout, 8) + configuration.timeoutIntervalForResource = max(Self.workspacePullRequestProbeTimeout, 8) + let session = URLSession(configuration: configuration) + let authHeader = workspacePullRequestAuthHeaderValue() + var results: [String: WorkspacePullRequestRepoFetchResult] = [:] + + let fetchedResults = await withTaskGroup( + of: (String, WorkspacePullRequestRepoFetchResult).self, + returning: [(String, WorkspacePullRequestRepoFetchResult)].self + ) { group in + for repoSlug in repoDirectoriesBySlug.keys { + group.addTask { + let result = await Self.workspacePullRequestRepoFetchResult( + repoSlug: repoSlug, + candidateBranches: candidateBranchesByRepo[repoSlug] ?? [], + cachedEntry: cacheBySlug[repoSlug], + useCachedRecentWindow: allowCachedResults + && (cacheBySlug[repoSlug].map { + now.timeIntervalSince($0.fetchedAt) < Self.workspacePullRequestRepoCacheLifetime + } ?? false), + session: session, + authHeader: authHeader + ) + return (repoSlug, result) + } + } - var sawTransientFailure = false - for repoSlug in repoSlugs { - switch workspacePullRequestSnapshot(directory: directory, branch: branch, repoSlug: repoSlug) { - case .deferred: - continue - case .resolved(let pullRequest): - return .resolved(pullRequest) - case .transientFailure: - sawTransientFailure = true - case .notFound, .unsupportedRepository: - continue + var collected: [(String, WorkspacePullRequestRepoFetchResult)] = [] + for await result in group { + collected.append(result) } + return collected } - return sawTransientFailure ? .transientFailure : .notFound + for (repoSlug, result) in fetchedResults { + results[repoSlug] = result + } + return results } - private nonisolated static func workspacePullRequestSnapshot( - directory: String, - branch: String, - repoSlug: String - ) -> WorkspacePullRequestSnapshot { - let result = runCommandResult( - directory: directory, - executable: "gh", - arguments: [ - "pr", "list", - "--repo", repoSlug, - "--state", "all", - "--head", branch, - "--json", "number,state,url,updatedAt", - ], - timeout: workspacePullRequestProbeTimeout - ) + private nonisolated static func resolveWorkspacePullRequestRefreshResults( + candidates: [WorkspacePullRequestCandidate], + repoResults: [String: WorkspacePullRequestRepoFetchResult] + ) -> [WorkspacePullRequestRefreshResult] { + candidates.map { candidate in + if candidate.repoSlugs.isEmpty { + return WorkspacePullRequestRefreshResult( + workspaceId: candidate.workspaceId, + panelId: candidate.panelId, + resolution: .unsupportedRepository, + usedCachedRepoData: false + ) + } - guard let result else { -#if DEBUG - dlog( - "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) status=nil" + var matchedPullRequest: GitHubPullRequestProbeItem? + var matchedPullRequestUsedCache = false + var sawTransientFailure = false + var sawCachedSuccess = false + + for repoSlug in candidate.repoSlugs { + guard let repoResult = repoResults[repoSlug] else { continue } + switch repoResult { + case .success(let cacheEntry, let usedCache, let transientBranches): + if usedCache { + sawCachedSuccess = true + } + if let candidateMatch = cacheEntry.pullRequestsByBranch[candidate.branch] { + matchedPullRequest = candidateMatch + matchedPullRequestUsedCache = usedCache + break + } + if transientBranches.contains(candidate.branch) { + sawTransientFailure = true + } + case .transientFailure: + sawTransientFailure = true + } + } + + let resolution: WorkspacePullRequestRefreshResult.Resolution + let usedCachedRepoData: Bool + if let matchedPullRequest, + let status = pullRequestStatus(from: matchedPullRequest.state) { + resolution = .resolved( + WorkspacePullRequestResolvedItem( + number: matchedPullRequest.number, + urlString: matchedPullRequest.url, + statusRawValue: status.rawValue, + branch: candidate.branch + ) + ) + usedCachedRepoData = matchedPullRequestUsedCache + } else if sawTransientFailure { + resolution = .transientFailure + usedCachedRepoData = false + } else { + resolution = .notFound + usedCachedRepoData = sawCachedSuccess + } + + return WorkspacePullRequestRefreshResult( + workspaceId: candidate.workspaceId, + panelId: candidate.panelId, + resolution: resolution, + usedCachedRepoData: usedCachedRepoData ) -#endif - return .transientFailure } + } - guard !result.timedOut, - result.executionError == nil, - let exitStatus = result.exitStatus else { + private nonisolated static func workspacePullRequestRepoFetchResult( + repoSlug: String, + candidateBranches: Set<String>, + cachedEntry: WorkspacePullRequestRepoCacheEntry?, + useCachedRecentWindow: Bool, + session: URLSession, + authHeader: String? + ) async -> WorkspacePullRequestRepoFetchResult { + let normalizedCandidateBranches = Set(candidateBranches.compactMap(normalizedBranchName)) + + if useCachedRecentWindow, + let cachedEntry { + let unresolvedBranches = unresolvedWorkspacePullRequestBranches( + normalizedCandidateBranches, + in: cachedEntry + ) + if unresolvedBranches.isEmpty { #if DEBUG - let statusText: String - if result.timedOut { - statusText = "timeout" - } else if let executionError = result.executionError { - statusText = "error=\(executionError)" - } else { - statusText = "unknown" + dlog( + "workspace.prRefresh.repo.cache repo=\(repoSlug) " + + "branches=\(cachedEntry.pullRequestsByBranch.count)" + ) +#endif + return .success(cachedEntry, usedCache: true, transientBranches: []) } - let stderr = debugLogSnippet(result.stderr) ?? "none" + + let lookupOutcome = await workspacePullRequestBranchLookupOutcome( + repoSlug: repoSlug, + candidateBranches: unresolvedBranches, + baseEntry: cachedEntry, + refreshedAt: Date(), + session: session, + authHeader: authHeader + ) +#if DEBUG dlog( - "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) status=\(statusText) stderr=\(stderr)" + "workspace.prRefresh.repo.cache.miss repo=\(repoSlug) " + + "branchLookups=\(unresolvedBranches.count) transient=\(lookupOutcome.transientBranches.count)" ) #endif - return .transientFailure + return .success( + lookupOutcome.cacheEntry, + usedCache: false, + transientBranches: lookupOutcome.transientBranches + ) } - if exitStatus != 0 { + let fetchTimestamp = Date() + var page = 1 + var fetchedPageCount = 0 + var allPullRequests: [GitHubPullRequestProbeItem] = [] + + while page <= Self.workspacePullRequestRepoPageLimit { + let endpoint = "repos/\(repoSlug)/pulls?state=all&sort=updated&direction=desc&per_page=\(Self.workspacePullRequestRepoPageSize)&page=\(page)" + guard let response = await performWorkspacePullRequestRequest( + session: session, + endpoint: endpoint, + authHeader: authHeader + ) else { #if DEBUG - dlog( - "workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) status=exit=\(exitStatus) stderr=\(debugLogSnippet(result.stderr) ?? "none")" - ) + dlog("workspace.prRefresh.repo.fail repo=\(repoSlug) page=\(page) status=nil") #endif - return .transientFailure - } + return .transientFailure + } - let output = result.stdout ?? "" - guard let pullRequests = decodeJSON([GitHubPullRequestProbeItem].self, from: output) else { + guard response.statusCode == 200, + let pullRequests = decodeJSON([WorkspacePullRequestRESTItem].self, from: response.data) else { #if DEBUG - dlog( - "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")" + dlog("workspace.prRefresh.repo.fail repo=\(repoSlug) page=\(page) status=\(response.statusCode)") +#endif + return .transientFailure + } + + fetchedPageCount += 1 + allPullRequests.append(contentsOf: pullRequests.map(Self.workspacePullRequestProbeItem)) + if pullRequests.count < Self.workspacePullRequestRepoPageSize { + break + } + page += 1 + } + + let recentWindowEntry = WorkspacePullRequestRepoCacheEntry( + fetchedAt: fetchTimestamp, + pullRequestsByBranch: pullRequestMapByNormalizedBranch(from: allPullRequests) + ) + let unresolvedBranches = unresolvedWorkspacePullRequestBranches( + normalizedCandidateBranches, + in: recentWindowEntry + ) + let lookupOutcome: WorkspacePullRequestBranchLookupOutcome + if unresolvedBranches.isEmpty { + lookupOutcome = WorkspacePullRequestBranchLookupOutcome( + cacheEntry: recentWindowEntry, + transientBranches: [] + ) + } else { + lookupOutcome = await workspacePullRequestBranchLookupOutcome( + repoSlug: repoSlug, + candidateBranches: unresolvedBranches, + baseEntry: recentWindowEntry, + refreshedAt: fetchTimestamp, + session: session, + authHeader: authHeader ) + } +#if DEBUG + dlog( + "workspace.prRefresh.repo.success repo=\(repoSlug) pages=\(fetchedPageCount) " + + "branches=\(lookupOutcome.cacheEntry.pullRequestsByBranch.count) " + + "branchLookups=\(unresolvedBranches.count) transient=\(lookupOutcome.transientBranches.count)" + ) #endif + return .success( + lookupOutcome.cacheEntry, + usedCache: false, + transientBranches: lookupOutcome.transientBranches + ) + } + + private nonisolated static func unresolvedWorkspacePullRequestBranches( + _ candidateBranches: Set<String>, + in cacheEntry: WorkspacePullRequestRepoCacheEntry + ) -> [String] { + candidateBranches + .filter { + cacheEntry.pullRequestsByBranch[$0] == nil + && !cacheEntry.knownAbsentBranches.contains($0) + } + .sorted() + } + + private nonisolated static func workspacePullRequestBranchLookupOutcome( + repoSlug: String, + candidateBranches: [String], + baseEntry: WorkspacePullRequestRepoCacheEntry, + refreshedAt: Date, + session: URLSession, + authHeader: String? + ) async -> WorkspacePullRequestBranchLookupOutcome { + guard !candidateBranches.isEmpty else { + return WorkspacePullRequestBranchLookupOutcome( + cacheEntry: baseEntry, + transientBranches: [] + ) + } + + let branchResults = await withTaskGroup( + of: (String, WorkspacePullRequestBranchFetchResult).self, + returning: [(String, WorkspacePullRequestBranchFetchResult)].self + ) { group in + for branch in candidateBranches { + group.addTask { + let result = await Self.workspacePullRequestBranchFetchResult( + repoSlug: repoSlug, + branch: branch, + session: session, + authHeader: authHeader + ) + return (branch, result) + } + } + + var collected: [(String, WorkspacePullRequestBranchFetchResult)] = [] + for await result in group { + collected.append(result) + } + return collected + } + + var pullRequestsByBranch = baseEntry.pullRequestsByBranch + var knownAbsentBranches = baseEntry.knownAbsentBranches + var transientBranches: Set<String> = [] + + for (branch, result) in branchResults { + switch result { + case .found(let pullRequest): + pullRequestsByBranch[branch] = pullRequest + knownAbsentBranches.remove(branch) + case .notFound: + knownAbsentBranches.insert(branch) + case .transientFailure: + transientBranches.insert(branch) + } + } + + return WorkspacePullRequestBranchLookupOutcome( + cacheEntry: WorkspacePullRequestRepoCacheEntry( + fetchedAt: refreshedAt, + pullRequestsByBranch: pullRequestsByBranch, + knownAbsentBranches: knownAbsentBranches + ), + transientBranches: transientBranches + ) + } + + private nonisolated static func workspacePullRequestBranchFetchResult( + repoSlug: String, + branch: String, + session: URLSession, + authHeader: String? + ) async -> WorkspacePullRequestBranchFetchResult { + guard let endpoint = workspacePullRequestBranchEndpoint( + repoSlug: repoSlug, + branch: branch + ) else { return .transientFailure } - guard let pullRequest = preferredPullRequest(from: pullRequests) else { + guard let response = await performWorkspacePullRequestRequest( + session: session, + endpoint: endpoint, + authHeader: authHeader + ) else { #if DEBUG - dlog( - "workspace.gitProbe.pr.none dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug)" - ) + dlog("workspace.prRefresh.branch.fail repo=\(repoSlug) branch=\(branch) status=nil") #endif - return .notFound + return .transientFailure } - guard let status = pullRequestStatus(from: pullRequest.state), - let url = URL(string: pullRequest.url) else { + guard response.statusCode == 200, + let pullRequests = decodeJSON([WorkspacePullRequestRESTItem].self, from: response.data) else { #if DEBUG dlog( - "workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")" + "workspace.prRefresh.branch.fail repo=\(repoSlug) " + + "branch=\(branch) status=\(response.statusCode)" ) #endif return .transientFailure } - let checks = status == .open - ? pullRequestChecksStatus(number: pullRequest.number, directory: directory, repoSlug: repoSlug) - : nil + let matchingPullRequests = pullRequests + .map(Self.workspacePullRequestProbeItem) + .filter { normalizedBranchName($0.headRefName) == branch } + if let preferredPullRequest = preferredPullRequest(from: matchingPullRequests) { + return .found(preferredPullRequest) + } + return .notFound + } -#if DEBUG - dlog( - "workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " + - "repo=\(repoSlug) number=\(pullRequest.number) state=\(status.rawValue) checks=\(checks?.rawValue ?? "none")" + private nonisolated static func workspacePullRequestBranchEndpoint( + repoSlug: String, + branch: String + ) -> String? { + let components = repoSlug.split(separator: "/", maxSplits: 1).map(String.init) + guard components.count == 2, + !components[0].isEmpty, + !components[1].isEmpty else { + return nil + } + + var query = URLComponents() + query.queryItems = [ + URLQueryItem(name: "state", value: "all"), + URLQueryItem(name: "head", value: "\(components[0]):\(branch)"), + URLQueryItem(name: "sort", value: "updated"), + URLQueryItem(name: "direction", value: "desc"), + URLQueryItem(name: "per_page", value: String(Self.workspacePullRequestRepoPageSize)), + ] + guard let percentEncodedQuery = query.percentEncodedQuery else { + return nil + } + return "repos/\(repoSlug)/pulls?\(percentEncodedQuery)" + } + + private nonisolated static func workspacePullRequestProbeItem( + from pullRequest: WorkspacePullRequestRESTItem + ) -> GitHubPullRequestProbeItem { + let rawState = pullRequest.mergedAt?.isEmpty == false ? "MERGED" : pullRequest.state + return GitHubPullRequestProbeItem( + number: pullRequest.number, + state: rawState, + url: pullRequest.htmlURL, + updatedAt: pullRequest.updatedAt, + headRefName: pullRequest.head.ref ) -#endif - return .resolved( - SidebarPullRequestState( - number: pullRequest.number, - label: "PR", - url: url, - status: status, - branch: branch, - checks: checks + } + + private nonisolated static func performWorkspacePullRequestRequest( + session: URLSession, + endpoint: String, + authHeader: String? + ) async -> WorkspacePullRequestHTTPResponse? { + guard let url = URL(string: "https://api.github.com/\(endpoint)") else { + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("cmux-workspace-pr-poller", forHTTPHeaderField: "User-Agent") + if let authHeader, !authHeader.isEmpty { + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + } + + do { + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return nil + } + return WorkspacePullRequestHTTPResponse( + statusCode: httpResponse.statusCode, + data: data ) - ) + } catch { + return nil + } + } + + private nonisolated static func workspacePullRequestAuthHeaderValue() -> String? { + let environment = ProcessInfo.processInfo.environment + if let envToken = environment["GH_TOKEN"] ?? environment["GITHUB_TOKEN"] { + let trimmed = envToken.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return "Bearer \(trimmed)" + } + } + + let directory = FileManager.default.currentDirectoryPath + let token = runCommand( + directory: directory, + executable: "gh", + arguments: ["auth", "token"], + timeout: workspacePullRequestProbeTimeout + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !token.isEmpty else { return nil } + return "Bearer \(token)" + } + + private nonisolated static func pullRequestMapByNormalizedBranch( + from pullRequests: [GitHubPullRequestProbeItem] + ) -> [String: GitHubPullRequestProbeItem] { + var pullRequestsByBranch: [String: GitHubPullRequestProbeItem] = [:] + + for pullRequest in pullRequests { + guard let branch = normalizedBranchName(pullRequest.headRefName), + pullRequestStatus(from: pullRequest.state) != nil, + URL(string: pullRequest.url) != nil else { + continue + } + + if let currentBest = pullRequestsByBranch[branch] { + pullRequestsByBranch[branch] = preferredPullRequest(from: [currentBest, pullRequest]) ?? currentBest + } else { + pullRequestsByBranch[branch] = pullRequest + } + } + + return pullRequestsByBranch } nonisolated static func preferredPullRequest( @@ -2762,60 +2912,6 @@ class TabManager: ObservableObject { return best } - private nonisolated static func pullRequestChecksStatus( - number: Int, - directory: String, - repoSlug: String - ) -> SidebarPullRequestChecksStatus? { - let result = runCommandResult( - directory: directory, - executable: "gh", - arguments: [ - "pr", "checks", String(number), - "--repo", repoSlug, - "--json", "bucket,state" - ], - timeout: workspacePullRequestProbeTimeout - ) - - guard let result, - !result.timedOut, - result.executionError == nil, - let output = result.stdout, - let exitStatus = result.exitStatus, - exitStatus == 0 || exitStatus == 8, - let checks = decodeJSON([GitHubPullRequestCheckItem].self, from: output) else { - return nil - } - - var sawPending = false - var sawPass = false - - for check in checks { - let bucket = check.bucket?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let state = check.state?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - - if isFailingCheckState(bucket: bucket, state: state) { - return .fail - } - if isPendingCheckState(bucket: bucket, state: state) { - sawPending = true - continue - } - if isPassingCheckState(bucket: bucket, state: state) { - sawPass = true - } - } - - if sawPending { - return .pending - } - if sawPass { - return .pass - } - return nil - } - private nonisolated static func pullRequestStatus( from rawState: String ) -> SidebarPullRequestStatus? { @@ -2836,32 +2932,8 @@ class TabManager: ObservableObject { return try? JSONDecoder().decode(T.self, from: data) } - private nonisolated static func isFailingCheckState(bucket: String?, state: String?) -> Bool { - switch bucket ?? state ?? "" { - case "fail", "failure", "failed", "error", "timed_out", "timedout", - "cancel", "cancelled", "canceled", "action_required", "startup_failure": - return true - default: - return false - } - } - - private nonisolated static func isPendingCheckState(bucket: String?, state: String?) -> Bool { - switch bucket ?? state ?? "" { - case "pending", "queued", "in_progress", "requested", "waiting", "expected": - return true - default: - return false - } - } - - private nonisolated static func isPassingCheckState(bucket: String?, state: String?) -> Bool { - switch bucket ?? state ?? "" { - case "pass", "success", "successful", "completed", "neutral", "skipping", "skipped": - return true - default: - return false - } + private nonisolated static func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) -> T? { + try? JSONDecoder().decode(T.self, from: data) } private nonisolated static let fallbackCommandSearchDirectories: [String] = [ @@ -3530,8 +3602,7 @@ class TabManager: ObservableObject { scheduleWorkspacePullRequestRefresh( workspaceId: tabId, panelId: surfaceId, - reason: "directoryChange", - burst: true + reason: "directoryChange" ) scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: tabId, @@ -3559,8 +3630,7 @@ class TabManager: ObservableObject { scheduleWorkspacePullRequestRefresh( workspaceId: tabId, panelId: surfaceId, - reason: "branchChange", - burst: true + reason: "branchChange" ) scheduleWorkspaceGitMetadataRefreshIfPossible( workspaceId: tabId, @@ -3597,8 +3667,7 @@ class TabManager: ObservableObject { scheduleWorkspacePullRequestRefresh( workspaceId: tabId, panelId: surfaceId, - reason: "shellPrompt", - burst: false + reason: "shellPrompt" ) } } @@ -3619,8 +3688,7 @@ class TabManager: ObservableObject { scheduleWorkspacePullRequestRefresh( workspaceId: tabId, panelId: surfaceId, - reason: "commandHint:\(action)", - burst: true + reason: "commandHint:\(action)" ) } @@ -3660,7 +3728,7 @@ class TabManager: ObservableObject { url: currentPullRequest.url, status: nextStatus, branch: currentPullRequest.branch, - checks: nil + isStale: false ) } @@ -6721,12 +6789,13 @@ extension TabManager { for tab in previousTabs { unwireClosedBrowserTracking(for: tab) } - let existingProbeKeys = Set(workspaceGitProbeGenerationByKey.keys) + let existingProbeKeys = Set(workspaceGitProbeStateByKey.keys) .union(workspaceGitProbeTimersByKey.keys) for key in existingProbeKeys { clearWorkspaceGitProbe(key) } workspaceGitTrackedDirectoryByKey.removeAll() + resetWorkspacePullRequestRefreshState() // Clear non-@Published state without touching tabs/selectedTabId yet. lastFocusedPanelByTab.removeAll() diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 2bd106f8eed8..035e36b1dce9 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -389,8 +389,7 @@ class TerminalController { label: String, url: URL, status: SidebarPullRequestStatus, - branch: String?, - checks: SidebarPullRequestChecksStatus? + branch: String? ) -> Bool { guard let current else { return true } let normalizedBranch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -406,24 +405,12 @@ class TerminalController { } return current.branch }() - let effectiveChecks: SidebarPullRequestChecksStatus? = { - if let checks { - return checks - } - guard current.number == number, - current.label == label, - current.url == url, - current.status == status else { - return nil - } - return current.checks - }() return current.number != number || current.label != label || current.url != url || current.status != status || current.branch != effectiveBranch - || current.checks != effectiveChecks + || current.isStale } nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { @@ -11432,8 +11419,8 @@ class TerminalController { clear_progress [--tab=X] - Clear progress bar report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch clear_git_branch [--tab=X] [--panel=Y] - Clear git branch - report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--branch=<name>] [--checks=pass|fail|pending] [--tab=X] [--panel=Y] - Report pull request / review item - report_review <number> <url> [--label=MR] [--state=open|merged|closed] [--checks=pass|fail|pending] [--tab=X] [--panel=Y] - Alias for provider-specific review item + report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--branch=<name>] [--tab=X] [--panel=Y] - Report pull request / review item + report_review <number> <url> [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item clear_pr [--tab=X] [--panel=Y] - Clear pull request report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning @@ -15127,7 +15114,7 @@ class TerminalController { private func reportPullRequest(_ args: String) -> String { let parsed = parseOptions(args) guard parsed.positional.count >= 2 else { - return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--branch=<name>] [--checks=pass|fail|pending] [--tab=X] [--panel=Y]" + return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--branch=<name>] [--tab=X] [--panel=Y]" } let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines) @@ -15148,20 +15135,13 @@ class TerminalController { return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed" } let branch = normalizedOptionValue(parsed.options["branch"]) - - let checks: SidebarPullRequestChecksStatus? - if let rawChecks = normalizedOptionValue(parsed.options["checks"]) { - guard let parsedChecks = SidebarPullRequestChecksStatus(rawValue: rawChecks.lowercased()) else { - return "ERROR: Invalid pull request checks '\(rawChecks)' — use: pass, fail, pending" - } - checks = parsedChecks - } else { - checks = nil + if normalizedOptionValue(parsed.options["checks"]) != nil { + return "ERROR: Unsupported option '--checks' — pull request checks are no longer tracked" } let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR" guard !labelRaw.isEmpty else { - return "ERROR: Invalid review label — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--branch=<name>] [--checks=pass|fail|pending] [--tab=X] [--panel=Y]" + return "ERROR: Invalid review label — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--branch=<name>] [--tab=X] [--panel=Y]" } let label = String(labelRaw.prefix(16)) @@ -15170,7 +15150,7 @@ class TerminalController { return schedulePanelMetadataMutation( args: args, options: parsed.options, - missingPanelUsage: "report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--branch=<name>] [--checks=pass|fail|pending] [--tab=X] [--panel=Y]" + missingPanelUsage: "report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--branch=<name>] [--tab=X] [--panel=Y]" ) { tab, surfaceId in guard Self.shouldReplacePullRequest( current: tab.panelPullRequests[surfaceId], @@ -15178,8 +15158,7 @@ class TerminalController { label: label, url: url, status: status, - branch: branch, - checks: checks + branch: branch ) else { return } @@ -15190,8 +15169,7 @@ class TerminalController { label: label, url: url, status: status, - branch: branch, - checks: checks + branch: branch ) } } @@ -15618,11 +15596,9 @@ class TerminalController { if let pr = tab.sidebarPullRequestsInDisplayOrder().first { lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)") lines.append("pr_label=\(pr.label)") - lines.append("pr_checks=\(pr.checks?.rawValue ?? "none")") } else { lines.append("pr=none") lines.append("pr_label=none") - lines.append("pr_checks=none") } if tab.listeningPorts.isEmpty { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d65b8ead29d4..dac4b68fca8c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6039,12 +6039,6 @@ enum SidebarPullRequestStatus: String { case closed } -enum SidebarPullRequestChecksStatus: String { - case pass - case fail - case pending -} - private func normalizedSidebarBranchName(_ branch: String?) -> String? { guard let branch else { return nil } let trimmed = branch.trimmingCharacters(in: .whitespacesAndNewlines) @@ -6057,7 +6051,7 @@ struct SidebarPullRequestState: Equatable { let url: URL let status: SidebarPullRequestStatus let branch: String? - let checks: SidebarPullRequestChecksStatus? + let isStale: Bool init( number: Int, @@ -6065,14 +6059,14 @@ struct SidebarPullRequestState: Equatable { url: URL, status: SidebarPullRequestStatus, branch: String? = nil, - checks: SidebarPullRequestChecksStatus? = nil + isStale: Bool = false ) { self.number = number self.label = label self.url = url self.status = status self.branch = normalizedSidebarBranchName(branch) - self.checks = checks + self.isStale = isStale } } @@ -6317,13 +6311,8 @@ enum SidebarBranchOrdering { } } - func checksPriority(_ checks: SidebarPullRequestChecksStatus?) -> Int { - switch checks { - case .fail: return 3 - case .pending: return 2 - case .pass: return 1 - case nil: return 0 - } + func freshnessPriority(_ isStale: Bool) -> Int { + isStale ? 0 : 1 } func normalizedReviewURLKey(for url: URL) -> String { @@ -6360,10 +6349,10 @@ enum SidebarBranchOrdering { continue } guard let existing = pullRequestsByKey[key] else { continue } - if statusPriority(state.status) > statusPriority(existing.status) { + if freshnessPriority(state.isStale) > freshnessPriority(existing.isStale) { pullRequestsByKey[key] = state - } else if state.status == existing.status, - checksPriority(state.checks) > checksPriority(existing.checks) { + } else if freshnessPriority(state.isStale) == freshnessPriority(existing.isStale), + statusPriority(state.status) > statusPriority(existing.status) { pullRequestsByKey[key] = state } } @@ -7653,7 +7642,7 @@ final class Workspace: Identifiable, ObservableObject { url: URL, status: SidebarPullRequestStatus, branch: String? = nil, - checks: SidebarPullRequestChecksStatus? = nil + isStale: Bool = false ) { let existing = panelPullRequests[panelId] let normalizedBranch = normalizedSidebarBranchName(branch) @@ -7674,26 +7663,13 @@ final class Workspace: Identifiable, ObservableObject { } return existing.branch }() - let resolvedChecks: SidebarPullRequestChecksStatus? = { - if let checks { - return checks - } - guard let existing, - existing.number == number, - existing.label == label, - existing.url == url, - existing.status == status else { - return nil - } - return existing.checks - }() let state = SidebarPullRequestState( number: number, label: label, url: url, status: status, branch: resolvedBranch, - checks: resolvedChecks + isStale: isStale ) if existing != state { panelPullRequests[panelId] = state diff --git a/cmuxTests/SidebarOrderingTests.swift b/cmuxTests/SidebarOrderingTests.swift index f9a4aff5558e..da58fd0761da 100644 --- a/cmuxTests/SidebarOrderingTests.swift +++ b/cmuxTests/SidebarOrderingTests.swift @@ -388,7 +388,8 @@ final class SidebarBranchOrderingTests: XCTestCase { number: 42, label: "PR", url: "https://github.com/manaflow-ai/cmux/pull/42", - status: .open + status: .open, + isStale: true ), second: pullRequestState( number: 42, @@ -417,7 +418,8 @@ final class SidebarBranchOrderingTests: XCTestCase { number: 42, label: "PR", url: "https://github.com/manaflow-ai/cmux/pull/42", - status: .open + status: .open, + isStale: true ), second: pullRequestState( number: 42, @@ -438,7 +440,7 @@ final class SidebarBranchOrderingTests: XCTestCase { ) } - func testOrderedUniquePullRequestsPrefersEntryWithChecksWhenStatusesMatch() { + func testOrderedUniquePullRequestsPrefersFreshEntryWhenStatusesMatch() { let first = UUID() let second = UUID() @@ -449,25 +451,26 @@ final class SidebarBranchOrderingTests: XCTestCase { number: 42, label: "PR", url: "https://github.com/manaflow-ai/cmux/pull/42", - status: .open + status: .open, + isStale: true ), second: pullRequestState( number: 42, label: "PR", url: "https://github.com/manaflow-ai/cmux/pull/42", status: .open, - checks: .pass + isStale: false ) ], fallbackPullRequest: nil ) XCTAssertEqual(pullRequests.count, 1) - XCTAssertEqual(pullRequests.first?.checks, .pass) + XCTAssertEqual(pullRequests.first?.isStale, false) } @MainActor - func testUpdatePanelPullRequestPreservesExistingChecksWhenUpdateOmitsThem() { + func testUpdatePanelPullRequestClearsStaleFlagOnFreshUpdate() { let workspace = Workspace(title: "Tests", workingDirectory: FileManager.default.currentDirectoryPath, portOrdinal: 0) guard let panelId = workspace.focusedPanelId else { XCTFail("Expected focused panel for new workspace") @@ -480,7 +483,7 @@ final class SidebarBranchOrderingTests: XCTestCase { label: "PR", url: URL(string: "https://github.com/manaflow-ai/cmux/pull/42")!, status: .open, - checks: .pass + isStale: true ) workspace.updatePanelPullRequest( panelId: panelId, @@ -490,8 +493,8 @@ final class SidebarBranchOrderingTests: XCTestCase { status: .open ) - XCTAssertEqual(workspace.panelPullRequests[panelId]?.checks, .pass) - XCTAssertEqual(workspace.pullRequest?.checks, .pass) + XCTAssertEqual(workspace.panelPullRequests[panelId]?.isStale, false) + XCTAssertEqual(workspace.pullRequest?.isStale, false) } func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() { @@ -561,7 +564,7 @@ final class SidebarBranchOrderingTests: XCTestCase { url: String, status: SidebarPullRequestStatus, branch: String? = nil, - checks: SidebarPullRequestChecksStatus? = nil + isStale: Bool = false ) -> SidebarPullRequestState { SidebarPullRequestState( number: number, @@ -569,7 +572,7 @@ final class SidebarBranchOrderingTests: XCTestCase { url: URL(string: url)!, status: status, branch: branch, - checks: checks + isStale: isStale ) } } @@ -920,6 +923,29 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { ) } + func testShouldReplacePullRequestReturnsTrueWhenCurrentStateIsStale() throws { + let url = try XCTUnwrap(URL(string: "https://github.com/manaflow-ai/cmux/pull/42")) + let current = SidebarPullRequestState( + number: 42, + label: "PR", + url: url, + status: .open, + branch: "feature/work", + isStale: true + ) + + XCTAssertTrue( + TerminalController.shouldReplacePullRequest( + current: current, + number: 42, + label: "PR", + url: url, + status: .open, + branch: "feature/work" + ) + ) + } + func testExplicitSocketScopeParsesValidUUIDTabAndPanel() { let workspaceId = UUID() let panelId = UUID() diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index e3948cbfbbc6..27d36afa51e4 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -404,6 +404,42 @@ final class TabManagerPullRequestProbeTests: XCTestCase { XCTAssertFalse(TabManager.shouldSkipWorkspacePullRequestLookup(branch: "release/master-fix")) } + func testWorkspacePullRequestRefreshAllowsRepoCacheForTimerAndPeriodicReasons() { + XCTAssertTrue(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "periodicPoll")) + XCTAssertTrue(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "periodicPoll.followUp")) + XCTAssertTrue(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "selectedPeriodicPoll")) + XCTAssertTrue(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "selectedPeriodicPoll.followUp")) + XCTAssertTrue(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "timer")) + XCTAssertTrue(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "timer.followUp")) + + XCTAssertFalse(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "branchChange")) + XCTAssertFalse(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "branchChange.followUp")) + XCTAssertFalse(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "shellPrompt")) + XCTAssertFalse(TabManager.workspacePullRequestRefreshAllowsRepoCache(reason: "commandHint:merge")) + } + + func testWorkspacePullRequestShouldRefreshHonorsForcedRefreshForTerminalStates() { + let now = Date(timeIntervalSince1970: 1_000) + let recentTerminalRefresh = now.addingTimeInterval(-60) + + XCTAssertTrue( + TabManager.shouldRefreshWorkspacePullRequest( + now: now, + nextPollAt: .distantPast, + lastTerminalStateRefreshAt: recentTerminalRefresh, + currentPullRequestStatus: .merged + ) + ) + XCTAssertFalse( + TabManager.shouldRefreshWorkspacePullRequest( + now: now, + nextPollAt: now.addingTimeInterval(60), + lastTerminalStateRefreshAt: recentTerminalRefresh, + currentPullRequestStatus: .closed + ) + ) + } + func testTrackedWorkspaceGitMetadataPollCandidatesIncludeMainAndMasterPanels() throws { let manager = TabManager() guard let workspace = manager.selectedWorkspace, diff --git a/cmuxTests/WorkspacePullRequestSidebarTests.swift b/cmuxTests/WorkspacePullRequestSidebarTests.swift index e3cc26e1431a..44fe5ee2fc75 100644 --- a/cmuxTests/WorkspacePullRequestSidebarTests.swift +++ b/cmuxTests/WorkspacePullRequestSidebarTests.swift @@ -56,7 +56,7 @@ final class WorkspacePullRequestSidebarTests: XCTestCase { url: url, status: .open, branch: "feature/work", - checks: .pass + isStale: true ) workspace.panelPullRequests[secondPanelId] = SidebarPullRequestState( number: 1640, From 9412c7908cf23bad3d2f7150fa90545673aaff5e Mon Sep 17 00:00:00 2001 From: Austin Wang <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 02:21:22 -0700 Subject: [PATCH 37/89] Fix nightly codesign: sign nested plugins and frameworks before outer app (#2676) The CmuxDockTilePlugin.plugin in Contents/PlugIns was not being signed, causing `codesign --verify --deep --strict` to fail. Add a deepest-first signing sweep of all nested bundles (.plugin, .appex, .framework, .xpc) under Contents/PlugIns and Contents/Frameworks before signing the outer .app. Applied to both nightly.yml and release.yml. Fixes: https://github.com/manaflow-ai/cmux/actions/runs/24072474849 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> --- .github/workflows/nightly.yml | 8 ++++++++ .github/workflows/release.yml | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9d2e4602b3d1..21353bbf36f1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -406,6 +406,14 @@ jobs: if [ -f "$HELPER_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi + # Sign nested bundles (plugins, frameworks, appex, xpc) deepest-first before the outer app + for DIR in "$APP_PATH/Contents/PlugIns" "$APP_PATH/Contents/Frameworks"; do + if [ -d "$DIR" ]; then + while IFS= read -r -d '' bundle; do + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" + done < <(find "$DIR" -depth -type d \( -name '*.plugin' -o -name '*.appex' -o -name '*.framework' -o -name '*.xpc' \) -print0) + fi + done /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" ./scripts/assert-passkey-entitlement.sh "$APP_PATH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c23e3026e333..7fd4f378f6a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -267,6 +267,14 @@ jobs: if [ -f "$HELPER_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi + # Sign nested bundles (plugins, frameworks, appex, xpc) deepest-first before the outer app + for DIR in "$APP_PATH/Contents/PlugIns" "$APP_PATH/Contents/Frameworks"; do + if [ -d "$DIR" ]; then + while IFS= read -r -d '' bundle; do + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" + done < <(find "$DIR" -depth -type d \( -name '*.plugin' -o -name '*.appex' -o -name '*.framework' -o -name '*.xpc' \) -print0) + fi + done /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" ./scripts/assert-passkey-entitlement.sh "$APP_PATH" From 18d9fca64e3ab7af149a0b6d841a4e756f58380e Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 02:32:50 -0700 Subject: [PATCH 38/89] Fix codesign: three-pass signing for Sparkle nested executables The single-pass approach only signed bundle directories (.framework, .plugin) but not the standalone executables and nested apps inside them. Sparkle.framework contains Updater.app and Autoupdate which need to be individually signed before the framework itself. Three-pass signing order: 1) Nested .app/.xpc bundles inside frameworks (deepest-first) 2) Standalone Mach-O executables & dylibs (e.g. Sparkle's Autoupdate) 3) .framework/.plugin/.appex bundles (deepest-first) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .github/workflows/nightly.yml | 21 +++++++++++++++++++-- .github/workflows/release.yml | 21 +++++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 21353bbf36f1..1d96669e70f4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -406,12 +406,29 @@ jobs: if [ -f "$HELPER_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi - # Sign nested bundles (plugins, frameworks, appex, xpc) deepest-first before the outer app + # Sign all nested bundles and executables deepest-first before the outer app. + # Three passes to guarantee correct ordering: + # 1) Nested .app/.xpc bundles inside frameworks (e.g. Sparkle's Updater.app) + # 2) Standalone Mach-O executables inside frameworks (e.g. Sparkle's Autoupdate) + # 3) Top-level .framework/.plugin/.appex bundles themselves for DIR in "$APP_PATH/Contents/PlugIns" "$APP_PATH/Contents/Frameworks"; do if [ -d "$DIR" ]; then + # Pass 1: nested .app and .xpc bundles (deepest-first) while IFS= read -r -d '' bundle; do /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" - done < <(find "$DIR" -depth -type d \( -name '*.plugin' -o -name '*.appex' -o -name '*.framework' -o -name '*.xpc' \) -print0) + done < <(find "$DIR" -depth -type d \( -name '*.app' -o -name '*.xpc' \) -print0) + + # Pass 2: standalone Mach-O executables & dylibs (skip those inside .app bundles) + while IFS= read -r -d '' f; do + if file "$f" | grep -qE 'Mach-O'; then + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" + fi + done < <(find "$DIR" -type f \( -perm +111 -o -name '*.dylib' \) -not -path '*.app/*' -print0) + + # Pass 3: .framework, .plugin, .appex bundles (deepest-first) + while IFS= read -r -d '' bundle; do + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" + done < <(find "$DIR" -depth -type d \( -name '*.framework' -o -name '*.plugin' -o -name '*.appex' \) -print0) fi done /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fd4f378f6a0..863fd4ee28fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -267,12 +267,29 @@ jobs: if [ -f "$HELPER_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi - # Sign nested bundles (plugins, frameworks, appex, xpc) deepest-first before the outer app + # Sign all nested bundles and executables deepest-first before the outer app. + # Three passes to guarantee correct ordering: + # 1) Nested .app/.xpc bundles inside frameworks (e.g. Sparkle's Updater.app) + # 2) Standalone Mach-O executables inside frameworks (e.g. Sparkle's Autoupdate) + # 3) Top-level .framework/.plugin/.appex bundles themselves for DIR in "$APP_PATH/Contents/PlugIns" "$APP_PATH/Contents/Frameworks"; do if [ -d "$DIR" ]; then + # Pass 1: nested .app and .xpc bundles (deepest-first) while IFS= read -r -d '' bundle; do /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" - done < <(find "$DIR" -depth -type d \( -name '*.plugin' -o -name '*.appex' -o -name '*.framework' -o -name '*.xpc' \) -print0) + done < <(find "$DIR" -depth -type d \( -name '*.app' -o -name '*.xpc' \) -print0) + + # Pass 2: standalone Mach-O executables & dylibs (skip those inside .app bundles) + while IFS= read -r -d '' f; do + if file "$f" | grep -qE 'Mach-O'; then + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" + fi + done < <(find "$DIR" -type f \( -perm +111 -o -name '*.dylib' \) -not -path '*.app/*' -print0) + + # Pass 3: .framework, .plugin, .appex bundles (deepest-first) + while IFS= read -r -d '' bundle; do + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" + done < <(find "$DIR" -depth -type d \( -name '*.framework' -o -name '*.plugin' -o -name '*.appex' \) -print0) fi done /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" From c4a08fb222625ab347e58890e2aa2dbe441b7d46 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 02:35:48 -0700 Subject: [PATCH 39/89] Fix terminal scrollbar occlusion --- Sources/AppDelegate.swift | 2 +- Sources/GhosttyTerminalView.swift | 62 +++++++++++++++++++++++++ cmuxTests/TerminalAndGhosttyTests.swift | 35 ++++---------- 3 files changed, 73 insertions(+), 26 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 3fe3705dc836..eba3d12d8239 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5809,8 +5809,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { var refreshedCount = 0 forEachTerminalPanel { terminalPanel in - terminalPanel.hostedView.reconcileGeometryNow() terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() + terminalPanel.hostedView.reconcileGeometryNow() terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") refreshedCount += 1 } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 517be0a06ba7..0ae3be69b68d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1181,6 +1181,11 @@ private final class GhosttySurfaceCallbackContext { // MARK: - Ghostty App Singleton class GhosttyApp { + enum ScrollbarVisibility: String { + case system + case never + } + static let shared = GhosttyApp() private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { @@ -2468,6 +2473,17 @@ class GhosttyApp { return found && enabled } + func scrollbarVisibility() -> ScrollbarVisibility { + guard let config else { return .system } + var value: UnsafePointer<Int8>? + let key = "scrollbar" + guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), + let value else { + return .system + } + return ScrollbarVisibility(rawValue: String(cString: value)) ?? .system + } + func appleScriptAutomationEnabled() -> Bool { guard let config else { return false } var enabled = false @@ -8532,6 +8548,7 @@ final class GhosttySurfaceScrollView: NSView { private var searchOverlayMutationGeneration: UInt64 = 0 private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] + private var scrollbarTrackingArea: NSTrackingArea? private var isLiveScrolling = false private var lastSentRow: Int? /// Tracks whether the user has scrolled away from the bottom to review scrollback. @@ -8777,6 +8794,7 @@ final class GhosttySurfaceScrollView: NSView { backgroundView.layer?.isOpaque = initialTerminalBackground.alphaComponent >= 1.0 addSubview(backgroundView) addSubview(scrollView) + synchronizeScrollbarAppearance() inactiveOverlayView.wantsLayer = true inactiveOverlayView.layer?.backgroundColor = NSColor.clear.cgColor inactiveOverlayView.isHidden = true @@ -9072,6 +9090,37 @@ final class GhosttySurfaceScrollView: NSView { // Avoid stealing focus on scroll; focus is managed explicitly by the surface view. override var acceptsFirstResponder: Bool { false } + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + guard scrollView.hasVerticalScroller, + NSScroller.preferredScrollerStyle == .legacy else { return } + scrollView.flashScrollers() + } + + override func updateTrackingAreas() { + if let scrollbarTrackingArea { + removeTrackingArea(scrollbarTrackingArea) + self.scrollbarTrackingArea = nil + } + + super.updateTrackingAreas() + + guard scrollView.hasVerticalScroller, + let scroller = scrollView.verticalScroller else { return } + + let trackingArea = NSTrackingArea( + rect: convert(scroller.bounds, from: scroller), + options: [ + .mouseMoved, + .activeInKeyWindow, + ], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + scrollbarTrackingArea = trackingArea + } + override func layout() { super.layout() synchronizeGeometryAndContent() @@ -9769,6 +9818,7 @@ final class GhosttySurfaceScrollView: NSView { } func refreshHostBackgroundAfterGhosttyConfigReload() { + synchronizeScrollbarAppearance() surfaceView.applySurfaceBackground() surfaceView.applyWindowBackgroundIfActive() } @@ -11225,6 +11275,16 @@ final class GhosttySurfaceScrollView: NSView { synchronizeScrollView() } + private func synchronizeScrollbarAppearance() { + scrollView.hasVerticalScroller = GhosttyApp.shared.scrollbarVisibility() != .never + // Mirror upstream Ghostty: keep overlay scrollers even when the + // system preference is legacy so terminal content never sits beneath a + // permanently reserved scrollbar gutter. + scrollView.autohidesScrollers = false + scrollView.scrollerStyle = .overlay + updateTrackingAreas() + } + private func handlePreferredScrollerStyleChange() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in @@ -11233,6 +11293,8 @@ final class GhosttySurfaceScrollView: NSView { return } + synchronizeScrollbarAppearance() + // Retile just the scroll view so contentSize reflects the current // scrollbar mode without perturbing viewport origin or hosted view // geometry; the broader reconcile path caused visible content glitches. diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 0f6d80eb91ac..bc3836b7ff60 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -2209,7 +2209,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertTrue(state.isHidden) } - func testPreferredScrollerStyleChangeRecalculatesTerminalSurfaceWidth() { + func testPreferredScrollerStyleChangeRestoresOverlayScrollbarWidth() { let surface = TerminalSurface( tabId: UUID(), context: GHOSTTY_SURFACE_CONTEXT_SPLIT, @@ -2289,32 +2289,17 @@ final class GhosttySurfaceOverlayTests: XCTestCase { NotificationCenter.default.post(name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - XCTAssertEqual(scrollView.scrollerStyle, .legacy) - assertPendingSurfaceWidth( - legacyContentWidth, - "Preferred scroller style changes should recalculate the terminal grid width immediately" - ) - - scrollView.scrollerStyle = .overlay - scrollView.layoutSubtreeIfNeeded() - let overlayContentWidth = scrollView.contentSize.width - XCTAssertGreaterThan( - overlayContentWidth, - legacyContentWidth, - "Overlay scrollbars should restore the full terminal content width" - ) - assertPendingSurfaceWidth( - legacyContentWidth, - "Changing the scroll view style alone should leave the terminal grid stale until the scroller-style observer runs" - ) - - NotificationCenter.default.post(name: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - + let restoredContentWidth = scrollView.contentSize.width XCTAssertEqual(scrollView.scrollerStyle, .overlay) + XCTAssertEqual( + restoredContentWidth, + initialContentWidth, + accuracy: 0.5, + "Preferred scroller style changes should restore Ghostty's overlay scrollbar behavior so terminal content is not occluded by a persistent gutter" + ) assertPendingSurfaceWidth( - overlayContentWidth, - "Preferred scroller style changes should also restore the wider terminal grid when overlay scrollbars return" + restoredContentWidth, + "Preferred scroller style changes should restore the wider terminal grid when overlay scrollbars return" ) } From 1a2624a7a819d2fd673f003ad599a087f37e50b0 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 02:46:20 -0700 Subject: [PATCH 40/89] Fix codesign: drop -perm filter so Sparkle Autoupdate gets signed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The -perm +111 filter in Pass 2 was excluding Sparkle's Autoupdate binary (likely lacks execute bits in the built artifact). Remove the permission-based filter and check ALL regular files with `file` for Mach-O instead — this reliably catches every binary regardless of file permissions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1d96669e70f4..66f9771b01b7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -423,7 +423,7 @@ jobs: if file "$f" | grep -qE 'Mach-O'; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" fi - done < <(find "$DIR" -type f \( -perm +111 -o -name '*.dylib' \) -not -path '*.app/*' -print0) + done < <(find "$DIR" -type f -not -path '*.app/*' -print0) # Pass 3: .framework, .plugin, .appex bundles (deepest-first) while IFS= read -r -d '' bundle; do diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 863fd4ee28fa..ee610a46d46a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -284,7 +284,7 @@ jobs: if file "$f" | grep -qE 'Mach-O'; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" fi - done < <(find "$DIR" -type f \( -perm +111 -o -name '*.dylib' \) -not -path '*.app/*' -print0) + done < <(find "$DIR" -type f -not -path '*.app/*' -print0) # Pass 3: .framework, .plugin, .appex bundles (deepest-first) while IFS= read -r -d '' bundle; do From e2d5cff17ed9fa5d528195a711b24d0d1fb07f9c Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 02:56:51 -0700 Subject: [PATCH 41/89] Fix codesign: -path '*.app/*' excluded everything inside outer .app The find filter `-not -path '*.app/*'` matched the OUTER app bundle (cmux NIGHTLY.app/) in every file's full path, so Pass 2 found zero files and Autoupdate was never signed. Fix: use `-prune` on nested .app directories instead. This skips Updater.app's contents (already signed in Pass 1) while still finding standalone executables like Autoupdate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 66f9771b01b7..5bca097ee427 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -423,7 +423,7 @@ jobs: if file "$f" | grep -qE 'Mach-O'; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" fi - done < <(find "$DIR" -type f -not -path '*.app/*' -print0) + done < <(find "$DIR" \( -type d -name '*.app' -prune \) -o \( -type f -print0 \)) # Pass 3: .framework, .plugin, .appex bundles (deepest-first) while IFS= read -r -d '' bundle; do diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee610a46d46a..53987af51d2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -284,7 +284,7 @@ jobs: if file "$f" | grep -qE 'Mach-O'; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" fi - done < <(find "$DIR" -type f -not -path '*.app/*' -print0) + done < <(find "$DIR" \( -type d -name '*.app' -prune \) -o \( -type f -print0 \)) # Pass 3: .framework, .plugin, .appex bundles (deepest-first) while IFS= read -r -d '' bundle; do From c6ef4e75bf90900425a977a78b0b1afb329405df Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 03:40:06 -0700 Subject: [PATCH 42/89] Revert "Merge pull request #2680 from manaflow-ai/fix-codesign-path-filter" This reverts commit 2afa083d4ad4dfe339965ca0875f8557940ed702, reversing changes made to 2ff7478c1cbd9b9aae8380bbe9b02478f2dd1f82. --- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5bca097ee427..66f9771b01b7 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -423,7 +423,7 @@ jobs: if file "$f" | grep -qE 'Mach-O'; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" fi - done < <(find "$DIR" \( -type d -name '*.app' -prune \) -o \( -type f -print0 \)) + done < <(find "$DIR" -type f -not -path '*.app/*' -print0) # Pass 3: .framework, .plugin, .appex bundles (deepest-first) while IFS= read -r -d '' bundle; do diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53987af51d2b..ee610a46d46a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -284,7 +284,7 @@ jobs: if file "$f" | grep -qE 'Mach-O'; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" fi - done < <(find "$DIR" \( -type d -name '*.app' -prune \) -o \( -type f -print0 \)) + done < <(find "$DIR" -type f -not -path '*.app/*' -print0) # Pass 3: .framework, .plugin, .appex bundles (deepest-first) while IFS= read -r -d '' bundle; do From c3f5f04f35c685c66c9882de7fdb089058267d95 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 03:40:16 -0700 Subject: [PATCH 43/89] Revert "Merge pull request #2679 from manaflow-ai/fix-codesign-autoupdate" This reverts commit 2ff7478c1cbd9b9aae8380bbe9b02478f2dd1f82, reversing changes made to e88ff4346840b8c2acbc09ce22ff3352e33a1312. --- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 66f9771b01b7..1d96669e70f4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -423,7 +423,7 @@ jobs: if file "$f" | grep -qE 'Mach-O'; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" fi - done < <(find "$DIR" -type f -not -path '*.app/*' -print0) + done < <(find "$DIR" -type f \( -perm +111 -o -name '*.dylib' \) -not -path '*.app/*' -print0) # Pass 3: .framework, .plugin, .appex bundles (deepest-first) while IFS= read -r -d '' bundle; do diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee610a46d46a..863fd4ee28fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -284,7 +284,7 @@ jobs: if file "$f" | grep -qE 'Mach-O'; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" fi - done < <(find "$DIR" -type f -not -path '*.app/*' -print0) + done < <(find "$DIR" -type f \( -perm +111 -o -name '*.dylib' \) -not -path '*.app/*' -print0) # Pass 3: .framework, .plugin, .appex bundles (deepest-first) while IFS= read -r -d '' bundle; do From f441a39d6069b7ad1d9a8cc41b440ad514a8333f Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 03:40:25 -0700 Subject: [PATCH 44/89] Revert "Merge pull request #2677 from manaflow-ai/fix-nightly-codesign-dock-tile-plugin" This reverts commit e88ff4346840b8c2acbc09ce22ff3352e33a1312, reversing changes made to 9412c7908cf23bad3d2f7150fa90545673aaff5e. --- .github/workflows/nightly.yml | 21 ++------------------- .github/workflows/release.yml | 21 ++------------------- 2 files changed, 4 insertions(+), 38 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1d96669e70f4..21353bbf36f1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -406,29 +406,12 @@ jobs: if [ -f "$HELPER_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi - # Sign all nested bundles and executables deepest-first before the outer app. - # Three passes to guarantee correct ordering: - # 1) Nested .app/.xpc bundles inside frameworks (e.g. Sparkle's Updater.app) - # 2) Standalone Mach-O executables inside frameworks (e.g. Sparkle's Autoupdate) - # 3) Top-level .framework/.plugin/.appex bundles themselves + # Sign nested bundles (plugins, frameworks, appex, xpc) deepest-first before the outer app for DIR in "$APP_PATH/Contents/PlugIns" "$APP_PATH/Contents/Frameworks"; do if [ -d "$DIR" ]; then - # Pass 1: nested .app and .xpc bundles (deepest-first) while IFS= read -r -d '' bundle; do /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" - done < <(find "$DIR" -depth -type d \( -name '*.app' -o -name '*.xpc' \) -print0) - - # Pass 2: standalone Mach-O executables & dylibs (skip those inside .app bundles) - while IFS= read -r -d '' f; do - if file "$f" | grep -qE 'Mach-O'; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" - fi - done < <(find "$DIR" -type f \( -perm +111 -o -name '*.dylib' \) -not -path '*.app/*' -print0) - - # Pass 3: .framework, .plugin, .appex bundles (deepest-first) - while IFS= read -r -d '' bundle; do - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" - done < <(find "$DIR" -depth -type d \( -name '*.framework' -o -name '*.plugin' -o -name '*.appex' \) -print0) + done < <(find "$DIR" -depth -type d \( -name '*.plugin' -o -name '*.appex' -o -name '*.framework' -o -name '*.xpc' \) -print0) fi done /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 863fd4ee28fa..7fd4f378f6a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -267,29 +267,12 @@ jobs: if [ -f "$HELPER_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi - # Sign all nested bundles and executables deepest-first before the outer app. - # Three passes to guarantee correct ordering: - # 1) Nested .app/.xpc bundles inside frameworks (e.g. Sparkle's Updater.app) - # 2) Standalone Mach-O executables inside frameworks (e.g. Sparkle's Autoupdate) - # 3) Top-level .framework/.plugin/.appex bundles themselves + # Sign nested bundles (plugins, frameworks, appex, xpc) deepest-first before the outer app for DIR in "$APP_PATH/Contents/PlugIns" "$APP_PATH/Contents/Frameworks"; do if [ -d "$DIR" ]; then - # Pass 1: nested .app and .xpc bundles (deepest-first) while IFS= read -r -d '' bundle; do /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" - done < <(find "$DIR" -depth -type d \( -name '*.app' -o -name '*.xpc' \) -print0) - - # Pass 2: standalone Mach-O executables & dylibs (skip those inside .app bundles) - while IFS= read -r -d '' f; do - if file "$f" | grep -qE 'Mach-O'; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$f" - fi - done < <(find "$DIR" -type f \( -perm +111 -o -name '*.dylib' \) -not -path '*.app/*' -print0) - - # Pass 3: .framework, .plugin, .appex bundles (deepest-first) - while IFS= read -r -d '' bundle; do - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" - done < <(find "$DIR" -depth -type d \( -name '*.framework' -o -name '*.plugin' -o -name '*.appex' \) -print0) + done < <(find "$DIR" -depth -type d \( -name '*.plugin' -o -name '*.appex' -o -name '*.framework' -o -name '*.xpc' \) -print0) fi done /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" From 2e0f9d4341a2d146ac7a64883bfecfbc00b575f7 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 03:40:34 -0700 Subject: [PATCH 45/89] Revert "Fix nightly codesign: sign nested plugins and frameworks before outer app (#2676)" This reverts commit 9412c7908cf23bad3d2f7150fa90545673aaff5e. --- .github/workflows/nightly.yml | 8 -------- .github/workflows/release.yml | 8 -------- 2 files changed, 16 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 21353bbf36f1..9d2e4602b3d1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -406,14 +406,6 @@ jobs: if [ -f "$HELPER_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi - # Sign nested bundles (plugins, frameworks, appex, xpc) deepest-first before the outer app - for DIR in "$APP_PATH/Contents/PlugIns" "$APP_PATH/Contents/Frameworks"; do - if [ -d "$DIR" ]; then - while IFS= read -r -d '' bundle; do - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" - done < <(find "$DIR" -depth -type d \( -name '*.plugin' -o -name '*.appex' -o -name '*.framework' -o -name '*.xpc' \) -print0) - fi - done /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" ./scripts/assert-passkey-entitlement.sh "$APP_PATH" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fd4f378f6a0..c23e3026e333 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -267,14 +267,6 @@ jobs: if [ -f "$HELPER_PATH" ]; then /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" fi - # Sign nested bundles (plugins, frameworks, appex, xpc) deepest-first before the outer app - for DIR in "$APP_PATH/Contents/PlugIns" "$APP_PATH/Contents/Frameworks"; do - if [ -d "$DIR" ]; then - while IFS= read -r -d '' bundle; do - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$bundle" - done < <(find "$DIR" -depth -type d \( -name '*.plugin' -o -name '*.appex' -o -name '*.framework' -o -name '*.xpc' \) -print0) - fi - done /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" ./scripts/assert-passkey-entitlement.sh "$APP_PATH" From b90c41f5ead70d37d55ee0165ff1ff2683000a20 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 7 Apr 2026 03:40:44 -0700 Subject: [PATCH 46/89] Revert "Add passkey, WebAuthn, and FIDO2 support to browser pane (#2660)" This reverts commit f550206f2bd0935f5d26a739893601324f3567d1. --- .github/workflows/nightly.yml | 10 +- .github/workflows/release.yml | 10 +- Resources/Info.plist | 2 - Resources/InfoPlist.xcstrings | 113 ------------ Sources/Panels/BrowserPanel.swift | 166 ------------------ .../Panels/BrowserPopupWindowController.swift | 5 - cmux.embedded.entitlements | 18 -- cmux.entitlements | 2 - cmuxTests/BrowserConfigTests.swift | 138 --------------- scripts/assert-passkey-entitlement.sh | 41 ----- scripts/build-sign-upload.sh | 11 +- scripts/reload.sh | 28 +-- 12 files changed, 13 insertions(+), 531 deletions(-) delete mode 100644 cmux.embedded.entitlements delete mode 100755 scripts/assert-passkey-entitlement.sh diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9d2e4602b3d1..0e3a892c40bf 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -393,22 +393,20 @@ jobs: echo "Missing APPLE_SIGNING_IDENTITY secret" >&2 exit 1 fi - APP_ENTITLEMENTS="cmux.entitlements" - EMBEDDED_ENTITLEMENTS="cmux.embedded.entitlements" + ENTITLEMENTS="cmux.entitlements" for APP_PATH in \ "build-universal/Build/Products/Release/cmux NIGHTLY.app" do CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" if [ -f "$CLI_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$CLI_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH" fi if [ -f "$HELPER_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" fi - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" - ./scripts/assert-passkey-entitlement.sh "$APP_PATH" done - name: Notarize apps and dmgs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c23e3026e333..02203d76011e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -257,19 +257,17 @@ jobs: exit 1 fi APP_PATH="build-universal/Build/Products/Release/cmux.app" - APP_ENTITLEMENTS="cmux.entitlements" - EMBEDDED_ENTITLEMENTS="cmux.embedded.entitlements" + ENTITLEMENTS="cmux.entitlements" CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" if [ -f "$CLI_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$CLI_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH" fi if [ -f "$HELPER_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" fi - /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" - ./scripts/assert-passkey-entitlement.sh "$APP_PATH" - name: Notarize app if: steps.guard_release_assets.outputs.skip_all != 'true' diff --git a/Resources/Info.plist b/Resources/Info.plist index 3b99f6ed615e..f41ba036881d 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -47,8 +47,6 @@ <string>A program running within cmux would like to use your microphone.</string> <key>NSCameraUsageDescription</key> <string>A program running within cmux would like to use your camera.</string> - <key>NSBluetoothAlwaysUsageDescription</key> - <string>cmux uses Bluetooth for cross-device passkey sign-in in embedded browser authentication flows.</string> <key>CFBundleURLTypes</key> <array> <dict> diff --git a/Resources/InfoPlist.xcstrings b/Resources/InfoPlist.xcstrings index cb7e83d383e0..baeee7089db6 100644 --- a/Resources/InfoPlist.xcstrings +++ b/Resources/InfoPlist.xcstrings @@ -2,119 +2,6 @@ "sourceLanguage": "en", "version": "1.0", "strings": { - "NSBluetoothAlwaysUsageDescription": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "cmux uses Bluetooth for cross-device passkey sign-in in embedded browser authentication flows." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux は内蔵ブラウザの認証フローでクロスデバイスのパスキーサインインに Bluetooth を使用します。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "cmux 在嵌入式浏览器身份验证流程中使用蓝牙进行跨设备通行密钥登录。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "cmux 在內嵌瀏覽器身份驗證流程中使用藍牙進行跨裝置密碼金鑰登入。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "cmux는 내장 브라우저 인증 흐름에서 기기 간 패스키 로그인에 Bluetooth를 사용합니다." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "cmux verwendet Bluetooth für die geräteübergreifende Passkey-Anmeldung in den Authentifizierungsabläufen des integrierten Browsers." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "cmux usa Bluetooth para el inicio de sesión con claves de acceso entre dispositivos en los flujos de autenticación del navegador integrado." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "cmux utilise Bluetooth pour la connexion par clé d'accès entre appareils dans les flux d'authentification du navigateur intégré." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "cmux usa il Bluetooth per l'accesso con passkey tra dispositivi nei flussi di autenticazione del browser integrato." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "cmux bruger Bluetooth til adgangsnøgle-login på tværs af enheder i den integrerede browsers godkendelsesforløb." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "cmux używa Bluetooth do logowania za pomocą kluczy dostępu między urządzeniami w procesach uwierzytelniania wbudowanej przeglądarki." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "cmux использует Bluetooth для входа с помощью ключей доступа между устройствами в потоках аутентификации встроенного браузера." - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "cmux koristi Bluetooth za prijavu pomoću pristupnog ključa između uređaja u tokovima autentifikacije ugrađenog preglednika." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "يستخدم cmux البلوتوث لتسجيل الدخول باستخدام مفاتيح المرور عبر الأجهزة في تدفقات المصادقة بالمتصفح المدمج." - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "cmux bruker Bluetooth for tilgangsnøkkel-pålogging på tvers av enheter i den innebygde nettleserens autentiseringsflyter." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "O cmux usa Bluetooth para login com chaves de acesso entre dispositivos nos fluxos de autenticação do navegador integrado." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "cmux ใช้บลูทูธสำหรับการลงชื่อเข้าใช้ด้วย passkey ข้ามอุปกรณ์ในขั้นตอนการตรวจสอบสิทธิ์ของเบราว์เซอร์ในตัว" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "cmux, gömülü tarayıcının kimlik doğrulama akışlarında cihazlar arası geçiş anahtarı oturum açma için Bluetooth kullanır." - } - } - } - }, "NSCameraUsageDescription": { "extractionState": "manual", "localizations": { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index e238ef353ea4..64ec6cad492e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1,6 +1,5 @@ import Foundation import Combine -import AuthenticationServices import WebKit import AppKit import Bonsplit @@ -1729,164 +1728,6 @@ final class BrowserPortalAnchorView: NSView { } } -// WKWebView handles WebAuthn itself for browser apps; this support code only -// determines when cmux should request browser passkey access from macOS. -enum BrowserPasskeyAuthorizationSupport { - static let remoteLoopbackAliasHost = "cmux-loopback.localtest.me" - - static func isPotentiallyTrustworthy(url: URL?) -> Bool { - guard let url, - let scheme = url.scheme?.lowercased() else { - return false - } - if scheme == "https" { - return true - } - guard scheme == "http", - let host = url.host else { - return false - } - return isLoopbackHost(host) - } - - static func shouldRequestAuthorization( - for url: URL?, - authorizationState: ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, - hasPendingRequest: Bool, - didPromptThisSession: Bool - ) -> Bool { - guard isPotentiallyTrustworthy(url: url) else { return false } - guard authorizationState == .notDetermined else { return false } - guard !hasPendingRequest else { return false } - guard !didPromptThisSession else { return false } - return true - } - - /// Returns a host/origin string suitable for debug logs without leaking - /// query parameters, paths, OIDC state, or auth codes. - static func redactedURLDescription(for url: URL?) -> String { - guard let url else { return "nil" } - let scheme = url.scheme?.lowercased() ?? "?" - let host = url.host ?? "?" - return "\(scheme)://\(host)" - } - - private static func isLoopbackHost(_ host: String) -> Bool { - let normalized = normalizedHost(host) - let octets = normalized.split(separator: ".", omittingEmptySubsequences: false) - let is127Loopback = octets.count == 4 - && octets[0] == "127" - && octets.allSatisfy { component in - guard let value = Int(component) else { return false } - return (0...255).contains(value) - } - - return normalized == "localhost" - || normalized.hasSuffix(".localhost") - || normalized == "::1" - || is127Loopback - || normalized == remoteLoopbackAliasHost - || normalized.hasSuffix("." + remoteLoopbackAliasHost) - } - - private static func normalizedHost(_ host: String) -> String { - var normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if normalized.hasPrefix("[") && normalized.hasSuffix("]") { - normalized.removeFirst() - normalized.removeLast() - } - if normalized.hasSuffix(".") { - normalized.removeLast() - } - return normalized - } -} - -private extension ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState { - var cmuxDebugName: String { - switch self { - case .authorized: - return "authorized" - case .denied: - return "denied" - case .notDetermined: - return "notDetermined" - @unknown default: - return "unknown" - } - } -} - -@MainActor -final class BrowserPasskeyAuthorizationManager { - static let shared = BrowserPasskeyAuthorizationManager() - - private let credentialManager = ASAuthorizationWebBrowserPublicKeyCredentialManager() - private var authorizationTask: Task<ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, Never>? - private var didPromptThisSession = false - - private init() {} - - func requestAuthorizationIfNeeded(for url: URL?, source: String) { - // Skip the system passkey authorization prompt when running under - // automated UI tests; the dialog blocks the main thread and breaks - // unrelated keyboard regressions on CI. - if SessionRestorePolicy.isRunningUnderAutomatedTests() { - return - } - - let state = credentialManager.authorizationStateForPlatformCredentials - let redactedURL = BrowserPasskeyAuthorizationSupport.redactedURLDescription(for: url) - guard BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( - for: url, - authorizationState: state, - hasPendingRequest: authorizationTask != nil, - didPromptThisSession: didPromptThisSession - ) else { -#if DEBUG - dlog( - "browser.passkey.authorization.skip " + - "source=\(source) origin=\(redactedURL) state=\(state.cmuxDebugName) " + - "pending=\(authorizationTask == nil ? 0 : 1) prompted=\(didPromptThisSession ? 1 : 0)" - ) -#endif - return - } - - didPromptThisSession = true -#if DEBUG - dlog( - "browser.passkey.authorization.begin " + - "source=\(source) origin=\(redactedURL) state=\(state.cmuxDebugName)" - ) -#endif - - let task = Task<ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, Never> { [credentialManager] in - await withCheckedContinuation { continuation in - credentialManager.requestAuthorizationForPublicKeyCredentials { authorizationState in - continuation.resume(returning: authorizationState) - } - } - } - authorizationTask = task - - Task { @MainActor [weak self] in - guard let self else { return } - let result = await task.value - self.authorizationTask = nil - if result == .notDetermined { - self.didPromptThisSession = false - } -#if DEBUG - dlog( - "browser.passkey.authorization.end " + - "source=\(source) origin=\(redactedURL) result=\(result.cmuxDebugName)" - ) -#endif - } - } -} - @MainActor final class BrowserPanel: Panel, ObservableObject { private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" @@ -6348,13 +6189,6 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } - if navigationAction.targetFrame?.isMainFrame != false { - BrowserPasskeyAuthorizationManager.shared.requestAuthorizationIfNeeded( - for: navigationAction.request.url, - source: "browser.navigation.mainFrame" - ) - } - #if DEBUG let targetURL = navigationAction.request.url?.absoluteString ?? "nil" dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 4571818b6e5c..623a9bc997d5 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -577,11 +577,6 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { return } - BrowserPasskeyAuthorizationManager.shared.requestAuthorizationIfNeeded( - for: url, - source: "browser.popup.navigation.mainFrame" - ) - decisionHandler(.allow) } diff --git a/cmux.embedded.entitlements b/cmux.embedded.entitlements deleted file mode 100644 index 09e191a56f65..000000000000 --- a/cmux.embedded.entitlements +++ /dev/null @@ -1,18 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>com.apple.security.cs.disable-library-validation</key> - <true/> - <key>com.apple.security.cs.allow-unsigned-executable-memory</key> - <true/> - <key>com.apple.security.cs.allow-jit</key> - <true/> - <key>com.apple.security.device.camera</key> - <true/> - <key>com.apple.security.device.audio-input</key> - <true/> - <key>com.apple.security.automation.apple-events</key> - <true/> -</dict> -</plist> diff --git a/cmux.entitlements b/cmux.entitlements index 2544d749d59e..09e191a56f65 100644 --- a/cmux.entitlements +++ b/cmux.entitlements @@ -8,8 +8,6 @@ <true/> <key>com.apple.security.cs.allow-jit</key> <true/> - <key>com.apple.developer.web-browser.public-key-credential</key> - <true/> <key>com.apple.security.device.camera</key> <true/> <key>com.apple.security.device.audio-input</key> diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index a5d5fb7349d5..7ee49c5733a6 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -1,7 +1,6 @@ import XCTest import Combine import AppKit -import AuthenticationServices import SwiftUI import UniformTypeIdentifiers import WebKit @@ -1225,143 +1224,6 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { } } -final class BrowserPasskeyAuthorizationSupportTests: XCTestCase { - func testPotentiallyTrustworthyWebAuthnURLAllowsHTTPSAndLoopbackHTTP() { - XCTAssertTrue( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "https://github.com/login") - ) - ) - XCTAssertTrue( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "http://localhost:3000/login") - ) - ) - XCTAssertTrue( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "http://auth.localhost/challenge") - ) - ) - XCTAssertTrue( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "http://127.0.0.1:8080") - ) - ) - XCTAssertTrue( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "http://127.42.99.1/login") - ) - ) - XCTAssertTrue( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "http://[::1]:8443") - ) - ) - // BrowserPanel.remoteProxyLoopbackAliasURL rewrites local pages on - // remote workspaces to this host, so it must be treated as loopback. - XCTAssertTrue( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "http://cmux-loopback.localtest.me/login") - ) - ) - XCTAssertTrue( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "http://app.cmux-loopback.localtest.me/login") - ) - ) - } - - func testPotentiallyTrustworthyWebAuthnURLRejectsUnsupportedOrigins() { - XCTAssertFalse( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "http://example.com/login") - ) - ) - XCTAssertFalse( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "http://0.0.0.0:3000") - ) - ) - XCTAssertFalse( - BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy( - url: URL(string: "file:///tmp/login.html") - ) - ) - XCTAssertFalse(BrowserPasskeyAuthorizationSupport.isPotentiallyTrustworthy(url: nil)) - } - - func testShouldRequestAuthorizationRequiresTrustworthyPendingFreeNotDeterminedState() { - XCTAssertTrue( - BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( - for: URL(string: "https://accounts.google.com"), - authorizationState: .notDetermined, - hasPendingRequest: false, - didPromptThisSession: false - ) - ) - - XCTAssertFalse( - BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( - for: URL(string: "https://accounts.google.com"), - authorizationState: .authorized, - hasPendingRequest: false, - didPromptThisSession: false - ) - ) - XCTAssertFalse( - BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( - for: URL(string: "https://accounts.google.com"), - authorizationState: .denied, - hasPendingRequest: false, - didPromptThisSession: false - ) - ) - XCTAssertFalse( - BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( - for: URL(string: "https://accounts.google.com"), - authorizationState: .notDetermined, - hasPendingRequest: true, - didPromptThisSession: false - ) - ) - XCTAssertFalse( - BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( - for: URL(string: "https://accounts.google.com"), - authorizationState: .notDetermined, - hasPendingRequest: false, - didPromptThisSession: true - ) - ) - XCTAssertFalse( - BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( - for: URL(string: "http://example.com"), - authorizationState: .notDetermined, - hasPendingRequest: false, - didPromptThisSession: false - ) - ) - } - - func testRedactedURLDescriptionStripsPathAndQuery() { - XCTAssertEqual( - BrowserPasskeyAuthorizationSupport.redactedURLDescription( - for: URL(string: "https://login.example.com/oauth?code=secret&state=token") - ), - "https://login.example.com" - ) - XCTAssertEqual( - BrowserPasskeyAuthorizationSupport.redactedURLDescription( - for: URL(string: "http://localhost:3000/path?token=abc") - ), - "http://localhost" - ) - XCTAssertEqual( - BrowserPasskeyAuthorizationSupport.redactedURLDescription(for: nil), - "nil" - ) - } -} - @MainActor final class BrowserDeveloperToolsConfigurationTests: XCTestCase { diff --git a/scripts/assert-passkey-entitlement.sh b/scripts/assert-passkey-entitlement.sh deleted file mode 100755 index 1d480c350300..000000000000 --- a/scripts/assert-passkey-entitlement.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if [ "$#" -ne 1 ]; then - echo "usage: $0 /path/to/app.app" >&2 - exit 64 -fi - -APP_PATH="$1" -EXPECTED_KEY="com.apple.developer.web-browser.public-key-credential" - -if [ ! -d "$APP_PATH" ]; then - echo "App bundle not found: $APP_PATH" >&2 - exit 66 -fi - -RAW_OUTPUT="$(mktemp)" -PLIST_OUTPUT="$(mktemp)" -cleanup() { - rm -f "$RAW_OUTPUT" "$PLIST_OUTPUT" -} -trap cleanup EXIT - -/usr/bin/codesign -d --entitlements :- "$APP_PATH" >"$RAW_OUTPUT" 2>&1 -awk 'found { print } /^<\?xml/ { found = 1; print }' "$RAW_OUTPUT" >"$PLIST_OUTPUT" - -if [ ! -s "$PLIST_OUTPUT" ]; then - echo "Could not extract entitlements from signed app: $APP_PATH" >&2 - cat "$RAW_OUTPUT" >&2 - exit 1 -fi - -VALUE="$(/usr/libexec/PlistBuddy -c "Print :$EXPECTED_KEY" "$PLIST_OUTPUT" 2>/dev/null || true)" -if [ "$VALUE" != "true" ]; then - echo "Missing expected entitlement '$EXPECTED_KEY' on: $APP_PATH" >&2 - cat "$PLIST_OUTPUT" >&2 - exit 1 -fi - -echo "Verified entitlement '$EXPECTED_KEY' on: $APP_PATH" diff --git a/scripts/build-sign-upload.sh b/scripts/build-sign-upload.sh index f0e6e5812f56..86ec511ea099 100755 --- a/scripts/build-sign-upload.sh +++ b/scripts/build-sign-upload.sh @@ -47,8 +47,7 @@ fi TAG="$1" SIGN_HASH="A050CC7E193C8221BDBA204E731B046CDCCC1B30" -APP_ENTITLEMENTS="cmux.entitlements" -EMBEDDED_ENTITLEMENTS="cmux.embedded.entitlements" +ENTITLEMENTS="cmux.entitlements" APP_PATH="build/Build/Products/Release/cmux.app" # --- Pre-flight --- @@ -94,16 +93,14 @@ echo "Sparkle keys injected" # --- Codesign --- echo "Codesigning..." CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" -echo "Applying app entitlements from $APP_ENTITLEMENTS" if [ -f "$CLI_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$EMBEDDED_ENTITLEMENTS" "$CLI_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$CLI_PATH" fi if [ -f "$HELPER_PATH" ]; then - /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$EMBEDDED_ENTITLEMENTS" "$HELPER_PATH" + /usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$HELPER_PATH" fi -/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$APP_ENTITLEMENTS" "$APP_PATH" +/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" -./scripts/assert-passkey-entitlement.sh "$APP_PATH" echo "Codesign verified" # --- Notarize app --- diff --git a/scripts/reload.sh b/scripts/reload.sh index fdc9921ee7f6..136c676b1208 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -36,20 +36,6 @@ should_skip_ghostty_cli_helper_zig_build() { return 1 } -find_codesigning_identity_sha() { - local label_prefix="$1" - security find-identity -v -p codesigning 2>/dev/null \ - | awk -v prefix="$label_prefix" 'index($0, "\"" prefix) { print $2; exit }' -} - -find_apple_development_identity_sha() { - find_codesigning_identity_sha "Apple Development:" -} - -find_developer_id_application_identity_sha() { - find_codesigning_identity_sha "Developer ID Application:" -} - write_dev_cli_shim() { local target="$1" local fallback_bin="$2" @@ -444,8 +430,7 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then rm -f "$CMUX_SOCKET" fi fi - APPLE_DEVELOPMENT_IDENTITY_SHA="$(find_apple_development_identity_sha || true)" - DEVELOPER_ID_IDENTITY_SHA="$(find_developer_id_application_identity_sha || true)" + /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement-der "$TAG_APP_PATH" >/dev/null 2>&1 || true fi APP_PATH="$TAG_APP_PATH" fi @@ -490,17 +475,6 @@ if [[ -x "$GHOSTTY_HELPER_SRC" ]]; then cp "$GHOSTTY_HELPER_SRC" "$BIN_DIR/ghostty" chmod +x "$BIN_DIR/ghostty" fi -if [[ -n "$TAG" && -d "$APP_PATH" ]]; then - if [[ -n "${APPLE_DEVELOPMENT_IDENTITY_SHA:-}" ]]; then - /usr/bin/codesign --force --sign "$APPLE_DEVELOPMENT_IDENTITY_SHA" --timestamp=none --generate-entitlement-der --entitlements "$PWD/cmux.entitlements" "$APP_PATH" - elif [[ -n "${DEVELOPER_ID_IDENTITY_SHA:-}" ]]; then - echo "Only Developer ID signing identities were found. Unnotarized Developer ID-tagged builds fail local launch assessment, so this build stays ad-hoc and will not have passkey entitlements. Install an Apple Development identity to test passkeys locally." - /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement-der "$APP_PATH" - else - echo "No Apple Development or Developer ID signing identity found; local tagged app will launch without passkey entitlements." - /usr/bin/codesign --force --sign - --timestamp=none --generate-entitlement-der "$APP_PATH" - fi -fi CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" if [[ -x "$CLI_PATH" ]]; then echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true From 9692022c6be0391ae4956a29ea72c0b0e852875c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:15:30 -0700 Subject: [PATCH 47/89] Update ghostty submodule to upstream main (112 commits past v1.3.1) Merges ghostty-org/ghostty main into manaflow-ai/ghostty. See https://github.com/manaflow-ai/ghostty/pull/29 for details. --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index ae3cc5d298d6..fec0e6bc0398 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit ae3cc5d298d6a913297fc4dc7cd8b08283e1fa01 +Subproject commit fec0e6bc039887bc9bbb2c7817ec339e4fc49bad From 0aedcfdde2e659abd22db1991fc020c465d61c64 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:16:31 -0700 Subject: [PATCH 48/89] Update ghostty submodule to merged upstream (ghostty PR #30) Points to cc6f4c287 which includes 112 upstream commits past v1.3.1 plus cmux selectCursorCell/clearSelection APIs. --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index fec0e6bc0398..cc6f4c287983 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit fec0e6bc039887bc9bbb2c7817ec339e4fc49bad +Subproject commit cc6f4c287983325a3d87ec99f7d201eab53487da From 044ff84d73d558c61e2396a7ae99af339f7ef16a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:25:04 -0700 Subject: [PATCH 49/89] Add GhosttyKit checksum for cc6f4c28 (upstream merge) --- scripts/ghosttykit-checksums.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index b438fa7f4f6e..569f2b8577e9 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -14,3 +14,4 @@ f9030b5c5232db69ba8625bb53d51ce735b80d51 6c439d731d97bd35a3289f54478af3ac01e30ba 9549a4c06ca3e51a7b951ae91eb8a423032527bf c1c0f404503c3ca1fe39b0c6aa254e0ebf20b7626b1993ead087c8ce84e43ce9 ae3cc5d298d6a913297fc4dc7cd8b08283e1fa01 bf7dc0ad0b4afe70113e17f6cd14647e47873fe975402dbd044d3161febaa0da 9fa3ab01bb67d5cec4daa358e25509a271af8171 0e054087a894280bd3cee5f11a9e30527534901d65b09dfe1dcc665b00798c5a +cc6f4c287983325a3d87ec99f7d201eab53487da f4f8a6d3cc13b184d3834cf42a231e79116e4a8cc2f9911aa22e2906b5c23447 From 2b3d1c17cc3a22497fc2f4185890b36bc8daf165 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:39:02 -0700 Subject: [PATCH 50/89] Update ghostty submodule with xcframework tag fix Fixes CI panic: "tagged releases must be in vX.Y.Z format matching build.zig" caused by xcframework-* tags created by build-ghosttykit CI job. --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index cc6f4c287983..9fa02f69abc9 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit cc6f4c287983325a3d87ec99f7d201eab53487da +Subproject commit 9fa02f69abc945e480eed50c1eb77fe18514456e From cc9dee52e4eab2ffe15302b3bdec6a68c99da0e7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:45:02 -0700 Subject: [PATCH 51/89] Add GhosttyKit checksum for 9fa02f69 (xcframework tag fix) --- scripts/ghosttykit-checksums.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 569f2b8577e9..89f0eda526ae 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -15,3 +15,4 @@ f9030b5c5232db69ba8625bb53d51ce735b80d51 6c439d731d97bd35a3289f54478af3ac01e30ba ae3cc5d298d6a913297fc4dc7cd8b08283e1fa01 bf7dc0ad0b4afe70113e17f6cd14647e47873fe975402dbd044d3161febaa0da 9fa3ab01bb67d5cec4daa358e25509a271af8171 0e054087a894280bd3cee5f11a9e30527534901d65b09dfe1dcc665b00798c5a cc6f4c287983325a3d87ec99f7d201eab53487da f4f8a6d3cc13b184d3834cf42a231e79116e4a8cc2f9911aa22e2906b5c23447 +9fa02f69abc945e480eed50c1eb77fe18514456e 136ef6ff9c576e82f2d7e6b413df2fa1185aa46d577630a7c65d6a875a763ebf From c9e3ccb38cfae719a818d07ec06a6e76b181b51c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:01:07 -0700 Subject: [PATCH 52/89] Update ghostty submodule with cli-helper step --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index 9fa02f69abc9..811360c52e02 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 9fa02f69abc945e480eed50c1eb77fe18514456e +Subproject commit 811360c52e028945a4a4c4056ee8e4220fbc8c6a From 5702a7f79d68b60d526a077a696d8f45554272e7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:34:00 -0700 Subject: [PATCH 53/89] Add GhosttyKit checksum for 811360c5 (cli-helper step) --- scripts/ghosttykit-checksums.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 89f0eda526ae..30cf715624e0 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -16,3 +16,4 @@ ae3cc5d298d6a913297fc4dc7cd8b08283e1fa01 bf7dc0ad0b4afe70113e17f6cd14647e47873fe 9fa3ab01bb67d5cec4daa358e25509a271af8171 0e054087a894280bd3cee5f11a9e30527534901d65b09dfe1dcc665b00798c5a cc6f4c287983325a3d87ec99f7d201eab53487da f4f8a6d3cc13b184d3834cf42a231e79116e4a8cc2f9911aa22e2906b5c23447 9fa02f69abc945e480eed50c1eb77fe18514456e 136ef6ff9c576e82f2d7e6b413df2fa1185aa46d577630a7c65d6a875a763ebf +811360c52e028945a4a4c4056ee8e4220fbc8c6a b037c3bd141436bf2090fba04c8c101010aac09d0217dfbdca51f3bbc1a88de6 From db5a920e90eef01fc241618aef51759c4b8c30a7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:33:40 -0700 Subject: [PATCH 54/89] Update ghostty submodule with zig 0.15.2 compat fix --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index 811360c52e02..fc59b46ba677 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 811360c52e028945a4a4c4056ee8e4220fbc8c6a +Subproject commit fc59b46ba67787e4fd6ad0de63e5e469592d3eae From f45c8f4802f4ad1f84481ab6143db8a02edb3116 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:39:19 -0700 Subject: [PATCH 55/89] Add GhosttyKit checksum for fc59b46b (zig 0.15.2 compat) --- scripts/ghosttykit-checksums.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 30cf715624e0..96d2301d7ef0 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -17,3 +17,4 @@ ae3cc5d298d6a913297fc4dc7cd8b08283e1fa01 bf7dc0ad0b4afe70113e17f6cd14647e47873fe cc6f4c287983325a3d87ec99f7d201eab53487da f4f8a6d3cc13b184d3834cf42a231e79116e4a8cc2f9911aa22e2906b5c23447 9fa02f69abc945e480eed50c1eb77fe18514456e 136ef6ff9c576e82f2d7e6b413df2fa1185aa46d577630a7c65d6a875a763ebf 811360c52e028945a4a4c4056ee8e4220fbc8c6a b037c3bd141436bf2090fba04c8c101010aac09d0217dfbdca51f3bbc1a88de6 +fc59b46ba67787e4fd6ad0de63e5e469592d3eae 410196d8ebb5371e2c6ed25489e7411e43f920ff5f4bea2c0461856f7dbd431c From 4bad2da674128e15eafefb925d9d6986be8c9a93 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:46:58 -0700 Subject: [PATCH 56/89] Update ghostty to origin/main with all cmux patches --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index fc59b46ba677..3b684a085d40 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit fc59b46ba67787e4fd6ad0de63e5e469592d3eae +Subproject commit 3b684a085d40ec79e8a0ae863a4f2b48ed4dba74 From 1a0555551920abb109cd0ff4492c00c77e26936d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:53:36 -0700 Subject: [PATCH 57/89] Add GhosttyKit checksum for 3b684a08 (origin/main with all patches) --- scripts/ghosttykit-checksums.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 96d2301d7ef0..fc93a2c262d2 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -18,3 +18,4 @@ cc6f4c287983325a3d87ec99f7d201eab53487da f4f8a6d3cc13b184d3834cf42a231e79116e4a8 9fa02f69abc945e480eed50c1eb77fe18514456e 136ef6ff9c576e82f2d7e6b413df2fa1185aa46d577630a7c65d6a875a763ebf 811360c52e028945a4a4c4056ee8e4220fbc8c6a b037c3bd141436bf2090fba04c8c101010aac09d0217dfbdca51f3bbc1a88de6 fc59b46ba67787e4fd6ad0de63e5e469592d3eae 410196d8ebb5371e2c6ed25489e7411e43f920ff5f4bea2c0461856f7dbd431c +3b684a085d40ec79e8a0ae863a4f2b48ed4dba74 1d408a5ba5e7996b21a4f3346adbc971c47f3ccf7e440bd334ac5389cd2f11c8 From d576d913e179bd3613ddf5cd6c143a89a241f84a Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 8 Apr 2026 12:57:56 -0700 Subject: [PATCH 58/89] Fix selection autoscroll past terminal viewport edge (#2705) --- Sources/GhosttyTerminalView.swift | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0ae3be69b68d..6f502b38c7dc 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -7188,11 +7188,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let surface = surface else { return } let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) - let point = preferredPointerPoint(from: eventPoint) ?? eventPoint // Only update mouse position on the first click to prevent unwanted cursor // movement during double-click selection (issue #1698) if event.clickCount == 1 { - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, modsFromEvent(event)) } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) } @@ -7939,18 +7938,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let suppressCommandPathHover = shouldSuppressCommandPathHover(for: event.modifierFlags) let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) - let point = preferredPointerPoint(from: eventPoint) ?? eventPoint ghostty_surface_mouse_pos( surface, - point.x, - bounds.height - point.y, + eventPoint.x, + bounds.height - eventPoint.y, hoverModsFromFlags( event.modifierFlags, suppressCommandPathHover: suppressCommandPathHover ) ) updateWordPathHover( - at: point, + at: eventPoint, cmdHeld: event.modifierFlags.contains(.command), suppressPathHover: suppressCommandPathHover ) @@ -7963,18 +7961,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let suppressCommandPathHover = shouldSuppressCommandPathHover(for: event.modifierFlags) let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) - let point = preferredPointerPoint(from: eventPoint) ?? eventPoint ghostty_surface_mouse_pos( surface, - point.x, - bounds.height - point.y, + eventPoint.x, + bounds.height - eventPoint.y, hoverModsFromFlags( event.modifierFlags, suppressCommandPathHover: suppressCommandPathHover ) ) updateWordPathHover( - at: point, + at: eventPoint, cmdHeld: event.modifierFlags.contains(.command), suppressPathHover: suppressCommandPathHover ) @@ -8013,8 +8010,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard let surface = surface else { return } let eventPoint = convert(event.locationInWindow, from: nil) trackMousePointIfUsable(eventPoint) - let point = preferredPointerPoint(from: eventPoint) ?? eventPoint - ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) + // Forward the raw drag coordinates, including out-of-bounds positions. + // Selection auto-scroll depends on libghostty observing the pointer leave + // the viewport rather than a cached in-bounds hover point. + ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, modsFromEvent(event)) } override func scrollWheel(with event: NSEvent) { From 1d81b749b8631ab01296f0b929224c84fae05350 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:10:37 -0700 Subject: [PATCH 59/89] Add founder's edition Added a custom sponsorship URL for founders edition. --- .github/FUNDING.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000000..c67679ee5a93 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,16 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: ['https://github.com/manaflow-ai/cmux#founders-edition'] +# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] From c2e97beab99d903e4dc1019e5df3ff9ba5e836f7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:38:03 -0700 Subject: [PATCH 60/89] Add Cursor and Gemini CLI agent integrations + setup-hooks (#2717) * Add Cursor and Gemini CLI agent integrations Add hook-based integrations for Cursor and Gemini CLI, following the existing Codex pattern. Each agent gets: settings toggle in Automation, settings.json support, environment variable to disable, CLI hook handler, and install/uninstall commands. Cursor hooks write to ~/.cursor/hooks.json (flat format with version: 1). Events: beforeSubmitPrompt, stop, afterAgentResponse, shell exec/done. Gemini hooks write to ~/.gemini/settings.json (nested format, ms timeouts). Events: SessionStart, BeforeAgent, AfterAgent, SessionEnd. Also adds `cmux setup-hooks` to install hooks for all discovered agents (Codex, Cursor, Gemini) in one command. * Refactor to generic agent hook system, add Copilot/CodeBuddy/Factory/Qoder Replace per-agent install/uninstall/handler functions with a generic system driven by AgentHookDef configs. Adding a new agent is now just adding an entry to the agentDefs array. New agents: Copilot (~/.copilot/config.json), CodeBuddy (~/.codebuddy/settings.json), Factory (~/.factory/settings.json), Qoder (~/.qoder/settings.json). `cmux setup-hooks` now discovers and installs all 7 agents (Codex, Cursor, Gemini, Copilot, CodeBuddy, Factory, Qoder). Claude Code remains automatic via its wrapper script. * Migrate Codex to generic agent hook system, remove 500+ lines of duplication Move Codex from its custom runCodexHook/runCodexInstallHooks/etc. to the same generic AgentHookDef system used by all other agents. The generic handler now includes the Codex "extras" that all agents benefit from: - resolvePreferredWorkspaceId/SurfaceId for robust workspace resolution - --workspace/--surface flag parsing - shouldIgnoreClaudeHookTeardownError for graceful stop during teardown - sanitizeNotificationField/normalizedSingleLine/truncate for clean notifications - config.toml postInstallAction for Codex-specific codex_hooks = true Net result: -510 lines. Adding a new agent is now just a 10-line entry in the agentDefs array. * Add cmux uninstall-hooks and setup-hooks --uninstall cmux uninstall-hooks removes cmux hook entries from all discovered agents. cmux setup-hooks --uninstall does the same. Both support --agent <name> to target a specific agent. Only cmux-owned entries are removed. * Address review feedback: atomic writes, fix codex_hooks=false override - Use .atomic option for all config file writes to prevent corruption on interrupted writes - Fix codex config.toml: replace codex_hooks = false with true instead of only inserting when absent * Add localization entries for Cursor and Gemini settings strings Add en + ja translations for all 8 new localized string keys used in the Settings > Automation section for Cursor and Gemini integrations. * Fix adversarial review findings: JSON safety, CODEX_HOME, uninstall parity - Abort with error when config file exists but is malformed JSON instead of silently overwriting with empty hooks object - Respect CODEX_HOME env var via configDirEnvOverride on AgentHookDef, so users with non-default Codex home get hooks in the right place - Uninstall now reverts config.toml codex_hooks line, restoring full install/uninstall round-trip parity --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com> --- CLI/cmux.swift | 1078 +++++++++-------- Resources/Localizable.xcstrings | 136 +++ Sources/GhosttyTerminalView.swift | 6 + .../KeyboardShortcutSettingsFileStore.swift | 10 + Sources/cmuxApp.swift | 68 ++ 5 files changed, 774 insertions(+), 524 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 20b729ecb94e..7b47b783c17a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1810,10 +1810,10 @@ struct CMUXCLI { if command == "codex" { let sub = commandArgs.first?.lowercased() ?? "help" if sub == "install-hooks" { - try runCodexInstallHooks() + try installAgentHooks(Self.agentDef(named: "codex")!) return } else if sub == "uninstall-hooks" { - try runCodexUninstallHooks() + try uninstallAgentHooks(Self.agentDef(named: "codex")!) return } } @@ -1827,6 +1827,73 @@ struct CMUXCLI { } } + // Cursor hooks management (no socket needed) + if command == "cursor" { + let sub = commandArgs.first?.lowercased() ?? "help" + if sub == "install-hooks" { + try runCursorInstallHooks() + return + } else if sub == "uninstall-hooks" { + try runCursorUninstallHooks() + return + } + } + + // Cursor hook handler: gracefully no-op when not inside cmux + if command == "cursor-hook" { + guard ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] != nil else { + print("{}") + return + } + } + + // Gemini hooks management (no socket needed) + if command == "gemini" { + let sub = commandArgs.first?.lowercased() ?? "help" + if sub == "install-hooks" { + try runGeminiInstallHooks() + return + } else if sub == "uninstall-hooks" { + try runGeminiUninstallHooks() + return + } + } + + // Gemini hook handler: gracefully no-op when not inside cmux + if command == "gemini-hook" { + guard ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] != nil else { + print("{}") + return + } + } + + // Generic agent hooks management (copilot, codebuddy, factory, qoder) + for agentName in ["copilot", "codebuddy", "factory", "qoder"] { + guard command == agentName else { continue } + let sub = commandArgs.first?.lowercased() ?? "help" + if sub == "install-hooks", let def = Self.agentDef(named: agentName) { + try installAgentHooks(def) + return + } else if sub == "uninstall-hooks", let def = Self.agentDef(named: agentName) { + try uninstallAgentHooks(def) + return + } + } + + // Generic agent hook handlers: gracefully no-op outside cmux + if ["copilot-hook", "codebuddy-hook", "factory-hook", "qoder-hook"].contains(command) { + guard ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] != nil else { + print("{}") + return + } + } + + // Unified hook setup for all agents + if command == "setup-hooks" || command == "uninstall-hooks" { + try runSetupHooks(uninstall: command == "uninstall-hooks") + return + } + let client = SocketClient(path: resolvedSocketPath) if resolvedSocketPath != socketPath { cliTelemetry.breadcrumb( @@ -2650,7 +2717,7 @@ struct CMUXCLI { case "codex-hook": cliTelemetry.breadcrumb("codex-hook.dispatch") do { - try runCodexHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) + try runGenericAgentHook(def: Self.agentDef(named: "codex")!, commandArgs: commandArgs, client: client, telemetry: cliTelemetry) cliTelemetry.breadcrumb("codex-hook.completed") } catch { cliTelemetry.breadcrumb("codex-hook.failure") @@ -2658,6 +2725,43 @@ struct CMUXCLI { throw error } + case "cursor-hook": + cliTelemetry.breadcrumb("cursor-hook.dispatch") + do { + try runCursorHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) + cliTelemetry.breadcrumb("cursor-hook.completed") + } catch { + cliTelemetry.breadcrumb("cursor-hook.failure") + cliTelemetry.captureError(stage: "cursor_hook_dispatch", error: error) + throw error + } + + case "gemini-hook": + cliTelemetry.breadcrumb("gemini-hook.dispatch") + do { + try runGeminiHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) + cliTelemetry.breadcrumb("gemini-hook.completed") + } catch { + cliTelemetry.breadcrumb("gemini-hook.failure") + cliTelemetry.captureError(stage: "gemini_hook_dispatch", error: error) + throw error + } + + case "copilot-hook", "codebuddy-hook", "factory-hook", "qoder-hook": + let agentName = String(command.dropLast("-hook".count)) + guard let def = Self.agentDef(named: agentName) else { + throw CLIError(message: "Unknown agent: \(agentName)") + } + cliTelemetry.breadcrumb("\(command).dispatch") + do { + try runGenericAgentHook(def: def, commandArgs: commandArgs, client: client, telemetry: cliTelemetry) + cliTelemetry.breadcrumb("\(command).completed") + } catch { + cliTelemetry.breadcrumb("\(command).failure") + cliTelemetry.captureError(stage: "\(agentName)_hook_dispatch", error: error) + throw error + } + case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } let response = try sendV1Command("set_app_focus \(value)", client: client) @@ -13307,641 +13411,567 @@ struct CMUXCLI { /// when not running inside cmux. The command checks for cmux on PATH /// first so it silently succeeds even when cmux is not installed /// (e.g. user opened codex in a non-cmux terminal). - private static func codexHookCommand(_ event: String) -> String { - "[ -n \"$CMUX_SURFACE_ID\" ] && command -v cmux >/dev/null 2>&1 && cmux codex-hook \(event) || echo '{}'" - } - - private static let codexHooksJSON: [String: Any] = [ - "hooks": [ - "SessionStart": [[ - "hooks": [[ - "type": "command", - "command": codexHookCommand("session-start"), - "timeout": 10 - ] as [String: Any]] - ] as [String: Any]], - "UserPromptSubmit": [[ - "hooks": [[ - "type": "command", - "command": codexHookCommand("prompt-submit"), - "timeout": 10 - ] as [String: Any]] - ] as [String: Any]], - "Stop": [[ - "hooks": [[ - "type": "command", - "command": codexHookCommand("stop"), - "timeout": 10 - ] as [String: Any]] - ] as [String: Any]] - ] as [String: Any] - ] - - /// Identifier used to detect cmux-owned hooks during uninstall. - private static let codexHookCommandMarker = "cmux codex-hook" - - private func runCodexInstallHooks() throws { - let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") - || ProcessInfo.processInfo.arguments.contains("-y") - let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"] - ?? NSString(string: "~/.codex").expandingTildeInPath - let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json") - let configPath = (codexHome as NSString).appendingPathComponent("config.toml") - let fm = FileManager.default - - // Ensure ~/.codex/ exists - try fm.createDirectory(atPath: codexHome, withIntermediateDirectories: true, attributes: nil) - - // Read existing state - let existingHooksContent: String? = fm.fileExists(atPath: hooksPath) - ? (try? String(contentsOfFile: hooksPath, encoding: .utf8)) - : nil - - // Build merged hooks - var existing: [String: Any] = [:] - if let existingHooksContent, - let data = existingHooksContent.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - existing = parsed - } - - var hooks = existing["hooks"] as? [String: Any] ?? [:] - let cmuxHooks = Self.codexHooksJSON["hooks"] as! [String: Any] - for (eventName, cmuxGroups) in cmuxHooks { - guard let cmuxGroupArray = cmuxGroups as? [[String: Any]] else { continue } - var eventGroups = hooks[eventName] as? [[String: Any]] ?? [] - eventGroups.removeAll { group in - guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false } - return groupHooks.allSatisfy { hook in - (hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true - } - } - eventGroups.append(contentsOf: cmuxGroupArray) - hooks[eventName] = eventGroups - } - existing["hooks"] = hooks - let newJsonData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys]) - let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? "" - - // Build new config.toml content - let existingConfigContent: String = fm.fileExists(atPath: configPath) - ? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "") - : "" - let newConfigContent = buildConfigWithCodexHooks(existingConfigContent) - - // Check if anything would change - let hooksChanged = existingHooksContent != newHooksContent - let configChanged = existingConfigContent != newConfigContent - if !hooksChanged && !configChanged { - print("cmux hooks are already installed. Nothing to change.") - return - } + // MARK: - Agent PID inference - // Show diff and ask for confirmation - if hooksChanged { - print(" \(hooksPath):") - if let existingHooksContent { - printSimpleDiff(old: existingHooksContent, new: newHooksContent) - } else { - print(" (new file)") - let lines = newHooksContent.components(separatedBy: "\n") - for (i, line) in lines.enumerated() { - let lineLabel = String(format: "%3d", i + 1) - print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m") - } - } - print("") - } + private func inferredCodexAgentPID() -> Int? { + var candidate = getppid() + var remainingWrapperSkips = 8 - if configChanged { - print(" \(configPath):") - if existingConfigContent.isEmpty { - print(" (new file)") - let lines = newConfigContent.components(separatedBy: "\n") - for (i, line) in lines.enumerated() where !line.isEmpty { - let lineLabel = String(format: "%3d", i + 1) - print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m") - } - } else { - printSimpleDiff(old: existingConfigContent, new: newConfigContent) + while candidate > 1, remainingWrapperSkips > 0 { + guard let processName = processName(for: candidate) else { break } + if !codexHookWrapperProcessNames.contains(processName) { + break } - print("") + let next = parentPID(of: candidate) + guard next > 1, next != candidate else { break } + candidate = next + remainingWrapperSkips -= 1 } - if !skipConfirm { - print("Apply these changes? [Y/n] ", terminator: "") - if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - !response.isEmpty && response != "y" && response != "yes" { - print("Aborted.") - return - } - } + return candidate > 1 ? Int(candidate) : nil + } - // Apply changes - if hooksChanged { - try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic) - } - if configChanged { - try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8) + private func parentPID(of pid: pid_t) -> pid_t { + var info = kinfo_proc() + var size = MemoryLayout<kinfo_proc>.size + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + guard sysctl(&mib, 4, &info, &size, nil, 0) == 0 else { + return -1 } - - print("") - print("Installed. Hooks activate inside cmux and silently no-op elsewhere.") - print("To remove: cmux codex uninstall-hooks") + return info.kp_eproc.e_ppid } - private func runCodexUninstallHooks() throws { - let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") - || ProcessInfo.processInfo.arguments.contains("-y") - let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"] - ?? NSString(string: "~/.codex").expandingTildeInPath - let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json") - let configPath = (codexHome as NSString).appendingPathComponent("config.toml") - let fm = FileManager.default + private func processName(for pid: pid_t) -> String? { + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-p", String(pid), "-o", "comm="] + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice - guard fm.fileExists(atPath: hooksPath), - let data = try? Data(contentsOf: URL(fileURLWithPath: hooksPath)), - var parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - print("No hooks.json found at \(hooksPath)") - return + do { + try process.run() + } catch { + return nil } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } - guard var hooks = parsed["hooks"] as? [String: Any] else { - print("No hooks section found in \(hooksPath)") - return + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty else { + return nil } + return URL(fileURLWithPath: output).lastPathComponent.lowercased() + } - // Build the new state without cmux hooks - var removedCount = 0 - for eventName in hooks.keys { - guard var eventGroups = hooks[eventName] as? [[String: Any]] else { continue } - let before = eventGroups.count - eventGroups.removeAll { group in - guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false } - return groupHooks.allSatisfy { hook in - (hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true - } - } - removedCount += before - eventGroups.count - if eventGroups.isEmpty { - hooks.removeValue(forKey: eventName) - } else { - hooks[eventName] = eventGroups - } - } + // MARK: - Generic agent hook system - // Build config.toml without codex_hooks - let existingConfigContent: String = fm.fileExists(atPath: configPath) - ? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "") - : "" - let newConfigContent = buildConfigWithoutCodexHooks(existingConfigContent) - let configChanged = existingConfigContent != newConfigContent + /// Configuration for a hook-based agent integration. + private struct AgentHookDef { + let name: String // CLI name: "cursor", "gemini", etc. + let displayName: String // Human-readable: "Cursor", "Gemini" + let statusKey: String // Key for set_status: "cursor", "gemini" + let configDir: String // Relative to ~: ".cursor", ".gemini" + let configFile: String // File name: "hooks.json", "settings.json" + let configDirEnvOverride: String? // e.g. "CODEX_HOME" overrides configDir + let sessionStoreSuffix: String // e.g. "cursor" -> ~/.cmuxterm/cursor-hook-sessions.json + let disableEnvVar: String // e.g. "CMUX_CURSOR_HOOKS_DISABLED" + let hookMarker: String // Marker in commands: "cmux cursor-hook" + let format: HookFormat + let events: [HookEvent] + let postInstallAction: PostInstallAction? - if removedCount == 0 && !configChanged { - print("No cmux hooks found.") - return + enum HookFormat { + case flat // Cursor: {"hooks": {"event": [{"command": "..."}]}, "version": 1} + case nested(timeoutMs: Int) // Codex/Gemini: nested with type/command/timeout } - parsed["hooks"] = hooks - let newJsonData = try JSONSerialization.data(withJSONObject: parsed, options: [.prettyPrinted, .sortedKeys]) - let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? "" - let oldHooksContent = String(data: data, encoding: .utf8) ?? "" - - // Show diff and ask for confirmation - if removedCount > 0 { - print(" \(hooksPath):") - printSimpleDiff(old: oldHooksContent, new: newHooksContent) - print("") + struct HookEvent { + let agentEvent: String + let cmuxSubcommand: String } - if configChanged { - print(" \(configPath):") - printSimpleDiff(old: existingConfigContent, new: newConfigContent) - print("") + enum PostInstallAction { + case codexConfigToml // write codex_hooks = true to config.toml on install, remove on uninstall } - if !skipConfirm { - print("Apply these changes? [Y/n] ", terminator: "") - if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), - !response.isEmpty && response != "y" && response != "yes" { - print("Aborted.") - return + /// Resolves the config directory, respecting env override if set. + func resolvedConfigDir() -> String { + if let envKey = configDirEnvOverride, + let envValue = ProcessInfo.processInfo.environment[envKey], + !envValue.isEmpty { + return NSString(string: envValue).expandingTildeInPath } + return NSString(string: "~/\(configDir)").expandingTildeInPath } - if removedCount > 0 { - try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic) - } - if configChanged { - try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8) + init(name: String, displayName: String, statusKey: String, + configDir: String, configFile: String, configDirEnvOverride: String? = nil, + sessionStoreSuffix: String, disableEnvVar: String, hookMarker: String, + format: HookFormat, events: [HookEvent], postInstallAction: PostInstallAction? = nil) { + self.name = name; self.displayName = displayName; self.statusKey = statusKey + self.configDir = configDir; self.configFile = configFile + self.configDirEnvOverride = configDirEnvOverride + self.sessionStoreSuffix = sessionStoreSuffix; self.disableEnvVar = disableEnvVar + self.hookMarker = hookMarker; self.format = format; self.events = events + self.postInstallAction = postInstallAction } - print("Removed cmux Codex hooks.") } - /// Print a unified-diff-style view with context lines and line numbers. - private func printSimpleDiff(old: String, new: String, contextLines: Int = 2) { - let red = "\u{001B}[31m" - let green = "\u{001B}[32m" - let dim = "\u{001B}[2m" - let reset = "\u{001B}[0m" + private enum AgentHookAction { + case sessionStart, promptSubmit, stop, sessionEnd, noop + } + + private static let subcommandActions: [String: AgentHookAction] = [ + "session-start": .sessionStart, + "prompt-submit": .promptSubmit, + "stop": .stop, + "agent-response": .stop, + "shell-exec": .promptSubmit, + "shell-done": .noop, + "session-end": .sessionEnd, + ] + + // MARK: Agent definitions + + private static let agentDefs: [AgentHookDef] = [ + AgentHookDef( + name: "codex", displayName: "Codex", statusKey: "codex", + configDir: ".codex", configFile: "hooks.json", configDirEnvOverride: "CODEX_HOME", + sessionStoreSuffix: "codex", disableEnvVar: "CMUX_CODEX_HOOKS_DISABLED", + hookMarker: "cmux codex-hook", format: .nested(timeoutMs: 5000), + events: [ + .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), + .init(agentEvent: "UserPromptSubmit", cmuxSubcommand: "prompt-submit"), + .init(agentEvent: "Stop", cmuxSubcommand: "stop"), + ], + postInstallAction: .codexConfigToml + ), + AgentHookDef( + name: "cursor", displayName: "Cursor", statusKey: "cursor", + configDir: ".cursor", configFile: "hooks.json", + sessionStoreSuffix: "cursor", disableEnvVar: "CMUX_CURSOR_HOOKS_DISABLED", + hookMarker: "cmux cursor-hook", format: .flat, + events: [ + .init(agentEvent: "beforeSubmitPrompt", cmuxSubcommand: "prompt-submit"), + .init(agentEvent: "stop", cmuxSubcommand: "stop"), + .init(agentEvent: "afterAgentResponse", cmuxSubcommand: "agent-response"), + .init(agentEvent: "beforeShellExecution", cmuxSubcommand: "shell-exec"), + .init(agentEvent: "afterShellExecution", cmuxSubcommand: "shell-done"), + ] + ), + AgentHookDef( + name: "gemini", displayName: "Gemini", statusKey: "gemini", + configDir: ".gemini", configFile: "settings.json", + sessionStoreSuffix: "gemini", disableEnvVar: "CMUX_GEMINI_HOOKS_DISABLED", + hookMarker: "cmux gemini-hook", format: .nested(timeoutMs: 10000), + events: [ + .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), + .init(agentEvent: "BeforeAgent", cmuxSubcommand: "prompt-submit"), + .init(agentEvent: "AfterAgent", cmuxSubcommand: "stop"), + .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), + ] + ), + AgentHookDef( + name: "copilot", displayName: "Copilot", statusKey: "copilot", + configDir: ".copilot", configFile: "config.json", + sessionStoreSuffix: "copilot", disableEnvVar: "CMUX_COPILOT_HOOKS_DISABLED", + hookMarker: "cmux copilot-hook", format: .nested(timeoutMs: 5000), + events: [ + .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), + .init(agentEvent: "Stop", cmuxSubcommand: "stop"), + .init(agentEvent: "Notification", cmuxSubcommand: "stop"), + .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), + ] + ), + AgentHookDef( + name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", + configDir: ".codebuddy", configFile: "settings.json", + sessionStoreSuffix: "codebuddy", disableEnvVar: "CMUX_CODEBUDDY_HOOKS_DISABLED", + hookMarker: "cmux codebuddy-hook", format: .nested(timeoutMs: 5000), + events: [ + .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), + .init(agentEvent: "Stop", cmuxSubcommand: "stop"), + .init(agentEvent: "Notification", cmuxSubcommand: "stop"), + .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), + ] + ), + AgentHookDef( + name: "factory", displayName: "Factory", statusKey: "factory", + configDir: ".factory", configFile: "settings.json", + sessionStoreSuffix: "factory", disableEnvVar: "CMUX_FACTORY_HOOKS_DISABLED", + hookMarker: "cmux factory-hook", format: .nested(timeoutMs: 5000), + events: [ + .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), + .init(agentEvent: "Stop", cmuxSubcommand: "stop"), + .init(agentEvent: "Notification", cmuxSubcommand: "stop"), + .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), + ] + ), + AgentHookDef( + name: "qoder", displayName: "Qoder", statusKey: "qoder", + configDir: ".qoder", configFile: "settings.json", + sessionStoreSuffix: "qoder", disableEnvVar: "CMUX_QODER_HOOKS_DISABLED", + hookMarker: "cmux qoder-hook", format: .nested(timeoutMs: 5000), + events: [ + .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), + .init(agentEvent: "Stop", cmuxSubcommand: "stop"), + .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), + ] + ), + ] - let oldLines = old.components(separatedBy: "\n") - let newLines = new.components(separatedBy: "\n") + private static func agentDef(named name: String) -> AgentHookDef? { + agentDefs.first { $0.name == name } + } - // Simple LCS-based diff: find matching lines - let lcs = longestCommonSubsequence(oldLines, newLines) - var oldIdx = 0, newIdx = 0, lcsIdx = 0 + // MARK: Generic hook install/uninstall - struct DiffLine { - enum Kind { case context, remove, add } - let kind: Kind - let lineNo: Int // 1-based, refers to old line for context/remove, new line for add - let text: String - } - var allDiffs: [DiffLine] = [] + private func hookCommand(for def: AgentHookDef, event: AgentHookDef.HookEvent) -> String { + "[ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && command -v cmux >/dev/null 2>&1 && cmux \(def.name)-hook \(event.cmuxSubcommand) || echo '{}'" + } - while oldIdx < oldLines.count || newIdx < newLines.count { - if lcsIdx < lcs.count && oldIdx < oldLines.count && newIdx < newLines.count - && oldLines[oldIdx] == lcs[lcsIdx] && newLines[newIdx] == lcs[lcsIdx] { - allDiffs.append(DiffLine(kind: .context, lineNo: newIdx + 1, text: newLines[newIdx])) - oldIdx += 1; newIdx += 1; lcsIdx += 1 - } else if oldIdx < oldLines.count && (lcsIdx >= lcs.count || oldLines[oldIdx] != lcs[lcsIdx]) { - allDiffs.append(DiffLine(kind: .remove, lineNo: oldIdx + 1, text: oldLines[oldIdx])) - oldIdx += 1 - } else if newIdx < newLines.count { - allDiffs.append(DiffLine(kind: .add, lineNo: newIdx + 1, text: newLines[newIdx])) - newIdx += 1 + private func buildHooksDict(for def: AgentHookDef) -> [String: Any] { + var result: [String: Any] = [:] + for event in def.events { + let cmd = hookCommand(for: def, event: event) + switch def.format { + case .flat: + result[event.agentEvent] = [["command": cmd]] + case .nested(let timeoutMs): + result[event.agentEvent] = [["hooks": [["type": "command", "command": cmd, "timeout": timeoutMs] as [String: Any]]] as [String: Any]] } } + return result + } - // Find ranges with changes and expand by context - var changedIndices = Set<Int>() - for (i, d) in allDiffs.enumerated() where d.kind != .context { - for j in max(0, i - contextLines)...min(allDiffs.count - 1, i + contextLines) { - changedIndices.insert(j) - } + private func installAgentHooks(_ def: AgentHookDef) throws { + let fm = FileManager.default + let configDir = def.resolvedConfigDir() + let filePath = "\(configDir)/\(def.configFile)" + let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") + || ProcessInfo.processInfo.arguments.contains("-y") + + guard fm.fileExists(atPath: configDir) else { + print("~/\(def.configDir)/ does not exist. Install \(def.displayName) first.") + return } - var lastPrinted = -1 - for i in changedIndices.sorted() { - if lastPrinted >= 0 && i > lastPrinted + 1 { - print(" \(dim)...\(reset)") - } - let d = allDiffs[i] - let lineLabel = String(format: "%3d", d.lineNo) - switch d.kind { - case .context: - print(" \(dim)\(lineLabel) \(d.text)\(reset)") - case .remove: - print(" \(red)\(lineLabel) -\(d.text)\(reset)") - case .add: - print(" \(green)\(lineLabel) +\(d.text)\(reset)") + var existing: [String: Any] = [:] + if let data = fm.contents(atPath: filePath) { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CLIError(message: "\(filePath) exists but is not valid JSON. Fix or remove it before installing hooks.") } - lastPrinted = i + existing = json } - } - private func longestCommonSubsequence(_ a: [String], _ b: [String]) -> [String] { - let m = a.count, n = b.count - var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1) - for i in 1...m { - for j in 1...n { - if a[i - 1] == b[j - 1] { - dp[i][j] = dp[i - 1][j - 1] + 1 - } else { - dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + var hooks = existing["hooks"] as? [String: Any] ?? [:] + let newHooks = buildHooksDict(for: def) + + // Remove existing cmux-owned entries + for (event, value) in hooks { + switch def.format { + case .flat: + guard var entries = value as? [[String: Any]] else { continue } + entries.removeAll { ($0["command"] as? String)?.contains(def.hookMarker) == true } + hooks[event] = entries.isEmpty ? nil : entries + case .nested: + guard var groups = value as? [[String: Any]] else { continue } + groups.removeAll { group in + guard let hookList = group["hooks"] as? [[String: Any]] else { return false } + return hookList.allSatisfy { ($0["command"] as? String)?.contains(def.hookMarker) == true } } + hooks[event] = groups.isEmpty ? nil : groups } } - var result: [String] = [] - var i = m, j = n - while i > 0 && j > 0 { - if a[i - 1] == b[j - 1] { - result.append(a[i - 1]) - i -= 1; j -= 1 - } else if dp[i - 1][j] > dp[i][j - 1] { - i -= 1 - } else { - j -= 1 + + // Add new cmux entries + for (event, value) in newHooks { + switch def.format { + case .flat: + var entries = hooks[event] as? [[String: Any]] ?? [] + if let newEntries = value as? [[String: Any]] { entries.append(contentsOf: newEntries) } + hooks[event] = entries + case .nested: + var groups = hooks[event] as? [[String: Any]] ?? [] + if let newGroups = value as? [[String: Any]] { groups.append(contentsOf: newGroups) } + hooks[event] = groups } } - return result.reversed() - } - /// Returns config.toml content with codex_hooks = true under [features]. - private func buildConfigWithCodexHooks(_ content: String) -> String { - var lines = content.components(separatedBy: "\n") + existing["hooks"] = hooks + if case .flat = def.format { existing["version"] = 1 } - // Check if codex_hooks key already exists (exact key match at line start) - if let idx = lines.firstIndex(where: { isTomlKey($0, key: "codex_hooks") }) { - lines[idx] = "codex_hooks = true" - return lines.joined(separator: "\n") - } + let newData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys]) - // Find [features] section and insert after it (first occurrence only) - if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) { - lines.insert("codex_hooks = true", at: idx + 1) - return lines.joined(separator: "\n") + if !skipConfirm { + print("Will write to \(filePath):") + print(String(data: newData, encoding: .utf8) ?? "{}") + print("\nProceed? [y/N] ", terminator: "") + guard readLine()?.lowercased().hasPrefix("y") == true else { + print("Aborted.") + return + } } - // No [features] section, append one - var result = content - if !result.isEmpty && !result.hasSuffix("\n") { - result += "\n" + try newData.write(to: URL(fileURLWithPath: filePath), options: .atomic) + print("\(def.displayName) hooks installed at \(filePath)") + + // Post-install actions + if let action = def.postInstallAction { + switch action { + case .codexConfigToml: + let configPath = "\(configDir)/config.toml" + let existingContent: String = fm.fileExists(atPath: configPath) + ? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "") + : "" + let newContent: String + if existingContent.contains("codex_hooks") { + // Replace existing value (might be false) with true + newContent = existingContent.replacingOccurrences( + of: "codex_hooks\\s*=\\s*\\w+", + with: "codex_hooks = true", + options: .regularExpression + ) + } else if existingContent.contains("[features]") { + newContent = existingContent.replacingOccurrences( + of: "[features]", + with: "[features]\ncodex_hooks = true" + ) + } else { + newContent = existingContent + "\n[features]\ncodex_hooks = true\n" + } + if newContent != existingContent { + try newContent.write(toFile: configPath, atomically: true, encoding: .utf8) + print("Enabled codex_hooks in \(configPath)") + } + } } - result += "\n[features]\ncodex_hooks = true\n" - return result } - /// Returns config.toml content with codex_hooks removed from [features]. - private func buildConfigWithoutCodexHooks(_ content: String) -> String { - var lines = content.components(separatedBy: "\n") + private func uninstallAgentHooks(_ def: AgentHookDef) throws { + let fm = FileManager.default + let configDir = def.resolvedConfigDir() + let filePath = "\(configDir)/\(def.configFile)" - // Remove the codex_hooks line - lines.removeAll { isTomlKey($0, key: "codex_hooks") } + guard let data = fm.contents(atPath: filePath), + var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + print("No \(def.configFile) found at \(filePath)") + return + } - // If [features] section is now empty (only has the header, nothing before next section or EOF), - // remove the header too - if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) { - let nextNonEmpty = lines[(idx + 1)...].firstIndex(where: { - !$0.trimmingCharacters(in: .whitespaces).isEmpty - }) - let sectionEmpty = nextNonEmpty == nil || lines[nextNonEmpty!].trimmingCharacters(in: .whitespaces).hasPrefix("[") - if sectionEmpty { - lines.remove(at: idx) + var hooks = json["hooks"] as? [String: Any] ?? [:] + var removed = 0 + + for (event, value) in hooks { + switch def.format { + case .flat: + guard var entries = value as? [[String: Any]] else { continue } + let before = entries.count + entries.removeAll { ($0["command"] as? String)?.contains(def.hookMarker) == true } + removed += before - entries.count + hooks[event] = entries.isEmpty ? nil : entries + case .nested: + guard var groups = value as? [[String: Any]] else { continue } + let before = groups.count + groups.removeAll { group in + guard let hookList = group["hooks"] as? [[String: Any]] else { return false } + return hookList.allSatisfy { ($0["command"] as? String)?.contains(def.hookMarker) == true } + } + removed += before - groups.count + hooks[event] = groups.isEmpty ? nil : groups + } + } + + json["hooks"] = hooks + let newData = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) + try newData.write(to: URL(fileURLWithPath: filePath), options: .atomic) + print("Removed \(removed) cmux hook(s) from \(filePath)") + + // Post-uninstall actions + if let action = def.postInstallAction { + switch action { + case .codexConfigToml: + let configPath = "\(configDir)/config.toml" + guard fm.fileExists(atPath: configPath), + let content = try? String(contentsOfFile: configPath, encoding: .utf8), + content.contains("codex_hooks") else { return } + // Remove the codex_hooks line + let newContent = content.replacingOccurrences( + of: "\\n?codex_hooks\\s*=\\s*\\w+", + with: "", + options: .regularExpression + ) + if newContent != content { + try newContent.write(toFile: configPath, atomically: true, encoding: .utf8) + print("Removed codex_hooks from \(configPath)") + } } } - - return lines.joined(separator: "\n") } - /// Check if a TOML line sets a specific key (ignoring comments and whitespace). - private func isTomlKey(_ line: String, key: String) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.hasPrefix("#") else { return false } - guard trimmed.hasPrefix(key) else { return false } - let rest = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces) - return rest.hasPrefix("=") - } + // MARK: Generic hook handler - /// Codex hook handler. Gracefully no-ops when not running inside cmux. - private func runCodexHook( - commandArgs: [String], - client: SocketClient, - telemetry: CLISocketSentryTelemetry - ) throws { + private func runGenericAgentHook(def: AgentHookDef, commandArgs: [String], client: SocketClient, telemetry: CLISocketSentryTelemetry) throws { let env = ProcessInfo.processInfo.environment - - // Graceful no-op: if not inside cmux, exit silently with valid JSON - guard env["CMUX_SURFACE_ID"] != nil else { - print("{}") - return - } - - let subcommand = commandArgs.first?.lowercased() ?? "help" + let subcommand = commandArgs.first?.lowercased() ?? "" let hookArgs = Array(commandArgs.dropFirst()) + telemetry.breadcrumb("\(def.name)-hook.\(subcommand)") + + // Workspace/surface resolution: prefer --workspace/--surface flags, then session store, then env let hookWsFlag = optionValue(hookArgs, name: "--workspace") let workspaceArg = hookWsFlag ?? env["CMUX_WORKSPACE_ID"] let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? env["CMUX_SURFACE_ID"] : nil) + let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" - let parsedInput = parseClaudeHookInput(rawInput: rawInput) - let sessionStore = ClaudeHookSessionStore( + let input = parseClaudeHookInput(rawInput: rawInput) + + let store = ClaudeHookSessionStore( processEnv: env.merging( - ["CMUX_CLAUDE_HOOK_STATE_PATH": "~/.cmuxterm/codex-hook-sessions.json"], + ["CMUX_CLAUDE_HOOK_STATE_PATH": "~/.cmuxterm/\(def.sessionStoreSuffix)-hook-sessions.json"], uniquingKeysWith: { _, new in new } ) ) - telemetry.breadcrumb( - "codex-hook.input", - data: [ - "subcommand": subcommand, - "has_session_id": parsedInput.sessionId != nil - ] - ) - switch subcommand { - case "session-start": - telemetry.breadcrumb("codex-hook.session-start") - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( - preferred: nil, - fallback: workspaceArg, - client: client - ) - let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( - preferred: nil, - fallback: surfaceArg, - workspaceId: workspaceId, - client: client - ) - let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId) - let codexPid = inferredCodexAgentPID() - if let sessionId = parsedInput.sessionId { - try? sessionStore.upsert( - sessionId: sessionId, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: parsedInput.cwd, - pid: codexPid - ) + let sessionId = input.sessionId ?? env["CMUX_SURFACE_ID"] ?? "" + let action = Self.subcommandActions[subcommand] ?? .noop + let pidKey = "\(def.statusKey).\(sessionId.isEmpty ? "default" : sessionId)" + + switch action { + case .sessionStart: + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(preferred: nil, fallback: workspaceArg, client: client) + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(preferred: nil, fallback: surfaceArg, workspaceId: workspaceId, client: client) + let pid = inferredCodexAgentPID() + if !sessionId.isEmpty { + try? store.upsert(sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: input.cwd, pid: pid) } - if let codexPid { - _ = try? sendV1Command( - "set_agent_pid \(agentPIDKey) \(codexPid) --tab=\(workspaceId)", - client: client - ) + if let pid { + _ = try? sendV1Command("set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)", client: client) } - print("{}") - case "prompt-submit": - telemetry.breadcrumb("codex-hook.prompt-submit") - let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( - preferred: mappedSession?.workspaceId, - fallback: workspaceArg, - client: client - ) - let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) - let codexPid = mappedSession?.pid ?? inferredCodexAgentPID() - if let sessionId = parsedInput.sessionId, let mappedSession { - try? sessionStore.upsert( - sessionId: sessionId, - workspaceId: workspaceId, - surfaceId: mappedSession.surfaceId, - cwd: parsedInput.cwd ?? mappedSession.cwd, - pid: codexPid - ) + case .promptSubmit: + let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(preferred: mapped?.workspaceId, fallback: workspaceArg, client: client) + let pid = mapped?.pid ?? inferredCodexAgentPID() + if !sessionId.isEmpty { + try? store.upsert(sessionId: sessionId, workspaceId: workspaceId, surfaceId: mapped?.surfaceId ?? (surfaceArg ?? ""), cwd: input.cwd ?? mapped?.cwd, pid: pid) } - if let codexPid { - _ = try? sendV1Command( - "set_agent_pid \(agentPIDKey) \(codexPid) --tab=\(workspaceId)", - client: client - ) + if let pid { + _ = try? sendV1Command("set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)", client: client) } _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) - try setCodexStatus( - client: client, - workspaceId: workspaceId, - value: "Running", - icon: "bolt.fill", - color: "#4C8DFF" - ) - print("{}") + _ = try sendV1Command("set_status \(def.statusKey) Running --icon=bolt.fill --color=#4C8DFF --tab=\(workspaceId)", client: client) - case "stop": - telemetry.breadcrumb("codex-hook.stop") + case .stop: do { - let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( - preferred: mappedSession?.workspaceId, - fallback: workspaceArg, - client: client - ) - let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( - preferred: mappedSession?.surfaceId, - fallback: surfaceArg, - workspaceId: workspaceId, - client: client - ) - let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) - - // Build completion notification from Codex stop payload - let lastMessage = parsedInput.object?["last_assistant_message"] as? String - ?? parsedInput.object?["lastAssistantMessage"] as? String - let cwd = parsedInput.cwd ?? mappedSession?.cwd - let codexPid = mappedSession?.pid ?? inferredCodexAgentPID() + let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(preferred: mapped?.workspaceId, fallback: workspaceArg, client: client) + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(preferred: mapped?.surfaceId, fallback: surfaceArg, workspaceId: workspaceId, client: client) + let pid = mapped?.pid ?? inferredCodexAgentPID() + + let lastMsg = input.object?["last_assistant_message"] as? String + ?? input.object?["lastAssistantMessage"] as? String + let cwd = input.cwd ?? mapped?.cwd let projectName: String? = { guard let cwd, !cwd.isEmpty else { return nil } return URL(fileURLWithPath: NSString(string: cwd).expandingTildeInPath).lastPathComponent }() - if let sessionId = parsedInput.sessionId { - try? sessionStore.upsert( - sessionId: sessionId, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: cwd, - pid: codexPid, - lastSubtitle: "Completed", - lastBody: lastMessage.map { truncate($0, maxLength: 200) } - ) + if !sessionId.isEmpty { + try? store.upsert(sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: cwd, pid: pid, + lastSubtitle: "Completed", lastBody: lastMsg.map { truncate($0, maxLength: 200) }) } - if let codexPid { - _ = try? sendV1Command( - "set_agent_pid \(agentPIDKey) \(codexPid) --tab=\(workspaceId)", - client: client - ) + if let pid { + _ = try? sendV1Command("set_agent_pid \(pidKey) \(pid) --tab=\(workspaceId)", client: client) } - // Send completion notification var subtitle = "Completed" - if let projectName, !projectName.isEmpty { - subtitle = "Completed in \(projectName)" - } + if let projectName, !projectName.isEmpty { subtitle = "Completed in \(projectName)" } let body = sanitizeNotificationField( - lastMessage.map { truncate(normalizedSingleLine($0), maxLength: 200) } - ?? "Codex session completed" + lastMsg.map { truncate(normalizedSingleLine($0), maxLength: 200) } + ?? "\(def.displayName) session completed" ) - let payload = "Codex|\(sanitizeNotificationField(subtitle))|\(body)" + let payload = "\(def.displayName)|\(sanitizeNotificationField(subtitle))|\(body)" _ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) - - try? setCodexStatus( - client: client, - workspaceId: workspaceId, - value: "Idle", - icon: "pause.circle.fill", - color: "#8E8E93" - ) - print("{}") + _ = try? sendV1Command("set_status \(def.statusKey) Idle --icon=pause.circle.fill --color=#8E8E93 --tab=\(workspaceId)", client: client) } catch { if shouldIgnoreClaudeHookTeardownError(error) { - telemetry.breadcrumb("codex-hook.stop.ignored", data: ["error": String(describing: error)]) - print("{}") - return + telemetry.breadcrumb("\(def.name)-hook.stop.ignored", data: ["error": String(describing: error)]) + } else { + throw error } - throw error } - case "help", "--help", "-h": - print("cmux codex-hook <session-start|prompt-submit|stop> [--workspace <id>] [--surface <id>]") + case .sessionEnd: + if let mapped = try? store.consume(sessionId: sessionId, workspaceId: nil, surfaceId: nil) { + _ = try? sendV1Command("clear_status \(def.statusKey) --tab=\(mapped.workspaceId)", client: client) + _ = try? sendV1Command("clear_agent_pid \(pidKey) --tab=\(mapped.workspaceId)", client: client) + } - default: - throw CLIError(message: "Unknown codex-hook subcommand: \(subcommand)") + case .noop: + break } - } - private func setCodexStatus( - client: SocketClient, - workspaceId: String, - value: String, - icon: String, - color: String - ) throws { - let cmd = "set_status codex \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)" - _ = try client.send(command: cmd) + print("{}") } - private func codexAgentPIDKey(sessionId: String?) -> String { - guard let sessionId = sessionId?.trimmingCharacters(in: .whitespacesAndNewlines), - !sessionId.isEmpty else { - return "codex" - } - return "codex.\(sessionId)" + // MARK: Convenience wrappers + + private func runCursorInstallHooks() throws { try installAgentHooks(Self.agentDef(named: "cursor")!) } + private func runCursorUninstallHooks() throws { try uninstallAgentHooks(Self.agentDef(named: "cursor")!) } + private func runCursorHook(commandArgs: [String], client: SocketClient, telemetry: CLISocketSentryTelemetry) throws { + try runGenericAgentHook(def: Self.agentDef(named: "cursor")!, commandArgs: commandArgs, client: client, telemetry: telemetry) } - private func inferredCodexAgentPID() -> Int? { - var candidate = getppid() - var remainingWrapperSkips = 8 + private func runGeminiInstallHooks() throws { try installAgentHooks(Self.agentDef(named: "gemini")!) } + private func runGeminiUninstallHooks() throws { try uninstallAgentHooks(Self.agentDef(named: "gemini")!) } + private func runGeminiHook(commandArgs: [String], client: SocketClient, telemetry: CLISocketSentryTelemetry) throws { + try runGenericAgentHook(def: Self.agentDef(named: "gemini")!, commandArgs: commandArgs, client: client, telemetry: telemetry) + } - while candidate > 1, remainingWrapperSkips > 0 { - guard let processName = processName(for: candidate) else { break } - if !codexHookWrapperProcessNames.contains(processName) { - break - } - let next = parentPID(of: candidate) - guard next > 1, next != candidate else { break } - candidate = next - remainingWrapperSkips -= 1 - } + // MARK: - Unified setup-hooks - return candidate > 1 ? Int(candidate) : nil - } + private func runSetupHooks(uninstall: Bool = false) throws { + let args = ProcessInfo.processInfo.arguments + let agentFilter = optionValue(args, name: "--agent") + let isUninstall = uninstall || args.contains("--uninstall") + let fm = FileManager.default + let verb = isUninstall ? "uninstalling" : "installing" - private func parentPID(of pid: pid_t) -> pid_t { - var info = kinfo_proc() - var size = MemoryLayout<kinfo_proc>.size - var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] - guard sysctl(&mib, 4, &info, &size, nil, 0) == 0 else { - return -1 + print("cmux \(isUninstall ? "uninstall" : "setup")-hooks: \(verb) agent hooks") + if !isUninstall { + print(" (Claude Code hooks are injected automatically via the claude wrapper)") } - return info.kp_eproc.e_ppid - } + print("") - private func processName(for pid: pid_t) -> String? { - let process = Process() - let stdout = Pipe() - process.executableURL = URL(fileURLWithPath: "/bin/ps") - process.arguments = ["-p", String(pid), "-o", "comm="] - process.standardOutput = stdout - process.standardError = FileHandle.nullDevice + var count = 0 + var skipped = 0 - do { - try process.run() - } catch { - return nil + for def in Self.agentDefs { + if let filter = agentFilter, filter.lowercased() != def.name { continue } + let configDir = def.resolvedConfigDir() + if !fm.fileExists(atPath: configDir) { + print(" \(def.name): skipped (not found)") + skipped += 1 + continue + } + print(" \(def.name):") + if isUninstall { + try uninstallAgentHooks(def) + } else { + try installAgentHooks(def) + } + count += 1 + print("") } - process.waitUntilExit() - guard process.terminationStatus == 0 else { return nil } - let data = stdout.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !output.isEmpty else { - return nil - } - return URL(fileURLWithPath: output).lastPathComponent.lowercased() + print("Done: \(count) \(isUninstall ? "uninstalled" : "installed"), \(skipped) skipped") } + private func versionSummary() -> String { let info = resolvedVersionInfo() let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7f9c74cc0b0f..cea9379bda32 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -53599,6 +53599,142 @@ } } }, + "settings.automation.cursor": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cursor Integration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cursor連携" + } + } + } + }, + "settings.automation.cursor.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hooks must be installed with `cmux cursor install-hooks`. They no-op outside cmux terminals." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フックは`cmux cursor install-hooks`でインストールする必要があります。cmuxターミナル外では動作しません。" + } + } + } + }, + "settings.automation.cursor.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cursor runs without cmux integration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cursorはcmux連携なしで動作します。" + } + } + } + }, + "settings.automation.cursor.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar shows Cursor agent status and notifications." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにCursorエージェントのステータスと通知が表示されます。" + } + } + } + }, + "settings.automation.gemini": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gemini CLI Integration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Gemini CLI連携" + } + } + } + }, + "settings.automation.gemini.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hooks must be installed with `cmux gemini install-hooks`. They no-op outside cmux terminals." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フックは`cmux gemini install-hooks`でインストールする必要があります。cmuxターミナル外では動作しません。" + } + } + } + }, + "settings.automation.gemini.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Gemini runs without cmux integration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GeminiはCmux連携なしで動作します。" + } + } + } + }, + "settings.automation.gemini.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar shows Gemini session status and notifications." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにGeminiセッションのステータスと通知が表示されます。" + } + } + } + }, "settings.automation.openAccess.dialog.cancel": { "extractionState": "manual", "localizations": { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 6f502b38c7dc..0c8f1a92c9d0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4126,6 +4126,12 @@ final class TerminalSurface: Identifiable, ObservableObject { if let customClaudePath = ClaudeCodeIntegrationSettings.customClaudePath() { setManagedEnvironmentValue("CMUX_CUSTOM_CLAUDE_PATH", customClaudePath) } + if !CursorIntegrationSettings.hooksEnabled() { + setManagedEnvironmentValue("CMUX_CURSOR_HOOKS_DISABLED", "1") + } + if !GeminiIntegrationSettings.hooksEnabled() { + setManagedEnvironmentValue("CMUX_GEMINI_HOOKS_DISABLED", "1") + } if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path { let currentPath = env["PATH"] diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 276754dbd1b0..3f88ea2ac045 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -72,6 +72,8 @@ final class CmuxSettingsFileStore { "automation.socketPassword", "automation.claudeCodeIntegration", "automation.claudeBinaryPath", + "automation.cursorIntegration", + "automation.geminiIntegration", "automation.portBase", "automation.portRange", "customCommands.trustedDirectories", @@ -696,6 +698,12 @@ final class CmuxSettingsFileStore { if let raw = jsonString(section["claudeBinaryPath"]) { snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.customClaudePathKey] = .string(raw) } + if let value = jsonBool(section["cursorIntegration"]) { + snapshot.managedUserDefaults[CursorIntegrationSettings.hooksEnabledKey] = .bool(value) + } + if let value = jsonBool(section["geminiIntegration"]) { + snapshot.managedUserDefaults[GeminiIntegrationSettings.hooksEnabledKey] = .bool(value) + } if let value = jsonInt(section["portBase"]) { guard value > 0 else { logInvalid("automation.portBase", sourcePath: sourcePath) @@ -1376,6 +1384,8 @@ final class CmuxSettingsFileStore { "socketPassword": "", "claudeCodeIntegration": ClaudeCodeIntegrationSettings.defaultHooksEnabled, "claudeBinaryPath": "", + "cursorIntegration": CursorIntegrationSettings.defaultHooksEnabled, + "geminiIntegration": GeminiIntegrationSettings.defaultHooksEnabled, "portBase": 9100, "portRange": 10, ], diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 8ca1b26cfd58..bdfc11798e6e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3817,6 +3817,30 @@ enum ClaudeCodeIntegrationSettings { } } +enum CursorIntegrationSettings { + static let hooksEnabledKey = "cursorHooksEnabled" + static let defaultHooksEnabled = true + + static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: hooksEnabledKey) == nil { + return defaultHooksEnabled + } + return defaults.bool(forKey: hooksEnabledKey) + } +} + +enum GeminiIntegrationSettings { + static let hooksEnabledKey = "geminiHooksEnabled" + static let defaultHooksEnabled = true + + static func hooksEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: hooksEnabledKey) == nil { + return defaultHooksEnabled + } + return defaults.bool(forKey: hooksEnabledKey) + } +} + enum WelcomeSettings { static let shownKey = "cmuxWelcomeShown" } @@ -4063,6 +4087,10 @@ struct SettingsView: View { private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @AppStorage(ClaudeCodeIntegrationSettings.customClaudePathKey) private var customClaudePath = "" + @AppStorage(CursorIntegrationSettings.hooksEnabledKey) + private var cursorHooksEnabled = CursorIntegrationSettings.defaultHooksEnabled + @AppStorage(GeminiIntegrationSettings.hooksEnabledKey) + private var geminiHooksEnabled = GeminiIntegrationSettings.defaultHooksEnabled @AppStorage(TelemetrySettings.sendAnonymousTelemetryKey) private var sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry @AppStorage(PreferredEditorSettings.key) private var preferredEditorCommand = "" @@ -5499,6 +5527,44 @@ struct SettingsView: View { } } + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.cursorIntegration"), + String(localized: "settings.automation.cursor", defaultValue: "Cursor Integration"), + subtitle: cursorHooksEnabled + ? String(localized: "settings.automation.cursor.subtitleOn", defaultValue: "Sidebar shows Cursor agent status and notifications.") + : String(localized: "settings.automation.cursor.subtitleOff", defaultValue: "Cursor runs without cmux integration.") + ) { + Toggle("", isOn: $cursorHooksEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsCursorHooksToggle") + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.automation.cursor.note", defaultValue: "Hooks must be installed with `cmux cursor install-hooks`. They no-op outside cmux terminals.")) + } + + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.geminiIntegration"), + String(localized: "settings.automation.gemini", defaultValue: "Gemini CLI Integration"), + subtitle: geminiHooksEnabled + ? String(localized: "settings.automation.gemini.subtitleOn", defaultValue: "Sidebar shows Gemini session status and notifications.") + : String(localized: "settings.automation.gemini.subtitleOff", defaultValue: "Gemini runs without cmux integration.") + ) { + Toggle("", isOn: $geminiHooksEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsGeminiHooksToggle") + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.automation.gemini.note", defaultValue: "Hooks must be installed with `cmux gemini install-hooks`. They no-op outside cmux terminals.")) + } + SettingsCard { SettingsCardRow(configurationReview: .json("automation.portBase"), String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), controlWidth: pickerColumnWidth) { TextField("", value: $cmuxPortBase, format: .number) @@ -6109,6 +6175,8 @@ struct SettingsView: View { socketControlMode = SocketControlSettings.defaultMode.rawValue claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled customClaudePath = "" + cursorHooksEnabled = CursorIntegrationSettings.defaultHooksEnabled + geminiHooksEnabled = GeminiIntegrationSettings.defaultHooksEnabled sendAnonymousTelemetry = TelemetrySettings.defaultSendAnonymousTelemetry preferredEditorCommand = "" browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue From 8510503d641e15dabab0e18ecae856cb1fe10890 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 8 Apr 2026 18:43:25 -0700 Subject: [PATCH 61/89] Fix minimal-mode traffic-light inset on new workspace --- Sources/TabManager.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 9e9a94378d9a..dc57ca829fad 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1903,6 +1903,20 @@ class TabManager: ObservableObject { ) } + private func applyCreationChromeInheritance( + to newWorkspace: Workspace, + from sourceWorkspace: Workspace? + ) { + guard let sourceWorkspace else { return } + // Sidebar-toggle relayout updates the live Bonsplit leading inset so minimal-mode + // workspaces reserve traffic-light space. New workspaces need that same inset + // copied immediately because creation itself does not trigger the resync path. + let inheritedLeadingInset = sourceWorkspace.bonsplitController.configuration.appearance.tabBarLeadingInset + if newWorkspace.bonsplitController.configuration.appearance.tabBarLeadingInset != inheritedLeadingInset { + newWorkspace.bonsplitController.configuration.appearance.tabBarLeadingInset = inheritedLeadingInset + } + } + /// Test seam for mutating live workspace state after the creation snapshot is captured. func didCaptureWorkspaceCreationSnapshot() {} @@ -1985,6 +1999,10 @@ class TabManager: ObservableObject { initialTerminalCommand: initialTerminalCommand, initialTerminalEnvironment: initialTerminalEnvironment ) + applyCreationChromeInheritance( + to: newWorkspace, + from: sourceWorkspace ?? capturedTabs.first + ) newWorkspace.owningTabManager = self if title != nil { newWorkspace.setCustomTitle(title) From 9b4e447e7786b84e7129777efe234b6260f5e64b Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Thu, 9 Apr 2026 17:28:27 -0700 Subject: [PATCH 62/89] Fix Raycast paste fallback regression --- Sources/GhosttyTerminalView.swift | 4 ++++ Sources/TerminalImageTransfer.swift | 5 +++++ cmuxTests/TerminalAndGhosttyTests.swift | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0c8f1a92c9d0..503f2af9ae5f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -160,6 +160,10 @@ enum GhosttyPasteboardHelper { return hasPasteableContents(in: pasteboard) } + static func fallbackPlainTextContents(from pasteboard: NSPasteboard) -> String? { + plainTextContents(from: pasteboard) + } + static func writeString(_ string: String, to location: ghostty_clipboard_e) { guard let pasteboard = pasteboard(for: location) else { return } pasteboard.clearContents() diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift index ce8473046e24..0486bddb5f49 100644 --- a/Sources/TerminalImageTransfer.swift +++ b/Sources/TerminalImageTransfer.swift @@ -265,6 +265,11 @@ enum TerminalImageTransferPlanner { return .fileURLs([imageURL]) } + // Clipboard managers can advertise unusable image types alongside valid text. + if let string = GhosttyPasteboardHelper.fallbackPlainTextContents(from: pasteboard), !string.isEmpty { + return .insertText(string) + } + if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty { return .insertText(escapeForShell(rawURL)) } diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index bc3836b7ff60..de8841a6389c 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -430,6 +430,24 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertEqual(targetResolutionCount, 0) } + func testPastePlanFallsBackToAlternatePlainTextWhenImageTypeIsUnusable() { + let pasteboard = NSPasteboard(name: .init("cmux-test-raycast-fallback-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString( + "hello from Raycast", + forType: NSPasteboard.PasteboardType(UTType.plainText.identifier) + ) + pasteboard.setData(Data("not a real tiff".utf8), forType: .tiff) + + let plan = TerminalImageTransferPlanner.plan( + pasteboard: pasteboard, + mode: .paste, + target: .local + ) + + XCTAssertEqual(plan, .insertText("hello from Raycast")) + } + func testLazyPastePlanResolvesTargetForFileURLPaste() throws { let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-image-\(UUID().uuidString).png") try make1x1PNG(color: .systemTeal).write(to: fileURL) From f8dfdefa8620cfa5b9deefffeef509f5a590ce55 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <lawrencecchen@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:43:45 -0700 Subject: [PATCH 63/89] Fix command-hold shortcut hints and keep sidebar text stable --- Sources/ContentView.swift | 19 ++---------- .../ShortcutAndCommandPaletteTests.swift | 31 +++++++++++++++++++ vendor/bonsplit | 2 +- 3 files changed, 34 insertions(+), 18 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index da5491e65839..742a334fd52c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10154,8 +10154,6 @@ enum ShortcutHintModifierPolicy { for modifierFlags: NSEvent.ModifierFlags, defaults: UserDefaults = .standard ) -> Bool { - let shortcut = KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) - guard !shortcut.hasChord else { return false } let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) guard normalized == [.command] else { @@ -12378,18 +12376,8 @@ enum SidebarTrailingAccessoryWidthPolicy { static let closeButtonWidth: CGFloat = 16 static func width( - canCloseWorkspace: Bool, - showsWorkspaceShortcutHint: Bool, - workspaceShortcutLabel: String?, - debugXOffset: Double + canCloseWorkspace: Bool ) -> CGFloat { - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - return SidebarWorkspaceShortcutHintMetrics.slotWidth( - label: workspaceShortcutLabel, - debugXOffset: debugXOffset - ) - } - return canCloseWorkspace ? closeButtonWidth : 0 } } @@ -12601,10 +12589,7 @@ private struct TabItemView: View, Equatable { private var trailingAccessoryWidth: CGFloat { SidebarTrailingAccessoryWidthPolicy.width( - canCloseWorkspace: canCloseWorkspace, - showsWorkspaceShortcutHint: showsWorkspaceShortcutHint, - workspaceShortcutLabel: workspaceShortcutLabel, - debugXOffset: sidebarShortcutHintXOffset + canCloseWorkspace: canCloseWorkspace ) } diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 567d0c87e634..2ee209e5309d 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -797,6 +797,37 @@ final class ShortcutHintModifierPolicyTests: XCTestCase { } } + func testShortcutHintStaysCommandOnlyWhenWorkspaceShortcutUsesChord() { + let action = KeyboardShortcutSettings.Action.selectWorkspaceByNumber + let originalShortcut = KeyboardShortcutSettings.shortcut(for: action) + defer { + KeyboardShortcutSettings.setShortcut(originalShortcut, for: action) + } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "1", + command: false, + shift: false, + option: false, + control: true, + chordKey: "2", + chordCommand: true, + chordShift: false, + chordOption: false, + chordControl: false + ), + for: action + ) + + withDefaultsSuite { defaults in + defaults.set(true, forKey: ShortcutHintDebugSettings.showHintsOnCommandHoldKey) + + XCTAssertTrue(ShortcutHintModifierPolicy.shouldShowHints(for: [.command], defaults: defaults)) + XCTAssertFalse(ShortcutHintModifierPolicy.shouldShowHints(for: [.control], defaults: defaults)) + } + } + func testShortcutHintUsesIntentionalHoldDelay() { XCTAssertEqual(ShortcutHintModifierPolicy.intentionalHoldDelay, 0.30, accuracy: 0.001) } diff --git a/vendor/bonsplit b/vendor/bonsplit index 098d9fa00e2b..7cd3b5e038e2 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 098d9fa00e2b1d4712f1a46b818ee7d53d4aa31f +Subproject commit 7cd3b5e038e28264c6efc9492d17126e29c8765d From 85dfc26558dd17d4c49991192147e9a5f0298890 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Thu, 9 Apr 2026 17:48:55 -0700 Subject: [PATCH 64/89] Fix browser paste-as-plain-text shortcut --- Sources/Panels/CmuxWebView.swift | 141 +++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 9693e3727ab2..e9ef7eb5b9cb 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -149,6 +149,7 @@ final class CmuxWebView: WKWebView { } private static var contextMenuFallbackKey: UInt8 = 0 + private static let pasteAsPlainTextKeyCode: UInt16 = 9 var onContextMenuDownloadStateChanged: ((Bool) -> Void)? /// Called when "Open Link in New Tab" context menu is selected. @@ -218,6 +219,130 @@ final class CmuxWebView: WKWebView { return body() } + private static func isPasteAsPlainTextCommandEquivalent(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + return event.keyCode == pasteAsPlainTextKeyCode && normalizedFlags == [.command, .shift] + } + + private static func javaScriptLiteral(_ value: String) -> String? { + guard let data = try? JSONSerialization.data(withJSONObject: [value]), + let arrayLiteral = String(data: data, encoding: .utf8), + arrayLiteral.count >= 2 else { + return nil + } + return String(arrayLiteral.dropFirst().dropLast()) + } + + @discardableResult + private func performPasteAsPlainTextFromPasteboard() -> Bool { + guard let text = NSPasteboard.general.string(forType: .string), + let textLiteral = Self.javaScriptLiteral(text) else { + return false + } + + let script = """ + (() => { + const text = \(textLiteral); + const active = document.activeElement; + const makeInputEvent = () => { + try { + return new InputEvent('input', { + bubbles: true, + inputType: 'insertFromPaste', + data: text + }); + } catch (_) { + return new Event('input', { bubbles: true }); + } + }; + + const editableTarget = (() => { + if (!active) return null; + if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) { + if (active.disabled || active.readOnly) return null; + return active; + } + if (active.isContentEditable) return active; + return active.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null; + })(); + + if (!editableTarget) return false; + + if (editableTarget instanceof HTMLInputElement || editableTarget instanceof HTMLTextAreaElement) { + const hasSelection = + typeof editableTarget.selectionStart === 'number' + && typeof editableTarget.selectionEnd === 'number'; + const start = hasSelection ? editableTarget.selectionStart : editableTarget.value.length; + const end = hasSelection ? editableTarget.selectionEnd : start; + + if (typeof editableTarget.setRangeText === 'function') { + editableTarget.setRangeText(text, start, end, 'end'); + } else { + const value = editableTarget.value; + editableTarget.value = value.slice(0, start) + text + value.slice(end); + if (typeof editableTarget.setSelectionRange === 'function') { + const caret = start + text.length; + editableTarget.setSelectionRange(caret, caret); + } + } + + editableTarget.dispatchEvent(makeInputEvent()); + return true; + } + + if (typeof document.execCommand === 'function') { + try { + if (document.execCommand('insertText', false, text)) { + return true; + } + } catch (_) {} + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return false; + + const range = selection.getRangeAt(0); + range.deleteContents(); + const node = document.createTextNode(text); + range.insertNode(node); + range.setStartAfter(node); + range.setEndAfter(node); + selection.removeAllRanges(); + selection.addRange(range); + editableTarget.dispatchEvent(makeInputEvent()); + return true; + })(); + """ + + evaluateJavaScript(script) { result, error in +#if DEBUG + let inserted = (result as? Bool) ?? false + dlog( + "browser.pasteAsPlainText " + + "web=\(ObjectIdentifier(self)) inserted=\(inserted ? 1 : 0) " + + "error=\(error?.localizedDescription ?? "nil")" + ) +#else + _ = result + _ = error +#endif + } + return true + } + + @IBAction func pasteAsPlainText(_ sender: Any?) { + _ = sender + _ = performPasteAsPlainTextFromPasteboard() + } + + override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + if item.action == #selector(pasteAsPlainText(_:)) { + return NSPasteboard.general.string(forType: .string) != nil + } + return super.validateUserInterfaceItem(item) + } + override func performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let typingTimingStart = CmuxTypingTiming.start() @@ -248,6 +373,14 @@ final class CmuxWebView: WKWebView { return result } + if Self.isPasteAsPlainTextCommandEquivalent(event), + performPasteAsPlainTextFromPasteboard() { +#if DEBUG + handled = true +#endif + return true + } + var replayedBrowserFindShortcutIntoWebContent = false if shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( event, @@ -316,6 +449,14 @@ final class CmuxWebView: WKWebView { ) } #endif + if Self.isPasteAsPlainTextCommandEquivalent(event), + performPasteAsPlainTextFromPasteboard() { +#if DEBUG + route = "pasteAsPlainText" +#endif + return + } + // Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent. // Route them through the same app-level shortcut handler as a fallback. if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), From 95973a9329de08a26676636b8c3b1cb2e401bb08 Mon Sep 17 00:00:00 2001 From: Michael Jauk <michael.jauk@dectria.com> Date: Fri, 10 Apr 2026 10:10:36 +0200 Subject: [PATCH 65/89] =?UTF-8?q?Fix=20shell=20integration=20not=20injecte?= =?UTF-8?q?d=20=E2=80=94=20Ghostty=20ZDOTDIR=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty's shell-integration=detect (the default) overrides ZDOTDIR inside ghostty_surface_new to point to its own integration directory, clobbering the ZDOTDIR that cmux sets for its own .zshenv bootstrap. This prevents cmux's _cmux_precmd hook from being registered, so report_pwd never fires and the sidebar cwd goes stale. Force shell-integration=none in the Ghostty config so Ghostty does not touch ZDOTDIR. cmux already handles loading Ghostty's shell integration manually via CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION in the .zshenv bootstrap. Save the user's original shell-integration preference before the override so that CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION is still gated correctly. Closes #2594 --- Sources/GhosttyTerminalView.swift | 42 +++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0c8f1a92c9d0..8261f0213f63 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1204,6 +1204,7 @@ class GhosttyApp { private(set) var defaultBackgroundColor: NSColor = .windowBackgroundColor private(set) var defaultBackgroundOpacity: Double = 1.0 private(set) var usesHostLayerBackground = true + private(set) var userGhosttyShellIntegrationMode: String = "detect" private static func resolveBackgroundLogURL( environment: [String: String] = ProcessInfo.processInfo.environment ) -> URL { @@ -1624,6 +1625,12 @@ class GhosttyApp { prefix: "cmux-layer-bg", logLabel: "layer background (fallback)" ) + loadInlineGhosttyConfig( + "shell-integration = none", + into: fallbackConfig, + prefix: "cmux-shell-integration-override", + logLabel: "shell integration override (fallback)" + ) usesHostLayerBackground = true ghostty_config_finalize(fallbackConfig) updateDefaultBackground(from: fallbackConfig, source: "initialize.fallbackConfig") @@ -1740,6 +1747,26 @@ class GhosttyApp { logLabel: "layer background" ) } + // Save the user's preference before we force it to none. + userGhosttyShellIntegrationMode = "detect" + do { + var value: UnsafePointer<Int8>? + let key = "shell-integration" + if ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), + let value { + userGhosttyShellIntegrationMode = String(cString: value) + } + } + + // Prevent Ghostty from overriding ZDOTDIR — cmux handles shell + // integration itself via the .zshenv bootstrap (#2594). + loadInlineGhosttyConfig( + "shell-integration = none", + into: config, + prefix: "cmux-shell-integration-override", + logLabel: "shell integration override" + ) + ghostty_config_finalize(config) } @@ -2492,17 +2519,6 @@ class GhosttyApp { return enabled } - fileprivate func shellIntegrationMode() -> String { - guard let config else { return "detect" } - var value: UnsafePointer<Int8>? - let key = "shell-integration" - guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))), - let value else { - return "detect" - } - return String(cString: value) - } - private func bellFeatures() -> CUnsignedInt { guard let config else { return 0 } var features: CUnsignedInt = 0 @@ -4157,7 +4173,7 @@ final class TerminalSurface: Identifiable, ObservableObject { ?? "/bin/zsh" let shellName = URL(fileURLWithPath: shell).lastPathComponent if shellName == "zsh" { - if GhosttyApp.shared.shellIntegrationMode() != "none" { + if GhosttyApp.shared.userGhosttyShellIntegrationMode != "none" { setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION", "1") } let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil) @@ -4181,7 +4197,7 @@ final class TerminalSurface: Identifiable, ObservableObject { setManagedEnvironmentValue("ZDOTDIR", integrationDir) } else if shellName == "bash" { - if GhosttyApp.shared.shellIntegrationMode() != "none" { + if GhosttyApp.shared.userGhosttyShellIntegrationMode != "none" { setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_BASH_INTEGRATION", "1") } // macOS ships /bin/bash 3.2, where Ghostty's automatic bash From eabe056a1a98bf93e4c224ff6aa27ce80e2e410e Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 02:26:31 -0700 Subject: [PATCH 66/89] Finish Raycast paste regression fix --- Sources/AppDelegate.swift | 38 +++++++++++++--- Sources/GhosttyTerminalView.swift | 44 ++++++++++++++----- Sources/TerminalImageTransfer.swift | 7 ++- .../ShortcutAndCommandPaletteTests.swift | 42 ++++++++++++++++++ 4 files changed, 111 insertions(+), 20 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eba3d12d8239..25b6871f5c33 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1830,6 +1830,18 @@ func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow } +func shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: NSEvent.ModifierFlags, + responderIsWindow: Bool, + responderHasViableKeyRoutingOwner: Bool +) -> Bool { + let normalizedFlags = flags.intersection(.deviceIndependentFlagsMask) + guard normalizedFlags.contains(.command) else { return false } + // Command shortcuts should only repair focus when the responder has fallen + // back to the window itself or another non-routable owner. + return responderIsWindow || !responderHasViableKeyRoutingOwner +} + func shouldRouteTerminalFontZoomShortcutToGhostty( firstResponderIsGhostty: Bool, flags: NSEvent.ModifierFlags, @@ -5705,7 +5717,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { guard event.type == .keyDown else { return } let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard !normalizedFlags.contains(.command) else { return } guard isMainTerminalWindow(window) else { return } guard window.attachedSheet == nil else { return } guard !isCommandPaletteEffectivelyVisible(in: window) else { return } @@ -5715,19 +5726,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let terminalPanel = workspace.terminalPanel(for: panelId) else { return } - guard responderNeedsFocusedTerminalKeyRepair( - window.firstResponder, - in: window, - hostedView: terminalPanel.hostedView - ) else { return } + let firstResponder = window.firstResponder + let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false + let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: normalizedFlags, + responderIsWindow: firstResponder is NSWindow, + responderHasViableKeyRoutingOwner: responderHasViableOwner + ) + if normalizedFlags.contains(.command) { + guard commandEquivalentNeedsRepair else { return } + } else { + guard responderNeedsFocusedTerminalKeyRepair( + firstResponder, + in: window, + hostedView: terminalPanel.hostedView + ) else { return } + } #if DEBUG - let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let before = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() + let mode = normalizedFlags.contains(.command) ? "command" : "plain" dlog( "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + "workspace=\(String(workspace.id.uuidString.prefix(5))) " + "panel=\(String(panelId.uuidString.prefix(5))) " + + "mode=\(mode) " + "target=\(target == .findField ? "searchField" : "surface") " + "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" ) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 503f2af9ae5f..ee8311278c15 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -101,6 +101,12 @@ private func cmuxScalarHex(_ value: String?) -> String { #endif enum GhosttyPasteboardHelper { + enum ImageFileMaterializationResult { + case saved(URL) + case noDecodableImagePayload + case rejectedImagePayload + } + private static let selectionPasteboard = NSPasteboard( name: NSPasteboard.Name("com.mitchellh.ghostty.selection") ) @@ -368,15 +374,12 @@ enum GhosttyPasteboardHelper { return normalized.isEmpty } - /// When the clipboard contains only image data (or rich text that resolves to - /// an attachment-only image), saves it as a temporary image file and returns the - /// file URL. Returns nil if the clipboard contains text or no image. - static func saveImageFileURLIfNeeded( - from pasteboard: NSPasteboard = .general, - assumeNoText: Bool = false - ) -> URL? { - if !assumeNoText && stringContents(from: pasteboard) != nil { return nil } - + /// Attempts to materialize a decodable pasteboard image into a temporary file. + /// `rejectedImagePayload` means a real image was found but could not be used, + /// so callers should not fall back to auxiliary plain text or URLs. + static func materializeImageFileURLIfNeeded( + from pasteboard: NSPasteboard = .general + ) -> ImageFileMaterializationResult { let imageData: Data let fileExtension: String if let directImage = directImageRepresentation(in: pasteboard) { @@ -390,7 +393,9 @@ enum GhosttyPasteboardHelper { let image = NSImage(pasteboard: pasteboard), let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData), - let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } + let pngData = bitmap.representation(using: .png, properties: [:]) else { + return .noDecodableImagePayload + } imageData = pngData fileExtension = "png" } @@ -400,7 +405,7 @@ enum GhosttyPasteboardHelper { #if DEBUG dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)") #endif - return nil + return .rejectedImagePayload } let formatter = DateFormatter() @@ -416,10 +421,25 @@ enum GhosttyPasteboardHelper { #if DEBUG dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") #endif - return nil + return .rejectedImagePayload } registerOwnedTemporaryImageFile(fileURL) + return .saved(fileURL) + } + + /// When the clipboard contains only image data (or rich text that resolves to + /// an attachment-only image), saves it as a temporary image file and returns the + /// file URL. Returns nil if the clipboard contains text or no image. + static func saveImageFileURLIfNeeded( + from pasteboard: NSPasteboard = .general, + assumeNoText: Bool = false + ) -> URL? { + if !assumeNoText && stringContents(from: pasteboard) != nil { return nil } + + guard case .saved(let fileURL) = materializeImageFileURLIfNeeded(from: pasteboard) else { + return nil + } return fileURL } diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift index 0486bddb5f49..0fb28e9f9123 100644 --- a/Sources/TerminalImageTransfer.swift +++ b/Sources/TerminalImageTransfer.swift @@ -261,8 +261,13 @@ enum TerminalImageTransferPlanner { return .insertText(string) } - if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) { + switch GhosttyPasteboardHelper.materializeImageFileURLIfNeeded(from: pasteboard) { + case .saved(let imageURL): return .fileURLs([imageURL]) + case .rejectedImagePayload: + return .reject + case .noDecodableImagePayload: + break } // Clipboard managers can advertise unusable image types alongside valid text. diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 567d0c87e634..4224ac5f359d 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -59,6 +59,48 @@ final class SplitShortcutTransientFocusGuardTests: XCTestCase { } } +final class CommandEquivalentTransientFocusRepairTests: XCTestCase { + func testRepairsCommandEquivalentWhenFirstResponderFallsBackToWindow() { + XCTAssertTrue( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: true, + responderHasViableKeyRoutingOwner: false + ) + ) + } + + func testRepairsCommandEquivalentWhenResponderHasNoViableOwner() { + XCTAssertTrue( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: false, + responderHasViableKeyRoutingOwner: false + ) + ) + } + + func testDoesNotRepairCommandEquivalentWhenResponderHasViableOwner() { + XCTAssertFalse( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: false, + responderHasViableKeyRoutingOwner: true + ) + ) + } + + func testIgnoresNonCommandEvents() { + XCTAssertFalse( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [], + responderIsWindow: true, + responderHasViableKeyRoutingOwner: false + ) + ) + } +} + final class ReactGrabShortcutRouteTests: XCTestCase { func testFocusedBrowserRoutesDirectlyWithoutPasteback() { let browserId = UUID() From 27f61e5192bf4055869b6e4c657a269438729ce1 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 02:27:00 -0700 Subject: [PATCH 67/89] Fix browser arrow key routing --- Sources/AppDelegate.swift | 67 +++++++++++++++++++++++++-- Sources/Panels/BrowserPanelView.swift | 2 + 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eba3d12d8239..f64f4026acae 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2253,6 +2253,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var browserOmnibarRepeatDelta: Int = 0 private var browserAddressBarFocusObserver: NSObjectProtocol? private var browserAddressBarBlurObserver: NSObjectProtocol? + private var browserWebViewFirstResponderObserver: NSObjectProtocol? private let updateController = UpdateController() private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) private let windowDecorationsController = WindowDecorationsController() @@ -10881,14 +10882,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. - if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) { + if let delta = commandOmnibarSelectionDelta( + hasFocusedAddressBar: hasFocusedAddressBarInShortcutContext, + flags: flags, + chars: chars + ) { dispatchBrowserOmnibarSelectionMove(delta: delta) startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: event.keyCode, delta: delta) return true } if let delta = browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, + hasFocusedAddressBar: hasFocusedAddressBarInShortcutContext, flags: event.modifierFlags, keyCode: event.keyCode ) { @@ -10905,7 +10910,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser // address bar is focused. Without this, app-level Cmd+N can steal focus. - if shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { + if hasFocusedAddressBarInShortcutContext, + shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { return false } @@ -11666,6 +11672,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { guard let panelId = browserAddressBarFocusedPanelId else { return nil } + let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutResponder = shortcutWindow?.firstResponder + + guard isBrowserOmnibarResponder(shortcutResponder) else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=responder_not_omnibar responder=\(shortcutResponder.map { String(describing: type(of: $0)) } ?? "nil") " + + "event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { #if DEBUG @@ -11707,6 +11726,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return panelId } + private func isBrowserOmnibarResponder(_ responder: NSResponder?) -> Bool { + guard let ownerView = keyRoutingOwnerView(for: responder) else { return false } + + var current: NSView? = ownerView + while let candidate = current { + if candidate.identifier == browserOmnibarTextFieldIdentifier { + return true + } + current = candidate.superview + } + + return false + } + @discardableResult func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { focusBrowserAddressBar(panelId: panelId) @@ -11734,11 +11767,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func commandOmnibarSelectionDelta( + hasFocusedAddressBar: Bool, flags: NSEvent.ModifierFlags, chars: String ) -> Int? { browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, + hasFocusedAddressBar: hasFocusedAddressBar, flags: flags, chars: chars ) @@ -12651,7 +12685,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func installBrowserAddressBarFocusObservers() { - guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == nil else { return } + guard browserAddressBarFocusObserver == nil, + browserAddressBarBlurObserver == nil, + browserWebViewFirstResponderObserver == nil else { return } browserAddressBarFocusObserver = NotificationCenter.default.addObserver( forName: .browserDidFocusAddressBar, @@ -12681,6 +12717,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.stopBrowserOmnibarSelectionRepeat() #if DEBUG dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") +#endif + } + } + + browserWebViewFirstResponderObserver = NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard let webView = notification.object as? CmuxWebView, + let panel = self.browserPanelOwning(webView) else { return } + panel.endSuppressWebViewFocusForAddressBar() + if self.browserAddressBarFocusedPanelId != nil { + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog( + "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + + "reason=webViewFirstResponder" + ) #endif } } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 221962baca36..1a2358e84f29 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -5,6 +5,7 @@ import AppKit import ObjectiveC private var cmuxBrowserPanelNeedsRenderingStateReattachKey: UInt8 = 0 +let browserOmnibarTextFieldIdentifier = NSUserInterfaceItemIdentifier("cmux.browserOmnibarTextField") private func browserPanelViewObjectID(_ object: AnyObject?) -> String { guard let object else { return "nil" } @@ -3953,6 +3954,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { func makeNSView(context: Context) -> OmnibarNativeTextField { let field = OmnibarNativeTextField(frame: .zero) + field.identifier = browserOmnibarTextFieldIdentifier field.font = .systemFont(ofSize: 12) field.placeholderString = placeholder field.delegate = context.coordinator From 8e3c15f3000e781982891a1d3558818498149814 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 02:31:59 -0700 Subject: [PATCH 68/89] Route browser up/down arrows through keyDown --- Sources/AppDelegate.swift | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f64f4026acae..6569eff18fab 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1587,6 +1587,25 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown( return browserOmnibarShouldSubmitOnReturn(flags: flags) } +func shouldDispatchBrowserArrowViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsBrowser: Bool, + firstResponderHasMarkedText: Bool = false, + flags: NSEvent.ModifierFlags +) -> Bool { + guard firstResponderIsBrowser else { return false } + guard !firstResponderHasMarkedText else { return false } + guard keyCode == 125 || keyCode == 126 else { return false } + + // Keep this narrow to avoid stealing app/browser shortcuts that layer onto + // modified arrow keys. Plain up/down should always flow through keyDown so + // web content such as Google Docs receives the event directly. + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + return normalizedFlags.isEmpty +} + func shouldToggleMainWindowFullScreenForCommandControlFShortcut( flags: NSEvent.ModifierFlags, chars: String, @@ -13898,6 +13917,7 @@ private var cmuxFirstResponderGuardCurrentEventContext: NSEvent? private var cmuxFirstResponderGuardHitViewContext: NSView? private var cmuxFirstResponderGuardContextWindowNumber: Int? private var cmuxBrowserReturnForwardingDepth = 0 +private var cmuxBrowserArrowForwardingDepth = 0 private var cmuxWindowFirstResponderBypassDepth = 0 private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0 @@ -14288,6 +14308,32 @@ private extension NSWindow { return true } + // Some browser content (notably Google Docs) loses plain up/down when + // NSWindow.performKeyEquivalent claims the arrow before WebKit sees + // keyDown. Route those arrows directly to the first responder instead. + if shouldDispatchBrowserArrowViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsBrowser: firstResponderWebView != nil, + firstResponderHasMarkedText: firstResponderHasMarkedText, + flags: event.modifierFlags + ) { + // Match the Return/Enter forwarding guard: AppKit/WebKit can re-enter + // performKeyEquivalent while the synthesized keyDown is in flight. + if cmuxBrowserArrowForwardingDepth > 0 { +#if DEBUG + dlog(" → browser Up/Down reentry; using normal dispatch") +#endif + return false + } + cmuxBrowserArrowForwardingDepth += 1 + defer { cmuxBrowserArrowForwardingDepth = max(0, cmuxBrowserArrowForwardingDepth - 1) } +#if DEBUG + dlog(" → browser Up/Down routed to firstResponder.keyDown") +#endif + self.firstResponder?.keyDown(with: event) + return true + } + if let firstResponderWebView, shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( event, From 39b081aa870caab48877cdb0ff880e2455b00da8 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 02:38:06 -0700 Subject: [PATCH 69/89] Fix browser plain-text paste review issues --- Sources/Panels/BrowserPanel.swift | 9 ++ Sources/Panels/CmuxWebView.swift | 167 +++++++++++++++++++++++++++--- 2 files changed, 162 insertions(+), 14 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 64ec6cad492e..354036c2dea6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2548,6 +2548,15 @@ final class BrowserPanel: Panel, ObservableObject { forMainFrameOnly: true ) ) + // Keep a native cache of whether the focused page element can currently accept + // plain-text paste so Cmd+Shift+V is only consumed when the browser can use it. + configuration.userContentController.addUserScript( + WKUserScript( + source: CmuxWebView.pasteAsPlainTextFocusTrackingBootstrapScriptSource, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + ) } private func bindWebView(_ webView: CmuxWebView) { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index e9ef7eb5b9cb..8d76d7e6b7c6 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -117,6 +117,94 @@ final class CmuxWebView: WKWebView { private static var lastMiddleClickIntent: MiddleClickIntent? private static let middleClickIntentMaxAge: TimeInterval = 0.8 + private static let pasteAsPlainTextFocusMessageHandlerName = "cmuxPasteAsPlainTextFocus" + static let pasteAsPlainTextFocusTrackingBootstrapScriptSource = """ + (() => { + try { + if (window.__cmuxPasteAsPlainTextFocusTrackerInstalled) return true; + window.__cmuxPasteAsPlainTextFocusTrackerInstalled = true; + + const handler = (() => { + try { + return window.webkit?.messageHandlers?.\(pasteAsPlainTextFocusMessageHandlerName) ?? null; + } catch (_) { + return null; + } + })(); + + const canPasteAsPlainTextInto = (el) => { + if (!el) return false; + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + return !(el.disabled || el.readOnly); + } + if (el.isContentEditable) return true; + return !!(el.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null); + }; + + const publish = (canPaste) => { + window.__cmuxPasteAsPlainTextTargetAvailable = canPaste; + try { + handler?.postMessage({ canPaste }); + } catch (_) {} + }; + + const publishForElement = (el) => { + publish(canPasteAsPlainTextInto(el ?? document.activeElement)); + }; + + document.addEventListener("focusin", (ev) => { + publishForElement(ev && ev.target ? ev.target : document.activeElement); + }, true); + document.addEventListener("focusout", () => { + requestAnimationFrame(() => publishForElement(document.activeElement)); + }, true); + document.addEventListener("selectionchange", () => { + publishForElement(document.activeElement); + }, true); + document.addEventListener("input", () => { + publishForElement(document.activeElement); + }, true); + document.addEventListener("change", () => { + publishForElement(document.activeElement); + }, true); + document.addEventListener("mousedown", (ev) => { + const target = ev && ev.target ? ev.target : null; + if (!canPasteAsPlainTextInto(target)) { + publish(false); + } + }, true); + window.addEventListener("beforeunload", () => { + publish(false); + }, true); + + publishForElement(document.activeElement); + return true; + } catch (_) { + return false; + } + })(); + """ + + private final class PasteAsPlainTextFocusMessageHandler: NSObject, WKScriptMessageHandler { + weak var webView: CmuxWebView? + + init(webView: CmuxWebView) { + self.webView = webView + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard let body = message.body as? [String: Any], + let canPaste = body["canPaste"] as? Bool else { + return + } + Task { @MainActor [weak webView] in + webView?.updatePasteAsPlainTextTargetAvailable(canPaste) + } + } + } static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool { guard let webView = webView as? CmuxWebView else { return false } @@ -149,7 +237,7 @@ final class CmuxWebView: WKWebView { } private static var contextMenuFallbackKey: UInt8 = 0 - private static let pasteAsPlainTextKeyCode: UInt16 = 9 + private static let pasteAsPlainTextKeyCode: UInt16 = 9 // V key (hardware position, layout-independent) var onContextMenuDownloadStateChanged: ((Bool) -> Void)? /// Called when "Open Link in New Tab" context menu is selected. @@ -161,11 +249,46 @@ final class CmuxWebView: WKWebView { /// BrowserPanelView updates this as pane focus state changes. var allowsFirstResponderAcquisition: Bool = true private var pointerFocusAllowanceDepth: Int = 0 + private var pasteAsPlainTextTargetAvailable = false + private var pasteAsPlainTextFocusMessageHandler: PasteAsPlainTextFocusMessageHandler? var allowsFirstResponderAcquisitionEffective: Bool { allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0 } var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth } + override init(frame: NSRect, configuration: WKWebViewConfiguration) { + super.init(frame: frame, configuration: configuration) + installPasteAsPlainTextFocusTracking() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + installPasteAsPlainTextFocusTracking() + } + + deinit { + configuration.userContentController.removeScriptMessageHandler( + forName: Self.pasteAsPlainTextFocusMessageHandlerName + ) + } + + private func installPasteAsPlainTextFocusTracking() { + let handler = PasteAsPlainTextFocusMessageHandler(webView: self) + pasteAsPlainTextFocusMessageHandler = handler + configuration.userContentController.add(handler, name: Self.pasteAsPlainTextFocusMessageHandlerName) + } + + private func updatePasteAsPlainTextTargetAvailable(_ available: Bool) { + guard pasteAsPlainTextTargetAvailable != available else { return } + pasteAsPlainTextTargetAvailable = available +#if DEBUG + dlog( + "browser.pasteAsPlainText.target " + + "web=\(ObjectIdentifier(self)) available=\(available ? 1 : 0)" + ) +#endif + } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { PaneFirstClickFocusSettings.isEnabled() } @@ -226,6 +349,7 @@ final class CmuxWebView: WKWebView { } private static func javaScriptLiteral(_ value: String) -> String? { + // Serialize as a JSON array, then strip the outer brackets to get a quoted JS string literal. guard let data = try? JSONSerialization.data(withJSONObject: [value]), let arrayLiteral = String(data: data, encoding: .utf8), arrayLiteral.count >= 2 else { @@ -236,7 +360,8 @@ final class CmuxWebView: WKWebView { @discardableResult private func performPasteAsPlainTextFromPasteboard() -> Bool { - guard let text = NSPasteboard.general.string(forType: .string), + guard pasteAsPlainTextTargetAvailable, + let text = NSPasteboard.general.string(forType: .string), let textLiteral = Self.javaScriptLiteral(text) else { return false } @@ -270,21 +395,32 @@ final class CmuxWebView: WKWebView { if (!editableTarget) return false; if (editableTarget instanceof HTMLInputElement || editableTarget instanceof HTMLTextAreaElement) { - const hasSelection = - typeof editableTarget.selectionStart === 'number' - && typeof editableTarget.selectionEnd === 'number'; + const hasSelection = (() => { + try { + return typeof editableTarget.selectionStart === 'number' + && typeof editableTarget.selectionEnd === 'number'; + } catch (_) { + return false; + } + })(); const start = hasSelection ? editableTarget.selectionStart : editableTarget.value.length; const end = hasSelection ? editableTarget.selectionEnd : start; - if (typeof editableTarget.setRangeText === 'function') { - editableTarget.setRangeText(text, start, end, 'end'); - } else { - const value = editableTarget.value; - editableTarget.value = value.slice(0, start) + text + value.slice(end); - if (typeof editableTarget.setSelectionRange === 'function') { - const caret = start + text.length; - editableTarget.setSelectionRange(caret, caret); + try { + if (hasSelection && typeof editableTarget.setRangeText === 'function') { + editableTarget.setRangeText(text, start, end, 'end'); + } else if (hasSelection) { + const value = editableTarget.value; + editableTarget.value = value.slice(0, start) + text + value.slice(end); + if (typeof editableTarget.setSelectionRange === 'function') { + const caret = start + text.length; + editableTarget.setSelectionRange(caret, caret); + } + } else { + editableTarget.value = text; } + } catch (_) { + return false; } editableTarget.dispatchEvent(makeInputEvent()); @@ -328,6 +464,8 @@ final class CmuxWebView: WKWebView { _ = error #endif } + // The editable-target cache is kept in sync from the page, so consuming the + // shortcut here avoids double-routing while JS performs the paste asynchronously. return true } @@ -338,7 +476,8 @@ final class CmuxWebView: WKWebView { override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { if item.action == #selector(pasteAsPlainText(_:)) { - return NSPasteboard.general.string(forType: .string) != nil + return pasteAsPlainTextTargetAvailable + && NSPasteboard.general.string(forType: .string) != nil } return super.validateUserInterfaceItem(item) } From 14a5b1dc98fa3385092b128b04936a0f9e43e9a2 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 02:38:17 -0700 Subject: [PATCH 70/89] Repair command-key focus drift in same window --- Sources/AppDelegate.swift | 41 +++++++++++++------ .../ShortcutAndCommandPaletteTests.swift | 23 +++++++++-- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 25b6871f5c33..6b735e515c69 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1830,16 +1830,30 @@ func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow } +func focusedTerminalKeyRepairNeeded( + responderIsWindow: Bool, + responderHasViableKeyRoutingOwner: Bool, + responderMatchesPreferredKeyboardFocus: Bool +) -> Bool { + responderIsWindow || !responderHasViableKeyRoutingOwner || !responderMatchesPreferredKeyboardFocus +} + func shouldRepairFocusedTerminalCommandEquivalentInputs( flags: NSEvent.ModifierFlags, responderIsWindow: Bool, - responderHasViableKeyRoutingOwner: Bool + responderHasViableKeyRoutingOwner: Bool, + responderMatchesPreferredKeyboardFocus: Bool ) -> Bool { let normalizedFlags = flags.intersection(.deviceIndependentFlagsMask) guard normalizedFlags.contains(.command) else { return false } - // Command shortcuts should only repair focus when the responder has fallen - // back to the window itself or another non-routable owner. - return responderIsWindow || !responderHasViableKeyRoutingOwner + // Command shortcuts should repair for the same failure states as plain + // keyDown routing, including same-window SwiftUI responder drift where a + // visible owner still exists but no longer matches the focused terminal. + return focusedTerminalKeyRepairNeeded( + responderIsWindow: responderIsWindow, + responderHasViableKeyRoutingOwner: responderHasViableKeyRoutingOwner, + responderMatchesPreferredKeyboardFocus: responderMatchesPreferredKeyboardFocus + ) } func shouldRouteTerminalFontZoomShortcutToGhostty( @@ -5701,14 +5715,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent hostedView: GhosttySurfaceScrollView ) -> Bool { guard let responder else { return true } - if responder is NSWindow { return true } - guard responderHasViableKeyRoutingOwner(responder, in: window) else { - return true - } - if hostedView.responderMatchesPreferredKeyboardFocus(responder) { - return false - } - return true + return focusedTerminalKeyRepairNeeded( + responderIsWindow: responder is NSWindow, + responderHasViableKeyRoutingOwner: responderHasViableKeyRoutingOwner(responder, in: window), + responderMatchesPreferredKeyboardFocus: hostedView.responderMatchesPreferredKeyboardFocus(responder) + ) } func repairFocusedTerminalKeyboardRoutingIfNeeded( @@ -5728,10 +5739,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let firstResponder = window.firstResponder let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false + let responderMatchesPreferredFocus = firstResponder.map { + terminalPanel.hostedView.responderMatchesPreferredKeyboardFocus($0) + } ?? false let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( flags: normalizedFlags, responderIsWindow: firstResponder is NSWindow, - responderHasViableKeyRoutingOwner: responderHasViableOwner + responderHasViableKeyRoutingOwner: responderHasViableOwner, + responderMatchesPreferredKeyboardFocus: responderMatchesPreferredFocus ) if normalizedFlags.contains(.command) { guard commandEquivalentNeedsRepair else { return } diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 4224ac5f359d..90c9fcd2c311 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -65,7 +65,8 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: true, - responderHasViableKeyRoutingOwner: false + responderHasViableKeyRoutingOwner: false, + responderMatchesPreferredKeyboardFocus: false ) ) } @@ -75,7 +76,19 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: false + responderHasViableKeyRoutingOwner: false, + responderMatchesPreferredKeyboardFocus: false + ) + ) + } + + func testRepairsCommandEquivalentWhenResponderDriftsWithinSameWindow() { + XCTAssertTrue( + shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: [.command], + responderIsWindow: false, + responderHasViableKeyRoutingOwner: true, + responderMatchesPreferredKeyboardFocus: false ) ) } @@ -85,7 +98,8 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: true + responderHasViableKeyRoutingOwner: true, + responderMatchesPreferredKeyboardFocus: true ) ) } @@ -95,7 +109,8 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [], responderIsWindow: true, - responderHasViableKeyRoutingOwner: false + responderHasViableKeyRoutingOwner: false, + responderMatchesPreferredKeyboardFocus: false ) ) } From 5a30a91ae1dfa547c60728b8bf375b262ca6f220 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 02:39:28 -0700 Subject: [PATCH 71/89] Share browser JS literal helper --- Sources/AppDelegate.swift | 23 ++++++++++++----------- Sources/Panels/CmuxWebView.swift | 12 +----------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eba3d12d8239..604b6a0c89b1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,6 +9,17 @@ import Combine import ObjectiveC.runtime import Darwin +func cmuxJavaScriptStringLiteral(_ value: String?) -> String? { + guard let value else { return nil } + // Serialize as a JSON array, then strip the outer brackets to get a quoted JS string literal. + guard let data = try? JSONSerialization.data(withJSONObject: [value]), + let arrayLiteral = String(data: data, encoding: .utf8), + arrayLiteral.count >= 2 else { + return nil + } + return String(arrayLiteral.dropFirst().dropLast()) +} + final class MainWindowHostingView<Content: View>: NSHostingView<Content> { private let zeroSafeAreaLayoutGuide = NSLayoutGuide() @@ -9041,16 +9052,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Task { @MainActor in evaluate() } } - private func javaScriptLiteral(_ value: String?) -> String { - guard let value else { return "null" } - guard let data = try? JSONSerialization.data(withJSONObject: [value]), - let arrayLiteral = String(data: data, encoding: .utf8), - arrayLiteral.count >= 2 else { - return "null" - } - return String(arrayLiteral.dropFirst().dropLast()) - } - private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel) { let script = """ (() => { @@ -9344,7 +9345,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent awaitingInputId: String? = nil, completion: @escaping ([String: String]) -> Void ) { - let expectedInputIdLiteral = javaScriptLiteral(awaitingInputId) + let expectedInputIdLiteral = cmuxJavaScriptStringLiteral(awaitingInputId) ?? "null" let script = """ (() => { const expectedInputId = \(expectedInputIdLiteral); diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 8d76d7e6b7c6..c1cdd9472bdc 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -348,21 +348,11 @@ final class CmuxWebView: WKWebView { return event.keyCode == pasteAsPlainTextKeyCode && normalizedFlags == [.command, .shift] } - private static func javaScriptLiteral(_ value: String) -> String? { - // Serialize as a JSON array, then strip the outer brackets to get a quoted JS string literal. - guard let data = try? JSONSerialization.data(withJSONObject: [value]), - let arrayLiteral = String(data: data, encoding: .utf8), - arrayLiteral.count >= 2 else { - return nil - } - return String(arrayLiteral.dropFirst().dropLast()) - } - @discardableResult private func performPasteAsPlainTextFromPasteboard() -> Bool { guard pasteAsPlainTextTargetAvailable, let text = NSPasteboard.general.string(forType: .string), - let textLiteral = Self.javaScriptLiteral(text) else { + let textLiteral = cmuxJavaScriptStringLiteral(text) else { return false } From 221529a86080c7e90b7afaac6411e3276c1811bd Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 02:51:22 -0700 Subject: [PATCH 72/89] Fix browser paste focus handler issues --- Sources/Panels/CmuxWebView.swift | 301 ++++++++++++++++++++++--------- 1 file changed, 220 insertions(+), 81 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index c1cdd9472bdc..9d09296d2808 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -118,6 +118,7 @@ final class CmuxWebView: WKWebView { private static var lastMiddleClickIntent: MiddleClickIntent? private static let middleClickIntentMaxAge: TimeInterval = 0.8 private static let pasteAsPlainTextFocusMessageHandlerName = "cmuxPasteAsPlainTextFocus" + private static var pasteAsPlainTextFocusHandlerInstalledKey: UInt8 = 0 static let pasteAsPlainTextFocusTrackingBootstrapScriptSource = """ (() => { try { @@ -132,13 +133,68 @@ final class CmuxWebView: WKWebView { } })(); - const canPasteAsPlainTextInto = (el) => { - if (!el) return false; - if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { - return !(el.disabled || el.readOnly); + const supportedTextInputTypes = new Set(["", "text", "search", "tel", "url", "email", "password"]); + + const deepestActiveElement = (root) => { + let active = root?.activeElement ?? null; + while (active) { + const shadowActive = active.shadowRoot?.activeElement ?? null; + if (shadowActive && shadowActive !== active) { + active = shadowActive; + continue; + } + + const tagName = typeof active.tagName === "string" ? active.tagName.toUpperCase() : ""; + if (tagName === "IFRAME") { + try { + const frameActive = active.contentDocument?.activeElement ?? null; + if (frameActive && frameActive !== active) { + active = frameActive; + continue; + } + } catch (_) {} + } + + break; + } + return active; + }; + + const isPlainTextTextControl = (el) => { + if (!el || el.disabled || el.readOnly) return false; + + const tagName = typeof el.tagName === "string" ? el.tagName.toUpperCase() : ""; + if (tagName === "TEXTAREA") return true; + if (tagName !== "INPUT") return false; + + const type = typeof el.type === "string" ? el.type.toLowerCase() : "text"; + return supportedTextInputTypes.has(type); + }; + + const resolvedCandidateElement = (el) => { + if (!el) return deepestActiveElement(document); + + const shadowActive = el.shadowRoot?.activeElement ?? null; + if (shadowActive && shadowActive !== el) { + return deepestActiveElement(el.shadowRoot) ?? shadowActive; + } + + const tagName = typeof el.tagName === "string" ? el.tagName.toUpperCase() : ""; + if (tagName === "IFRAME") { + try { + return deepestActiveElement(el.contentDocument) ?? el; + } catch (_) {} } - if (el.isContentEditable) return true; - return !!(el.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null); + + return el; + }; + + const canPasteAsPlainTextInto = (el) => { + const candidate = resolvedCandidateElement(el); + if (!candidate) return false; + if (isPlainTextTextControl(candidate)) return true; + if (candidate.isContentEditable) return true; + return !!(candidate.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null); }; const publish = (canPaste) => { @@ -149,7 +205,7 @@ final class CmuxWebView: WKWebView { }; const publishForElement = (el) => { - publish(canPasteAsPlainTextInto(el ?? document.activeElement)); + publish(canPasteAsPlainTextInto(el)); }; document.addEventListener("focusin", (ev) => { @@ -186,16 +242,13 @@ final class CmuxWebView: WKWebView { """ private final class PasteAsPlainTextFocusMessageHandler: NSObject, WKScriptMessageHandler { - weak var webView: CmuxWebView? - - init(webView: CmuxWebView) { - self.webView = webView - } - func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { + guard let webView = message.webView as? CmuxWebView else { + return + } guard let body = message.body as? [String: Any], let canPaste = body["canPaste"] as? Bool else { return @@ -206,6 +259,8 @@ final class CmuxWebView: WKWebView { } } + private static let sharedPasteAsPlainTextFocusMessageHandler = PasteAsPlainTextFocusMessageHandler() + static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool { guard let webView = webView as? CmuxWebView else { return false } guard let intent = lastMiddleClickIntent else { return false } @@ -250,7 +305,6 @@ final class CmuxWebView: WKWebView { var allowsFirstResponderAcquisition: Bool = true private var pointerFocusAllowanceDepth: Int = 0 private var pasteAsPlainTextTargetAvailable = false - private var pasteAsPlainTextFocusMessageHandler: PasteAsPlainTextFocusMessageHandler? var allowsFirstResponderAcquisitionEffective: Bool { allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0 } @@ -266,16 +320,25 @@ final class CmuxWebView: WKWebView { installPasteAsPlainTextFocusTracking() } - deinit { - configuration.userContentController.removeScriptMessageHandler( - forName: Self.pasteAsPlainTextFocusMessageHandlerName - ) - } - private func installPasteAsPlainTextFocusTracking() { - let handler = PasteAsPlainTextFocusMessageHandler(webView: self) - pasteAsPlainTextFocusMessageHandler = handler - configuration.userContentController.add(handler, name: Self.pasteAsPlainTextFocusMessageHandlerName) + let userContentController = configuration.userContentController + if objc_getAssociatedObject( + userContentController, + &Self.pasteAsPlainTextFocusHandlerInstalledKey + ) != nil { + return + } + + userContentController.add( + Self.sharedPasteAsPlainTextFocusMessageHandler, + name: Self.pasteAsPlainTextFocusMessageHandlerName + ) + objc_setAssociatedObject( + userContentController, + &Self.pasteAsPlainTextFocusHandlerInstalledKey, + NSNumber(value: true), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) } private func updatePasteAsPlainTextTargetAvailable(_ available: Bool) { @@ -348,6 +411,35 @@ final class CmuxWebView: WKWebView { return event.keyCode == pasteAsPlainTextKeyCode && normalizedFlags == [.command, .shift] } + private func evaluateJavaScriptSynchronously( + _ script: String, + timeout: TimeInterval = 0.25 + ) -> (completed: Bool, result: Any?, error: Error?) { + var completed = false + var result: Any? + var error: Error? + + evaluateJavaScript(script) { jsResult, jsError in + result = jsResult + error = jsError + completed = true + } + + let deadline = ProcessInfo.processInfo.systemUptime + timeout + while !completed { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { break } + + let sliceEnd = Date(timeIntervalSinceNow: min(remaining, 0.01)) + _ = RunLoop.current.run(mode: .default, before: sliceEnd) + if !completed { + _ = RunLoop.current.run(mode: .eventTracking, before: sliceEnd) + } + } + + return (completed, result, error) + } + @discardableResult private func performPasteAsPlainTextFromPasteboard() -> Bool { guard pasteAsPlainTextTargetAvailable, @@ -359,104 +451,149 @@ final class CmuxWebView: WKWebView { let script = """ (() => { const text = \(textLiteral); - const active = document.activeElement; - const makeInputEvent = () => { - try { - return new InputEvent('input', { - bubbles: true, - inputType: 'insertFromPaste', - data: text - }); - } catch (_) { - return new Event('input', { bubbles: true }); + const supportedTextInputTypes = new Set(['', 'text', 'search', 'tel', 'url', 'email', 'password']); + const deepestActiveElement = (root) => { + let active = root?.activeElement ?? null; + while (active) { + const shadowActive = active.shadowRoot?.activeElement ?? null; + if (shadowActive && shadowActive !== active) { + active = shadowActive; + continue; + } + + const tagName = typeof active.tagName === 'string' ? active.tagName.toUpperCase() : ''; + if (tagName === 'IFRAME') { + try { + const frameActive = active.contentDocument?.activeElement ?? null; + if (frameActive && frameActive !== active) { + active = frameActive; + continue; + } + } catch (_) {} + } + + break; } + return active; }; + const isPlainTextTextControl = (el) => { + if (!el || el.disabled || el.readOnly) return false; + const tagName = typeof el.tagName === 'string' ? el.tagName.toUpperCase() : ''; + if (tagName === 'TEXTAREA') return true; + if (tagName !== 'INPUT') return false; + + const type = typeof el.type === 'string' ? el.type.toLowerCase() : 'text'; + return supportedTextInputTypes.has(type); + }; + const active = deepestActiveElement(document); const editableTarget = (() => { if (!active) return null; - if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) { - if (active.disabled || active.readOnly) return null; - return active; - } + if (isPlainTextTextControl(active)) return active; if (active.isContentEditable) return active; return active.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null; })(); - if (!editableTarget) return false; - if (editableTarget instanceof HTMLInputElement || editableTarget instanceof HTMLTextAreaElement) { - const hasSelection = (() => { - try { - return typeof editableTarget.selectionStart === 'number' - && typeof editableTarget.selectionEnd === 'number'; - } catch (_) { - return false; + const ownerDocument = editableTarget.ownerDocument ?? document; + const ownerWindow = ownerDocument.defaultView ?? window; + const makeInputEvent = () => { + try { + const InputEventCtor = ownerWindow.InputEvent; + if (typeof InputEventCtor === 'function') { + return new InputEventCtor('input', { + bubbles: true, + inputType: 'insertFromPaste', + data: text + }); } - })(); - const start = hasSelection ? editableTarget.selectionStart : editableTarget.value.length; - const end = hasSelection ? editableTarget.selectionEnd : start; + } catch (_) {} try { - if (hasSelection && typeof editableTarget.setRangeText === 'function') { + const EventCtor = ownerWindow.Event; + if (typeof EventCtor === 'function') { + return new EventCtor('input', { bubbles: true }); + } + } catch (_) {} + + return null; + }; + + if (isPlainTextTextControl(editableTarget)) { + const value = typeof editableTarget.value === 'string' ? editableTarget.value : ''; + const start = typeof editableTarget.selectionStart === 'number' + ? editableTarget.selectionStart + : value.length; + const end = typeof editableTarget.selectionEnd === 'number' + ? editableTarget.selectionEnd + : start; + + try { + if (typeof editableTarget.setRangeText === 'function') { editableTarget.setRangeText(text, start, end, 'end'); - } else if (hasSelection) { - const value = editableTarget.value; + } else { editableTarget.value = value.slice(0, start) + text + value.slice(end); if (typeof editableTarget.setSelectionRange === 'function') { const caret = start + text.length; editableTarget.setSelectionRange(caret, caret); } - } else { - editableTarget.value = text; } } catch (_) { return false; } - editableTarget.dispatchEvent(makeInputEvent()); + const inputEvent = makeInputEvent(); + if (inputEvent) { + editableTarget.dispatchEvent(inputEvent); + } return true; } - if (typeof document.execCommand === 'function') { + if (typeof ownerDocument.execCommand === 'function') { try { - if (document.execCommand('insertText', false, text)) { + if (ownerDocument.execCommand('insertText', false, text)) { return true; } } catch (_) {} } - const selection = window.getSelection(); + const selection = typeof ownerWindow.getSelection === 'function' + ? ownerWindow.getSelection() + : null; if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); range.deleteContents(); - const node = document.createTextNode(text); + const node = ownerDocument.createTextNode(text); range.insertNode(node); range.setStartAfter(node); range.setEndAfter(node); selection.removeAllRanges(); selection.addRange(range); - editableTarget.dispatchEvent(makeInputEvent()); + + const inputEvent = makeInputEvent(); + if (inputEvent) { + editableTarget.dispatchEvent(inputEvent); + } return true; })(); """ - evaluateJavaScript(script) { result, error in + // NSResponder key-equivalent handling is synchronous. Wait briefly so failed page-side + // insertion can fall through to WebKit/page handlers instead of being consumed here. + let evaluation = evaluateJavaScriptSynchronously(script) + let inserted = evaluation.completed && ((evaluation.result as? Bool) ?? false) #if DEBUG - let inserted = (result as? Bool) ?? false - dlog( - "browser.pasteAsPlainText " + - "web=\(ObjectIdentifier(self)) inserted=\(inserted ? 1 : 0) " + - "error=\(error?.localizedDescription ?? "nil")" - ) -#else - _ = result - _ = error + let errorDescription = evaluation.completed + ? (evaluation.error?.localizedDescription ?? "nil") + : "timeout" + dlog( + "browser.pasteAsPlainText " + + "web=\(ObjectIdentifier(self)) inserted=\(inserted ? 1 : 0) " + + "error=\(errorDescription)" + ) #endif - } - // The editable-target cache is kept in sync from the page, so consuming the - // shortcut here avoids double-routing while JS performs the paste asynchronously. - return true + return inserted } @IBAction func pasteAsPlainText(_ sender: Any?) { @@ -502,12 +639,12 @@ final class CmuxWebView: WKWebView { return result } - if Self.isPasteAsPlainTextCommandEquivalent(event), - performPasteAsPlainTextFromPasteboard() { + if Self.isPasteAsPlainTextCommandEquivalent(event) { + let result = performPasteAsPlainTextFromPasteboard() || super.performKeyEquivalent(with: event) #if DEBUG - handled = true + handled = result #endif - return true + return result } var replayedBrowserFindShortcutIntoWebContent = false @@ -578,12 +715,14 @@ final class CmuxWebView: WKWebView { ) } #endif - if Self.isPasteAsPlainTextCommandEquivalent(event), - performPasteAsPlainTextFromPasteboard() { + if Self.isPasteAsPlainTextCommandEquivalent(event) { + let didPaste = performPasteAsPlainTextFromPasteboard() #if DEBUG - route = "pasteAsPlainText" + route = didPaste ? "pasteAsPlainText" : "super" #endif - return + if didPaste { + return + } } // Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent. From 52c83fdc3e27d89da8a881c49dbc18ba1f50c07c Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 02:51:31 -0700 Subject: [PATCH 73/89] Refresh bonsplit pointer and ghostty fork notes --- docs/ghostty-fork.md | 24 +++++++++++++++++++----- vendor/bonsplit | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 7b9a7f3d58a9..ed0d91d277a0 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,10 +12,9 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -Fork rebased onto upstream `main` at `3509ccf78` (`v1.3.1-457-g3509ccf78`) on March 30, 2026. -Current cmux pinned fork head: `ae3cc5d29` (`v1.3.1-473-gae3cc5d29`). -Fork `main` keeps this pin reachable via merge commit `5c781d710` -(`Retain layer-background pin ancestry on main`). +Fork main has advanced beyond the March 30, 2026 rebase onto upstream `main` +at `3509ccf78` (`v1.3.1-457-g3509ccf78`). +Current cmux pinned fork head: `3b684a085` (`tip-1717-g3b684a085`). ### 1) macOS display link restart on display changes @@ -112,7 +111,17 @@ tend to conflict together during rebases. - Allows the host app to provide the terminal background via `CALayer.backgroundColor` for instant coverage during view resizes, avoiding alpha double-stacking. - Replays the layer-background restore on top of the refreshed Ghostty base so cmux keeps the resize-coverage fix after the upstream sync. -The fork branch HEAD is now the section 7 layer-background restore commit. +### 8) TerminalStream kitty graphics APC handling + +- Commit: `a8e92c9c5` (terminal: add APC handler to stream_terminal) +- Files: + - `src/terminal/stream_terminal.zig` +- Summary: + - Wires `.apc_start`, `.apc_put`, and `.apc_end` through the shared APC parser in `TerminalStream`. + - Restores kitty graphics execution and APC OK/error replies for the non-termio stream path used by cmux/libghostty integrations. + +Fork main now carries the section 8 APC handling fix plus later upstream merges; +the current cmux pin is the head listed above. ## Upstreamed fork changes @@ -170,4 +179,9 @@ These files change frequently upstream; be careful when rebasing the fork: - Keep DECSET 1004 enablement side-effect free. xterm-compatible focus reporting should only emit `CSI I` / `CSI O` on actual focus transitions, not immediately when the mode is enabled. +- `src/terminal/stream_terminal.zig` + - Keep the APC handler wired into `.apc_start`, `.apc_put`, `.apc_end`, and preserve the + `apcEnd()` response path so kitty graphics still reach `Terminal.kittyGraphics()` and reply via + `write_pty`. + If you resolve a conflict, update this doc with what changed. diff --git a/vendor/bonsplit b/vendor/bonsplit index 7cd3b5e038e2..2979ef647d4a 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 7cd3b5e038e28264c6efc9492d17126e29c8765d +Subproject commit 2979ef647d4a6ab0bf7f93ce82c59cbf0c70e1e1 From 9b258884a991ce8fd0512c15c54ebdf4b595bc26 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 02:51:42 -0700 Subject: [PATCH 74/89] Narrow command focus repair for menu shortcuts --- Sources/AppDelegate.swift | 31 +++++++------------ .../ShortcutAndCommandPaletteTests.swift | 19 +++++------- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6b735e515c69..9a04b0674420 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1841,19 +1841,14 @@ func focusedTerminalKeyRepairNeeded( func shouldRepairFocusedTerminalCommandEquivalentInputs( flags: NSEvent.ModifierFlags, responderIsWindow: Bool, - responderHasViableKeyRoutingOwner: Bool, - responderMatchesPreferredKeyboardFocus: Bool + responderHasViableKeyRoutingOwner: Bool ) -> Bool { let normalizedFlags = flags.intersection(.deviceIndependentFlagsMask) guard normalizedFlags.contains(.command) else { return false } - // Command shortcuts should repair for the same failure states as plain - // keyDown routing, including same-window SwiftUI responder drift where a - // visible owner still exists but no longer matches the focused terminal. - return focusedTerminalKeyRepairNeeded( - responderIsWindow: responderIsWindow, - responderHasViableKeyRoutingOwner: responderHasViableKeyRoutingOwner, - responderMatchesPreferredKeyboardFocus: responderMatchesPreferredKeyboardFocus - ) + // Command shortcuts should only repair genuinely broken responder states. + // If another live view already owns first responder, let menu routing use + // that responder rather than retargeting to the selected terminal pane. + return responderIsWindow || !responderHasViableKeyRoutingOwner } func shouldRouteTerminalFontZoomShortcutToGhostty( @@ -5738,17 +5733,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } let firstResponder = window.firstResponder - let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false - let responderMatchesPreferredFocus = firstResponder.map { - terminalPanel.hostedView.responderMatchesPreferredKeyboardFocus($0) - } ?? false - let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( - flags: normalizedFlags, - responderIsWindow: firstResponder is NSWindow, - responderHasViableKeyRoutingOwner: responderHasViableOwner, - responderMatchesPreferredKeyboardFocus: responderMatchesPreferredFocus - ) if normalizedFlags.contains(.command) { + let responderHasViableOwner = firstResponder.map { responderHasViableKeyRoutingOwner($0, in: window) } ?? false + let commandEquivalentNeedsRepair = shouldRepairFocusedTerminalCommandEquivalentInputs( + flags: normalizedFlags, + responderIsWindow: firstResponder is NSWindow, + responderHasViableKeyRoutingOwner: responderHasViableOwner + ) guard commandEquivalentNeedsRepair else { return } } else { guard responderNeedsFocusedTerminalKeyRepair( diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 90c9fcd2c311..3869639f6509 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -65,8 +65,7 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: true, - responderHasViableKeyRoutingOwner: false, - responderMatchesPreferredKeyboardFocus: false + responderHasViableKeyRoutingOwner: false ) ) } @@ -76,19 +75,17 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: false, - responderMatchesPreferredKeyboardFocus: false + responderHasViableKeyRoutingOwner: false ) ) } - func testRepairsCommandEquivalentWhenResponderDriftsWithinSameWindow() { - XCTAssertTrue( + func testDoesNotRepairCommandEquivalentWhenLiveResponderDiffersFromSelectedPane() { + XCTAssertFalse( shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: true, - responderMatchesPreferredKeyboardFocus: false + responderHasViableKeyRoutingOwner: true ) ) } @@ -98,8 +95,7 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [.command], responderIsWindow: false, - responderHasViableKeyRoutingOwner: true, - responderMatchesPreferredKeyboardFocus: true + responderHasViableKeyRoutingOwner: true ) ) } @@ -109,8 +105,7 @@ final class CommandEquivalentTransientFocusRepairTests: XCTestCase { shouldRepairFocusedTerminalCommandEquivalentInputs( flags: [], responderIsWindow: true, - responderHasViableKeyRoutingOwner: false, - responderMatchesPreferredKeyboardFocus: false + responderHasViableKeyRoutingOwner: false ) ) } From 224c01d15e0cfa240e3a70d5896df573fa7a3677 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 03:09:12 -0700 Subject: [PATCH 75/89] Fix browser address bar shortcut focus routing --- Sources/AppDelegate.swift | 47 +++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6569eff18fab..bd84a0332094 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11691,45 +11691,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { guard let panelId = browserAddressBarFocusedPanelId else { return nil } - let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow - let shortcutResponder = shortcutWindow?.firstResponder - guard isBrowserOmnibarResponder(shortcutResponder) else { + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { #if DEBUG dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=responder_not_omnibar responder=\(shortcutResponder.map { String(describing: type(of: $0)) } ?? "nil") " + - "event=\(NSWindow.keyDescription(event))" + "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" ) #endif return nil } - guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { + guard let workspace = context.tabManager.selectedWorkspace else { #if DEBUG dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" ) #endif return nil } - guard let workspace = context.tabManager.selectedWorkspace else { + guard let panel = workspace.browserPanel(for: panelId) else { #if DEBUG dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + + "event=\(NSWindow.keyDescription(event))" ) #endif return nil } - guard workspace.browserPanel(for: panelId) != nil else { + let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutResponder = shortcutWindow?.firstResponder + let responderIsOmnibar = isBrowserOmnibarResponder(shortcutResponder) + let pendingAddressBarHandoff = + panel.pendingAddressBarFocusRequestId != nil && + workspace.focusedPanelId == panelId + + guard responderIsOmnibar || pendingAddressBarHandoff else { #if DEBUG + let focusedPanel = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + + "accepted=0 reason=responder_not_omnibar responder=\(shortcutResponder.map { String(describing: type(of: $0)) } ?? "nil") " + + "pending=\(panel.pendingAddressBarFocusRequestId != nil ? 1 : 0) focusedPanel=\(focusedPanel) " + "event=\(NSWindow.keyDescription(event))" ) #endif @@ -11737,9 +11744,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #if DEBUG + let acceptReason = responderIsOmnibar ? "omnibar_responder" : "pending_handoff" dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" + "accepted=1 reason=\(acceptReason) workspace=\(workspace.id.uuidString.prefix(5)) " + + "event=\(NSWindow.keyDescription(event))" ) #endif return panelId @@ -12748,8 +12757,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return } guard let webView = notification.object as? CmuxWebView, let panel = self.browserPanelOwning(webView) else { return } + guard panel.pendingAddressBarFocusRequestId == nil || + self.browserAddressBarFocusedPanelId != panel.id else { +#if DEBUG + dlog( + "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + + "reason=skip_pending_focus_handoff" + ) +#endif + return + } panel.endSuppressWebViewFocusForAddressBar() - if self.browserAddressBarFocusedPanelId != nil { + if self.browserAddressBarFocusedPanelId == panel.id { self.browserAddressBarFocusedPanelId = nil self.stopBrowserOmnibarSelectionRepeat() #if DEBUG @@ -12763,7 +12782,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func browserPanel(for panelId: UUID) -> BrowserPanel? { - return tabManager?.selectedWorkspace?.browserPanel(for: panelId) + return workspaceContainingPanel(panelId: panelId)?.workspace.browserPanel(for: panelId) } fileprivate func browserFindBarIsVisible(for webView: CmuxWebView) -> Bool { From 918de063351663f8bcdce7283928e08da6d8dedb Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 03:12:20 -0700 Subject: [PATCH 76/89] Address browser paste review follow-ups --- Sources/Panels/CmuxWebView.swift | 278 +++++++++++++++++-------------- 1 file changed, 155 insertions(+), 123 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 9d09296d2808..e8f3e4591261 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -119,6 +119,88 @@ final class CmuxWebView: WKWebView { private static let middleClickIntentMaxAge: TimeInterval = 0.8 private static let pasteAsPlainTextFocusMessageHandlerName = "cmuxPasteAsPlainTextFocus" private static var pasteAsPlainTextFocusHandlerInstalledKey: UInt8 = 0 + private static let pasteAsPlainTextSharedHelpersScriptSource = """ + const __cmuxPasteAsPlainTextHelpers = (() => { + const existing = window.__cmuxPasteAsPlainTextHelpers; + if (existing) return existing; + + const supportedTextInputTypes = new Set(["", "text", "search", "tel", "url", "email", "password"]); + + const deepestActiveElement = (root) => { + let active = root?.activeElement ?? null; + while (active) { + const shadowActive = active.shadowRoot?.activeElement ?? null; + if (shadowActive && shadowActive !== active) { + active = shadowActive; + continue; + } + + const tagName = typeof active.tagName === "string" ? active.tagName.toUpperCase() : ""; + if (tagName === "IFRAME") { + try { + const frameActive = active.contentDocument?.activeElement ?? null; + if (frameActive && frameActive !== active) { + active = frameActive; + continue; + } + } catch (_) {} + } + + break; + } + return active; + }; + + const isPlainTextTextControl = (el) => { + if (!el || el.disabled || el.readOnly) return false; + + const tagName = typeof el.tagName === "string" ? el.tagName.toUpperCase() : ""; + if (tagName === "TEXTAREA") return true; + if (tagName !== "INPUT") return false; + + const type = typeof el.type === "string" ? el.type.toLowerCase() : "text"; + return supportedTextInputTypes.has(type); + }; + + const resolvedCandidateElement = (el) => { + if (!el) return deepestActiveElement(document); + + const shadowActive = el.shadowRoot?.activeElement ?? null; + if (shadowActive && shadowActive !== el) { + return deepestActiveElement(el.shadowRoot) ?? shadowActive; + } + + const tagName = typeof el.tagName === "string" ? el.tagName.toUpperCase() : ""; + if (tagName === "IFRAME") { + try { + return deepestActiveElement(el.contentDocument) ?? el; + } catch (_) {} + } + + return el; + }; + + const editableTarget = (el) => { + const candidate = resolvedCandidateElement(el); + if (!candidate) return null; + if (isPlainTextTextControl(candidate)) return candidate; + if (candidate.isContentEditable) return candidate; + return candidate.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null; + }; + + const helpers = { + deepestActiveElement, + isPlainTextTextControl, + resolvedCandidateElement, + editableTarget, + canPasteAsPlainTextInto(el) { + return !!editableTarget(el); + } + }; + window.__cmuxPasteAsPlainTextHelpers = helpers; + return helpers; + })(); + """ static let pasteAsPlainTextFocusTrackingBootstrapScriptSource = """ (() => { try { @@ -133,79 +215,25 @@ final class CmuxWebView: WKWebView { } })(); - const supportedTextInputTypes = new Set(["", "text", "search", "tel", "url", "email", "password"]); - - const deepestActiveElement = (root) => { - let active = root?.activeElement ?? null; - while (active) { - const shadowActive = active.shadowRoot?.activeElement ?? null; - if (shadowActive && shadowActive !== active) { - active = shadowActive; - continue; - } - - const tagName = typeof active.tagName === "string" ? active.tagName.toUpperCase() : ""; - if (tagName === "IFRAME") { - try { - const frameActive = active.contentDocument?.activeElement ?? null; - if (frameActive && frameActive !== active) { - active = frameActive; - continue; - } - } catch (_) {} - } - - break; - } - return active; - }; - - const isPlainTextTextControl = (el) => { - if (!el || el.disabled || el.readOnly) return false; - - const tagName = typeof el.tagName === "string" ? el.tagName.toUpperCase() : ""; - if (tagName === "TEXTAREA") return true; - if (tagName !== "INPUT") return false; - - const type = typeof el.type === "string" ? el.type.toLowerCase() : "text"; - return supportedTextInputTypes.has(type); - }; - - const resolvedCandidateElement = (el) => { - if (!el) return deepestActiveElement(document); + \(pasteAsPlainTextSharedHelpersScriptSource) - const shadowActive = el.shadowRoot?.activeElement ?? null; - if (shadowActive && shadowActive !== el) { - return deepestActiveElement(el.shadowRoot) ?? shadowActive; - } - - const tagName = typeof el.tagName === "string" ? el.tagName.toUpperCase() : ""; - if (tagName === "IFRAME") { - try { - return deepestActiveElement(el.contentDocument) ?? el; - } catch (_) {} - } - - return el; - }; - - const canPasteAsPlainTextInto = (el) => { - const candidate = resolvedCandidateElement(el); - if (!candidate) return false; - if (isPlainTextTextControl(candidate)) return true; - if (candidate.isContentEditable) return true; - return !!(candidate.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null); - }; + const publishState = { lastCanPaste: null }; const publish = (canPaste) => { + if (publishState.lastCanPaste === canPaste) return; + publishState.lastCanPaste = canPaste; window.__cmuxPasteAsPlainTextTargetAvailable = canPaste; try { handler?.postMessage({ canPaste }); } catch (_) {} }; + window.__cmuxCanPasteAsPlainTextIntoCurrentFocus = () => { + return __cmuxPasteAsPlainTextHelpers.canPasteAsPlainTextInto(document.activeElement); + }; + const publishForElement = (el) => { - publish(canPasteAsPlainTextInto(el)); + publish(__cmuxPasteAsPlainTextHelpers.canPasteAsPlainTextInto(el)); }; document.addEventListener("focusin", (ev) => { @@ -411,6 +439,19 @@ final class CmuxWebView: WKWebView { return event.keyCode == pasteAsPlainTextKeyCode && normalizedFlags == [.command, .shift] } + private func webKitPasteAsPlainTextFallback(_ sender: Any?) { + let selector = NSSelectorFromString("pasteAsPlainText:") + guard let method = class_getInstanceMethod(WKWebView.self, selector) else { + return + } + + typealias PasteAsPlainTextFn = @convention(c) (AnyObject, Selector, Any?) -> Void + let implementation = method_getImplementation(method) + unsafeBitCast(implementation, to: PasteAsPlainTextFn.self)(self, selector, sender) + } + + // Key-equivalent handling is synchronous, so this bounded preflight pumps the main run loop. + // Keep callers limited to fast, side-effect-free reads from page-owned state. private func evaluateJavaScriptSynchronously( _ script: String, timeout: TimeInterval = 0.25 @@ -440,59 +481,46 @@ final class CmuxWebView: WKWebView { return (completed, result, error) } + private func pageCanAcceptPlainTextPaste() -> Bool { + let script = """ + (() => { + try { + const fn = window.__cmuxCanPasteAsPlainTextIntoCurrentFocus; + return typeof fn === 'function' ? !!fn() : false; + } catch (_) { + return false; + } + })(); + """ + + let evaluation = evaluateJavaScriptSynchronously(script) + let canPaste = evaluation.completed && ((evaluation.result as? Bool) ?? false) +#if DEBUG + let errorDescription = evaluation.completed + ? (evaluation.error?.localizedDescription ?? "nil") + : "timeout" + dlog( + "browser.pasteAsPlainText.preflight " + + "web=\(ObjectIdentifier(self)) canPaste=\(canPaste ? 1 : 0) " + + "error=\(errorDescription)" + ) +#endif + return canPaste + } + @discardableResult private func performPasteAsPlainTextFromPasteboard() -> Bool { - guard pasteAsPlainTextTargetAvailable, - let text = NSPasteboard.general.string(forType: .string), - let textLiteral = cmuxJavaScriptStringLiteral(text) else { + guard let text = NSPasteboard.general.string(forType: .string), + pageCanAcceptPlainTextPaste() else { return false } + let textLiteral = "\"\(BrowserFindJavaScript.jsStringEscape(text))\"" let script = """ (() => { const text = \(textLiteral); - const supportedTextInputTypes = new Set(['', 'text', 'search', 'tel', 'url', 'email', 'password']); - const deepestActiveElement = (root) => { - let active = root?.activeElement ?? null; - while (active) { - const shadowActive = active.shadowRoot?.activeElement ?? null; - if (shadowActive && shadowActive !== active) { - active = shadowActive; - continue; - } - - const tagName = typeof active.tagName === 'string' ? active.tagName.toUpperCase() : ''; - if (tagName === 'IFRAME') { - try { - const frameActive = active.contentDocument?.activeElement ?? null; - if (frameActive && frameActive !== active) { - active = frameActive; - continue; - } - } catch (_) {} - } - - break; - } - return active; - }; - const isPlainTextTextControl = (el) => { - if (!el || el.disabled || el.readOnly) return false; - - const tagName = typeof el.tagName === 'string' ? el.tagName.toUpperCase() : ''; - if (tagName === 'TEXTAREA') return true; - if (tagName !== 'INPUT') return false; - - const type = typeof el.type === 'string' ? el.type.toLowerCase() : 'text'; - return supportedTextInputTypes.has(type); - }; - const active = deepestActiveElement(document); - const editableTarget = (() => { - if (!active) return null; - if (isPlainTextTextControl(active)) return active; - if (active.isContentEditable) return active; - return active.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null; - })(); + \(Self.pasteAsPlainTextSharedHelpersScriptSource) + const editableTarget = __cmuxPasteAsPlainTextHelpers.editableTarget(document.activeElement); if (!editableTarget) return false; const ownerDocument = editableTarget.ownerDocument ?? document; @@ -519,7 +547,7 @@ final class CmuxWebView: WKWebView { return null; }; - if (isPlainTextTextControl(editableTarget)) { + if (__cmuxPasteAsPlainTextHelpers.isPlainTextTextControl(editableTarget)) { const value = typeof editableTarget.value === 'string' ? editableTarget.value : ''; const start = typeof editableTarget.selectionStart === 'number' ? editableTarget.selectionStart @@ -579,26 +607,30 @@ final class CmuxWebView: WKWebView { })(); """ - // NSResponder key-equivalent handling is synchronous. Wait briefly so failed page-side - // insertion can fall through to WebKit/page handlers instead of being consumed here. - let evaluation = evaluateJavaScriptSynchronously(script) - let inserted = evaluation.completed && ((evaluation.result as? Bool) ?? false) + evaluateJavaScript(script) { [weak self] result, error in + guard let self else { return } #if DEBUG - let errorDescription = evaluation.completed - ? (evaluation.error?.localizedDescription ?? "nil") - : "timeout" - dlog( - "browser.pasteAsPlainText " + - "web=\(ObjectIdentifier(self)) inserted=\(inserted ? 1 : 0) " + - "error=\(errorDescription)" - ) + let inserted = (result as? Bool) ?? false + dlog( + "browser.pasteAsPlainText " + + "web=\(ObjectIdentifier(self)) inserted=\(inserted ? 1 : 0) " + + "error=\(error?.localizedDescription ?? "nil")" + ) +#else + _ = result + _ = error #endif - return inserted + } + // Key-equivalent routing cannot await the page-side insertion result. Once the + // preflight confirms a live editable target, dispatch the insertion and consume. + return true } @IBAction func pasteAsPlainText(_ sender: Any?) { _ = sender - _ = performPasteAsPlainTextFromPasteboard() + if !performPasteAsPlainTextFromPasteboard() { + webKitPasteAsPlainTextFallback(sender) + } } override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { From 45a5179532ad2f06f1cde1a174bf79c68ecd614e Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 03:22:55 -0700 Subject: [PATCH 77/89] Use WebKit native plain-text browser paste --- Sources/Panels/CmuxWebView.swift | 119 +++---------------------------- 1 file changed, 9 insertions(+), 110 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index e8f3e4591261..b5f5fc9fcc89 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -253,7 +253,7 @@ final class CmuxWebView: WKWebView { }, true); document.addEventListener("mousedown", (ev) => { const target = ev && ev.target ? ev.target : null; - if (!canPasteAsPlainTextInto(target)) { + if (!__cmuxPasteAsPlainTextHelpers.canPasteAsPlainTextInto(target)) { publish(false); } }, true); @@ -509,126 +509,25 @@ final class CmuxWebView: WKWebView { } @discardableResult - private func performPasteAsPlainTextFromPasteboard() -> Bool { - guard let text = NSPasteboard.general.string(forType: .string), + private func performPasteAsPlainTextFromPasteboard(_ sender: Any? = nil) -> Bool { + guard NSPasteboard.general.string(forType: .string) != nil, pageCanAcceptPlainTextPaste() else { return false } - let textLiteral = "\"\(BrowserFindJavaScript.jsStringEscape(text))\"" - let script = """ - (() => { - const text = \(textLiteral); - \(Self.pasteAsPlainTextSharedHelpersScriptSource) - const editableTarget = __cmuxPasteAsPlainTextHelpers.editableTarget(document.activeElement); - if (!editableTarget) return false; - - const ownerDocument = editableTarget.ownerDocument ?? document; - const ownerWindow = ownerDocument.defaultView ?? window; - const makeInputEvent = () => { - try { - const InputEventCtor = ownerWindow.InputEvent; - if (typeof InputEventCtor === 'function') { - return new InputEventCtor('input', { - bubbles: true, - inputType: 'insertFromPaste', - data: text - }); - } - } catch (_) {} - - try { - const EventCtor = ownerWindow.Event; - if (typeof EventCtor === 'function') { - return new EventCtor('input', { bubbles: true }); - } - } catch (_) {} - - return null; - }; - - if (__cmuxPasteAsPlainTextHelpers.isPlainTextTextControl(editableTarget)) { - const value = typeof editableTarget.value === 'string' ? editableTarget.value : ''; - const start = typeof editableTarget.selectionStart === 'number' - ? editableTarget.selectionStart - : value.length; - const end = typeof editableTarget.selectionEnd === 'number' - ? editableTarget.selectionEnd - : start; - - try { - if (typeof editableTarget.setRangeText === 'function') { - editableTarget.setRangeText(text, start, end, 'end'); - } else { - editableTarget.value = value.slice(0, start) + text + value.slice(end); - if (typeof editableTarget.setSelectionRange === 'function') { - const caret = start + text.length; - editableTarget.setSelectionRange(caret, caret); - } - } - } catch (_) { - return false; - } - - const inputEvent = makeInputEvent(); - if (inputEvent) { - editableTarget.dispatchEvent(inputEvent); - } - return true; - } - - if (typeof ownerDocument.execCommand === 'function') { - try { - if (ownerDocument.execCommand('insertText', false, text)) { - return true; - } - } catch (_) {} - } - - const selection = typeof ownerWindow.getSelection === 'function' - ? ownerWindow.getSelection() - : null; - if (!selection || selection.rangeCount === 0) return false; - - const range = selection.getRangeAt(0); - range.deleteContents(); - const node = ownerDocument.createTextNode(text); - range.insertNode(node); - range.setStartAfter(node); - range.setEndAfter(node); - selection.removeAllRanges(); - selection.addRange(range); - - const inputEvent = makeInputEvent(); - if (inputEvent) { - editableTarget.dispatchEvent(inputEvent); - } - return true; - })(); - """ - - evaluateJavaScript(script) { [weak self] result, error in - guard let self else { return } + webKitPasteAsPlainTextFallback(sender) #if DEBUG - let inserted = (result as? Bool) ?? false - dlog( - "browser.pasteAsPlainText " + - "web=\(ObjectIdentifier(self)) inserted=\(inserted ? 1 : 0) " + - "error=\(error?.localizedDescription ?? "nil")" - ) -#else - _ = result - _ = error + dlog( + "browser.pasteAsPlainText " + + "web=\(ObjectIdentifier(self)) routedNative=1" + ) #endif - } - // Key-equivalent routing cannot await the page-side insertion result. Once the - // preflight confirms a live editable target, dispatch the insertion and consume. return true } @IBAction func pasteAsPlainText(_ sender: Any?) { _ = sender - if !performPasteAsPlainTextFromPasteboard() { + if !performPasteAsPlainTextFromPasteboard(sender) { webKitPasteAsPlainTextFallback(sender) } } From 66f7b5965261a4a977738fe631a2427875b9c9f1 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 03:25:40 -0700 Subject: [PATCH 78/89] Fix browser omnibar shortcut routing edge cases --- Sources/AppDelegate.swift | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index bd84a0332094..417c5d69ecf6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11725,12 +11725,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow let shortcutResponder = shortcutWindow?.firstResponder - let responderIsOmnibar = isBrowserOmnibarResponder(shortcutResponder) - let pendingAddressBarHandoff = - panel.pendingAddressBarFocusRequestId != nil && - workspace.focusedPanelId == panelId - guard responderIsOmnibar || pendingAddressBarHandoff else { + guard isBrowserOmnibarResponder(shortcutResponder) else { #if DEBUG let focusedPanel = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( @@ -11744,10 +11740,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #if DEBUG - let acceptReason = responderIsOmnibar ? "omnibar_responder" : "pending_handoff" dlog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=1 reason=\(acceptReason) workspace=\(workspace.id.uuidString.prefix(5)) " + + "accepted=1 reason=omnibar_responder workspace=\(workspace.id.uuidString.prefix(5)) " + "event=\(NSWindow.keyDescription(event))" ) #endif @@ -11755,17 +11750,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func isBrowserOmnibarResponder(_ responder: NSResponder?) -> Bool { - guard let ownerView = keyRoutingOwnerView(for: responder) else { return false } - - var current: NSView? = ownerView - while let candidate = current { - if candidate.identifier == browserOmnibarTextFieldIdentifier { - return true - } - current = candidate.superview - } - - return false + keyRoutingOwnerView(for: responder)?.identifier == browserOmnibarTextFieldIdentifier } @discardableResult @@ -12757,6 +12742,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return } guard let webView = notification.object as? CmuxWebView, let panel = self.browserPanelOwning(webView) else { return } + + if let trackedPanelId = self.browserAddressBarFocusedPanelId, + trackedPanelId != panel.id, + let trackedPanel = self.browserPanel(for: trackedPanelId), + !self.isBrowserOmnibarResponder(trackedPanel.webView.window?.firstResponder) { + trackedPanel.endSuppressWebViewFocusForAddressBar() + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog( + "addressBar CLEAR panelId=\(trackedPanelId.uuidString.prefix(8)) " + + "reason=stale_other_panel_webViewFirstResponder" + ) +#endif + } + guard panel.pendingAddressBarFocusRequestId == nil || self.browserAddressBarFocusedPanelId != panel.id else { #if DEBUG From e8d726d6e3da670d43b0c9a0d01dfab12c1db735 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 03:44:55 -0700 Subject: [PATCH 79/89] Avoid duplicate browser paste preflight --- Sources/Panels/CmuxWebView.swift | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index b5f5fc9fcc89..8994bf1389b5 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -333,6 +333,7 @@ final class CmuxWebView: WKWebView { var allowsFirstResponderAcquisition: Bool = true private var pointerFocusAllowanceDepth: Int = 0 private var pasteAsPlainTextTargetAvailable = false + private var lastPasteAsPlainTextPerformKeyEventTimestamp: TimeInterval? var allowsFirstResponderAcquisitionEffective: Bool { allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0 } @@ -508,6 +509,15 @@ final class CmuxWebView: WKWebView { return canPaste } + private func shouldSkipRepeatedPasteAsPlainTextPreflight(for event: NSEvent) -> Bool { + guard event.timestamp > 0, + let lastTimestamp = lastPasteAsPlainTextPerformKeyEventTimestamp else { + return false + } + lastPasteAsPlainTextPerformKeyEventTimestamp = nil + return lastTimestamp == event.timestamp + } + @discardableResult private func performPasteAsPlainTextFromPasteboard(_ sender: Any? = nil) -> Bool { guard NSPasteboard.general.string(forType: .string) != nil, @@ -571,7 +581,15 @@ final class CmuxWebView: WKWebView { } if Self.isPasteAsPlainTextCommandEquivalent(event) { + if event.timestamp > 0 { + lastPasteAsPlainTextPerformKeyEventTimestamp = event.timestamp + } else { + lastPasteAsPlainTextPerformKeyEventTimestamp = nil + } let result = performPasteAsPlainTextFromPasteboard() || super.performKeyEquivalent(with: event) + if result { + lastPasteAsPlainTextPerformKeyEventTimestamp = nil + } #if DEBUG handled = result #endif @@ -647,12 +665,18 @@ final class CmuxWebView: WKWebView { } #endif if Self.isPasteAsPlainTextCommandEquivalent(event) { - let didPaste = performPasteAsPlainTextFromPasteboard() + if shouldSkipRepeatedPasteAsPlainTextPreflight(for: event) { #if DEBUG - route = didPaste ? "pasteAsPlainText" : "super" + route = "super" #endif - if didPaste { - return + } else { + let didPaste = performPasteAsPlainTextFromPasteboard() +#if DEBUG + route = didPaste ? "pasteAsPlainText" : "super" +#endif + if didPaste { + return + } } } From 1ffe48649ed2b00af0b863b61d4489c8710b3faf Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 03:47:32 -0700 Subject: [PATCH 80/89] Fix browser omnibar field editor focus tracking --- Sources/AppDelegate.swift | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 417c5d69ecf6..a557b5dd5db1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11749,8 +11749,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return panelId } + private func browserOmnibarOwnerView(for responder: NSResponder?) -> NSView? { + guard let responder else { return nil } + + if let textView = responder as? NSTextView, + textView.isFieldEditor, + let delegateView = textView.delegate as? NSView, + delegateView.identifier == browserOmnibarTextFieldIdentifier { + return delegateView + } + + let ownerView = keyRoutingOwnerView(for: responder) + guard ownerView?.identifier == browserOmnibarTextFieldIdentifier else { return nil } + return ownerView + } + private func isBrowserOmnibarResponder(_ responder: NSResponder?) -> Bool { - keyRoutingOwnerView(for: responder)?.identifier == browserOmnibarTextFieldIdentifier + guard let ownerView = browserOmnibarOwnerView(for: responder) else { return false } + + if let fieldEditor = responder as? NSTextView, + fieldEditor.isFieldEditor { + return (ownerView as? NSTextField)?.currentEditor() === fieldEditor + } + + return true + } + + private func shouldPreserveBrowserAddressBarTracking(for panel: BrowserPanel) -> Bool { + guard browserAddressBarFocusedPanelId == panel.id else { return false } + if isBrowserOmnibarResponder(panel.webView.window?.firstResponder) { + return true + } + return panel.preferredFocusIntent == .addressBar && panel.shouldSuppressWebViewFocus() } @discardableResult @@ -12746,7 +12776,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let trackedPanelId = self.browserAddressBarFocusedPanelId, trackedPanelId != panel.id, let trackedPanel = self.browserPanel(for: trackedPanelId), - !self.isBrowserOmnibarResponder(trackedPanel.webView.window?.firstResponder) { + !self.shouldPreserveBrowserAddressBarTracking(for: trackedPanel) { trackedPanel.endSuppressWebViewFocusForAddressBar() self.browserAddressBarFocusedPanelId = nil self.stopBrowserOmnibarSelectionRepeat() @@ -12758,12 +12788,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } - guard panel.pendingAddressBarFocusRequestId == nil || - self.browserAddressBarFocusedPanelId != panel.id else { + guard !self.shouldPreserveBrowserAddressBarTracking(for: panel) else { #if DEBUG dlog( "addressBar CLEAR panelId=\(panel.id.uuidString.prefix(8)) " + - "reason=skip_pending_focus_handoff" + "reason=skip_preserve_omnibar_handoff" ) #endif return From 3d0e2f11dae0aa1813d09746c894568fe5b278d9 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 03:55:17 -0700 Subject: [PATCH 81/89] Broaden browser plain-text paste gating --- Sources/Panels/CmuxWebView.swift | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 8994bf1389b5..72c7fcf3840f 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -124,7 +124,21 @@ final class CmuxWebView: WKWebView { const existing = window.__cmuxPasteAsPlainTextHelpers; if (existing) return existing; - const supportedTextInputTypes = new Set(["", "text", "search", "tel", "url", "email", "password"]); + const supportedTextInputTypes = new Set([ + "", + "text", + "search", + "tel", + "url", + "email", + "password", + "number", + "date", + "datetime-local", + "month", + "time", + "week" + ]); const deepestActiveElement = (root) => { let active = root?.activeElement ?? null; @@ -162,6 +176,17 @@ final class CmuxWebView: WKWebView { return supportedTextInputTypes.has(type); }; + const isFocusedCrossOriginFrameElement = (el) => { + const tagName = typeof el?.tagName === "string" ? el.tagName.toUpperCase() : ""; + if (tagName !== "IFRAME") return false; + try { + void el.contentDocument; + return false; + } catch (_) { + return true; + } + }; + const resolvedCandidateElement = (el) => { if (!el) return deepestActiveElement(document); @@ -184,6 +209,7 @@ final class CmuxWebView: WKWebView { const candidate = resolvedCandidateElement(el); if (!candidate) return null; if (isPlainTextTextControl(candidate)) return candidate; + if (isFocusedCrossOriginFrameElement(candidate)) return candidate; if (candidate.isContentEditable) return candidate; return candidate.closest?.('[contenteditable]:not([contenteditable="false"])') ?? null; }; @@ -191,6 +217,7 @@ final class CmuxWebView: WKWebView { const helpers = { deepestActiveElement, isPlainTextTextControl, + isFocusedCrossOriginFrameElement, resolvedCandidateElement, editableTarget, canPasteAsPlainTextInto(el) { From 4f1aee645af74f6809823d8599488ab3885ce17c Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Fri, 10 Apr 2026 03:56:08 -0700 Subject: [PATCH 82/89] Skip browser paste preflight when cache is false --- Sources/Panels/CmuxWebView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 72c7fcf3840f..deaa515c6106 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -547,7 +547,8 @@ final class CmuxWebView: WKWebView { @discardableResult private func performPasteAsPlainTextFromPasteboard(_ sender: Any? = nil) -> Bool { - guard NSPasteboard.general.string(forType: .string) != nil, + guard pasteAsPlainTextTargetAvailable, + NSPasteboard.general.string(forType: .string) != nil, pageCanAcceptPlainTextPaste() else { return false } From 4662d72125d2bed3c9b77c66349e8ae05ad5a942 Mon Sep 17 00:00:00 2001 From: Jess Sullivan <jess@sulliwood.org> Date: Sat, 11 Apr 2026 15:37:21 -0400 Subject: [PATCH 83/89] chore(vendor): update bonsplit submodule to upstream-sync (cmd-hold hints, themed split, pane tab fixes) --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 1eaf57e758a2..ac8eec128e0b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 1eaf57e758a2ba4700907c58f135313bef6eeba1 +Subproject commit ac8eec128e0b91d116383d4967f556684a6885db From c940f903c88daf30ca173111c04aca65e0868b82 Mon Sep 17 00:00:00 2001 From: Jess Sullivan <jess@sulliwood.org> Date: Sat, 11 Apr 2026 15:38:39 -0400 Subject: [PATCH 84/89] chore(vendor): point bonsplit to upstream tip (TabItem equality fix will follow as upstream PR) --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index ac8eec128e0b..2979ef647d4a 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit ac8eec128e0b91d116383d4967f556684a6885db +Subproject commit 2979ef647d4a6ab0bf7f93ce82c59cbf0c70e1e1 From 8f1e7ae6ece2f577aee0618ad53ac5b543bd819b Mon Sep 17 00:00:00 2001 From: Jess Sullivan <jess@sulliwood.org> Date: Sat, 11 Apr 2026 15:59:15 -0400 Subject: [PATCH 85/89] fix(macOS): remove orphaned scheduleSelectedWorkspaceGitMetadataRefresh call --- Sources/TabManager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 05d7be105ff6..b1d038cf9d07 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -949,7 +949,6 @@ class TabManager: ObservableObject { if let selectedTabId = self.selectedTabId { self.dismissFocusedPanelNotificationIfActive(tabId: selectedTabId) } - self.scheduleSelectedWorkspaceGitMetadataRefresh(reason: "selectionChange") #if DEBUG let dtMs = self.debugWorkspaceSwitchStartTime > 0 ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 From d6a3d303dbec212e97aa72873f85e9db60464701 Mon Sep 17 00:00:00 2001 From: Jess Sullivan <jess@sulliwood.org> Date: Sat, 11 Apr 2026 18:15:12 -0400 Subject: [PATCH 86/89] fix(macOS): break settings body into Group blocks to fix Swift type-checker timeout --- Sources/cmuxApp.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 82f0b240e16f..bf27d2f39719 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5146,6 +5146,7 @@ struct SettingsView: View { ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { + Group { if sectionVisible(.app) { SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"), section: .app) .id(SettingsSection.app) @@ -5974,7 +5975,9 @@ struct SettingsView: View { } } } // end if sectionVisible(.sidebarAppearance) + } // end Group 1 (app, workspaceColors, sidebarAppearance) + Group { if sectionVisible(.automation) { SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation"), section: .automation) .id(SettingsSection.automation) @@ -6545,6 +6548,7 @@ struct SettingsView: View { .padding(.vertical, 10) } } // end if sectionVisible(.reset) + } // end Group 2 (automation, customCommands, browser, keyboardShortcuts, reset) } .padding(.horizontal, 20) .padding(.bottom, 20) From 70bfe39fb58974c5d37ca8b307e9ed14ef6d5837 Mon Sep 17 00:00:00 2001 From: Jess Sullivan <jess@sulliwood.org> Date: Sat, 11 Apr 2026 18:30:43 -0400 Subject: [PATCH 87/89] fix(macOS): split settings into 9 Group blocks for Swift type-checker The 2-Group approach still timed out on CI. Break into 6 outer Groups (one per settings section) and 3 inner Groups inside the app SettingsCard (~190 lines each instead of 577). --- Sources/cmuxApp.swift | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index bf27d2f39719..dce134675add 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5146,11 +5146,12 @@ struct SettingsView: View { ZStack(alignment: .top) { ScrollView { VStack(alignment: .leading, spacing: 14) { - Group { + Group { // Group: app section if sectionVisible(.app) { SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"), section: .app) .id(SettingsSection.app) SettingsCard { + Group { // app card rows part 1 SettingsCardRow( configurationReview: .json("app.language"), String(localized: "settings.app.language", defaultValue: "Language"), @@ -5342,7 +5343,9 @@ struct SettingsView: View { String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash") ) } + } // end app card rows part 1 + Group { // app card rows part 2 SettingsCardDivider() SettingsCardRow( @@ -5528,7 +5531,9 @@ struct SettingsView: View { .labelsHidden() .controlSize(.small) } + } // end app card rows part 2 + Group { // app card rows part 3 SettingsCardDivider() SettingsCardRow( @@ -5723,9 +5728,11 @@ struct SettingsView: View { .controlSize(.small) } .disabled(sidebarHideAllDetails) + } // end app card rows part 3 } } // end if sectionVisible(.app) - + } // end Group: app section + Group { // Group: workspaceColors section if sectionVisible(.workspaceColors) { SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors"), section: .workspaceColors) .id(SettingsSection.workspaceColors) @@ -5878,7 +5885,8 @@ struct SettingsView: View { } } } // end if sectionVisible(.workspaceColors) - + } // end Group: workspaceColors section + Group { // Group: sidebarAppearance section if sectionVisible(.sidebarAppearance) { SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance"), section: .sidebarAppearance) .id(SettingsSection.sidebarAppearance) @@ -5975,9 +5983,8 @@ struct SettingsView: View { } } } // end if sectionVisible(.sidebarAppearance) - } // end Group 1 (app, workspaceColors, sidebarAppearance) - - Group { + } // end Group: sidebarAppearance section + Group { // Group: automation section if sectionVisible(.automation) { SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation"), section: .automation) .id(SettingsSection.automation) @@ -6152,7 +6159,8 @@ struct SettingsView: View { SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) } } // end if sectionVisible(.automation) - + } // end Group: automation section + Group { // Group: customCommands + browser sections if sectionVisible(.customCommands) { SettingsSectionHeader(title: String(localized: "settings.section.customCommands", defaultValue: "Custom Commands"), section: .customCommands) .id(SettingsSection.customCommands) @@ -6467,7 +6475,8 @@ struct SettingsView: View { } } } // end if sectionVisible(.browser) - + } // end Group: customCommands + browser sections + Group { // Group: hotkey + keyboardShortcuts + reset sections GlobalHotkeySection() if sectionVisible(.keyboardShortcuts) { @@ -6548,7 +6557,7 @@ struct SettingsView: View { .padding(.vertical, 10) } } // end if sectionVisible(.reset) - } // end Group 2 (automation, customCommands, browser, keyboardShortcuts, reset) + } // end Group: hotkey + keyboardShortcuts + reset sections } .padding(.horizontal, 20) .padding(.bottom, 20) From feda26f496c3fbe31e176f77c98a5a65243a62f9 Mon Sep 17 00:00:00 2001 From: Jess Sullivan <jess@sulliwood.org> Date: Sat, 11 Apr 2026 19:02:10 -0400 Subject: [PATCH 88/89] fix(macOS): extract settings sections into @ViewBuilder properties Move each settings section (app, workspaceColors, sidebarAppearance, automation, customCommands+browser, hotkey+shortcuts) into private @ViewBuilder computed properties. The body VStack becomes trivial (6 property references), eliminating the Swift type-checker timeout. --- Sources/cmuxApp.swift | 2534 +++++++++++++++++++++-------------------- 1 file changed, 1277 insertions(+), 1257 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index dce134675add..862577680352 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5128,1436 +5128,1456 @@ struct SettingsView: View { section.matches(settingsSearchText) } - var body: some View { - let _ = keyboardShortcutSettingsObserver.revision - let _ = Self.validateBypassedSettingsConfigurationReviews() - ScrollViewReader { proxy in - HStack(spacing: 0) { - SettingsTOCSidebar( - searchText: $settingsSearchText, - activeSection: activeSection, - onSelect: { section in - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(section, anchor: .top) + + @ViewBuilder + private var settingsAppSection: some View { + if sectionVisible(.app) { + SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"), section: .app) + .id(SettingsSection.app) + SettingsCard { + Group { // app card rows part 1 + SettingsCardRow( + configurationReview: .json("app.language"), + String(localized: "settings.app.language", defaultValue: "Language"), + subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue + ? String(localized: "settings.app.language.restartSubtitle", defaultValue: "Restart cmux to apply") + : nil, + controlWidth: pickerColumnWidth + ) { + Picker("", selection: $appLanguage) { + ForEach(AppLanguage.allCases) { lang in + Text(lang.displayName).tag(lang.rawValue) } } - ) - Divider() - ZStack(alignment: .top) { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - Group { // Group: app section - if sectionVisible(.app) { - SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"), section: .app) - .id(SettingsSection.app) - SettingsCard { - Group { // app card rows part 1 - SettingsCardRow( - configurationReview: .json("app.language"), - String(localized: "settings.app.language", defaultValue: "Language"), - subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue - ? String(localized: "settings.app.language.restartSubtitle", defaultValue: "Restart cmux to apply") - : nil, - controlWidth: pickerColumnWidth - ) { - Picker("", selection: $appLanguage) { - ForEach(AppLanguage.allCases) { lang in - Text(lang.displayName).tag(lang.rawValue) - } - } - .labelsHidden() - .pickerStyle(.menu) - .onChange(of: appLanguage) { newValue in - guard !isResettingSettings else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [self] in - // Re-check current value to handle rapid changes - let current = appLanguage - if let lang = AppLanguage(rawValue: current) { - LanguageSettings.apply(lang) - } - if current != LanguageSettings.languageAtLaunch.rawValue { - showLanguageRestartAlert = true - } - } - } + .labelsHidden() + .pickerStyle(.menu) + .onChange(of: appLanguage) { newValue in + guard !isResettingSettings else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [self] in + // Re-check current value to handle rapid changes + let current = appLanguage + if let lang = AppLanguage(rawValue: current) { + LanguageSettings.apply(lang) } + if current != LanguageSettings.languageAtLaunch.rawValue { + showLanguageRestartAlert = true + } + } + } + } - SettingsCardDivider() + SettingsCardDivider() - ThemePickerRow( - configurationReview: .json("app.appearance"), - selectedMode: appearanceMode, - onSelect: { mode in - appearanceMode = mode.rawValue - } - ) + ThemePickerRow( + configurationReview: .json("app.appearance"), + selectedMode: appearanceMode, + onSelect: { mode in + appearanceMode = mode.rawValue + } + ) - SettingsCardDivider() + SettingsCardDivider() - AppIconPickerRow( - configurationReview: .json("app.appIcon"), - selectedMode: appIconMode, - onSelect: { mode in - appIconMode = mode.rawValue - AppIconSettings.applyIcon(mode) - } - ) + AppIconPickerRow( + configurationReview: .json("app.appIcon"), + selectedMode: appIconMode, + onSelect: { mode in + appIconMode = mode.rawValue + AppIconSettings.applyIcon(mode) + } + ) - SettingsCardDivider() + SettingsCardDivider() - SettingsPickerRow( - configurationReview: .json("app.newWorkspacePlacement"), - String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), - subtitle: selectedWorkspacePlacement.description, - controlWidth: pickerColumnWidth, - selection: $newWorkspacePlacement - ) { - ForEach(NewWorkspacePlacement.allCases) { placement in - Text(placement.displayName).tag(placement.rawValue) - } - } + SettingsPickerRow( + configurationReview: .json("app.newWorkspacePlacement"), + String(localized: "settings.app.newWorkspacePlacement", defaultValue: "New Workspace Placement"), + subtitle: selectedWorkspacePlacement.description, + controlWidth: pickerColumnWidth, + selection: $newWorkspacePlacement + ) { + ForEach(NewWorkspacePlacement.allCases) { placement in + Text(placement.displayName).tag(placement.rawValue) + } + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("app.minimalMode"), - String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), - subtitle: minimalModeSubtitle - ) { - Toggle("", isOn: minimalModeBinding) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsMinimalModeToggle") - .accessibilityLabel( - String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode") - ) - } + SettingsCardRow( + configurationReview: .json("app.minimalMode"), + String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), + subtitle: minimalModeSubtitle + ) { + Toggle("", isOn: minimalModeBinding) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsMinimalModeToggle") + .accessibilityLabel( + String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode") + ) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("app.keepWorkspaceOpenWhenClosingLastSurface"), - String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), - subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle - ) { - Toggle("", isOn: keepWorkspaceOpenOnLastSurfaceShortcutBinding) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + configurationReview: .json("app.keepWorkspaceOpenWhenClosingLastSurface"), + String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), + subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle + ) { + Toggle("", isOn: keepWorkspaceOpenOnLastSurfaceShortcutBinding) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("app.focusPaneOnFirstClick"), - String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"), - subtitle: paneFirstClickFocusSubtitle - ) { - Toggle("", isOn: $paneFirstClickFocusEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel( - String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click") - ) - } + SettingsCardRow( + configurationReview: .json("app.focusPaneOnFirstClick"), + String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"), + subtitle: paneFirstClickFocusSubtitle + ) { + Toggle("", isOn: $paneFirstClickFocusEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click") + ) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("app.preferredEditor"), - String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), - subtitle: String(localized: "settings.app.preferredEditor.subtitle", defaultValue: "Command to open files on Cmd-click. Leave empty for system default.") - ) { - TextField( - String(localized: "settings.app.preferredEditor.placeholder", defaultValue: "e.g. code, zed, subl"), - text: $preferredEditorCommand - ) - .textFieldStyle(.roundedBorder) - .frame(width: 200) - } + SettingsCardRow( + configurationReview: .json("app.preferredEditor"), + String(localized: "settings.app.preferredEditor", defaultValue: "Open Files With"), + subtitle: String(localized: "settings.app.preferredEditor.subtitle", defaultValue: "Command to open files on Cmd-click. Leave empty for system default.") + ) { + TextField( + String(localized: "settings.app.preferredEditor.placeholder", defaultValue: "e.g. code, zed, subl"), + text: $preferredEditorCommand + ) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("app.reorderOnNotification"), - String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), - subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") - ) { - Toggle("", isOn: $workspaceAutoReorder) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + configurationReview: .json("app.reorderOnNotification"), + String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), + subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") + ) { + Toggle("", isOn: $workspaceAutoReorder) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("notifications.dockBadge"), - String(localized: "settings.app.dockBadge", defaultValue: "Dock Badge"), - subtitle: String(localized: "settings.app.dockBadge.subtitle", defaultValue: "Show unread count on app icon (Dock and Cmd+Tab).") - ) { - Toggle("", isOn: $notificationDockBadgeEnabled) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + configurationReview: .json("notifications.dockBadge"), + String(localized: "settings.app.dockBadge", defaultValue: "Dock Badge"), + subtitle: String(localized: "settings.app.dockBadge.subtitle", defaultValue: "Show unread count on app icon (Dock and Cmd+Tab).") + ) { + Toggle("", isOn: $notificationDockBadgeEnabled) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("notifications.showInMenuBar"), - String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"), - subtitle: String(localized: "settings.app.showInMenuBar.subtitle", defaultValue: "Keep cmux in the menu bar for unread notifications and quick actions.") - ) { - Toggle("", isOn: $showMenuBarExtra) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel( - String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar") - ) - } + SettingsCardRow( + configurationReview: .json("notifications.showInMenuBar"), + String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"), + subtitle: String(localized: "settings.app.showInMenuBar.subtitle", defaultValue: "Keep cmux in the menu bar for unread notifications and quick actions.") + ) { + Toggle("", isOn: $showMenuBarExtra) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar") + ) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("notifications.unreadPaneRing"), - String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"), - subtitle: String(localized: "settings.notifications.paneRing.subtitle", defaultValue: "Show a blue ring around panes with unread notifications.") - ) { - Toggle("", isOn: $notificationPaneRingEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel( - String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring") - ) - } + SettingsCardRow( + configurationReview: .json("notifications.unreadPaneRing"), + String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"), + subtitle: String(localized: "settings.notifications.paneRing.subtitle", defaultValue: "Show a blue ring around panes with unread notifications.") + ) { + Toggle("", isOn: $notificationPaneRingEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring") + ) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("notifications.paneFlash"), - String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"), - subtitle: String(localized: "settings.notifications.paneFlash.subtitle", defaultValue: "Briefly flash a blue outline when cmux highlights a pane.") - ) { - Toggle("", isOn: $notificationPaneFlashEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel( - String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash") - ) - } - } // end app card rows part 1 + SettingsCardRow( + configurationReview: .json("notifications.paneFlash"), + String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"), + subtitle: String(localized: "settings.notifications.paneFlash.subtitle", defaultValue: "Briefly flash a blue outline when cmux highlights a pane.") + ) { + Toggle("", isOn: $notificationPaneFlashEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash") + ) + } + } // end app card rows part 1 - Group { // app card rows part 2 - SettingsCardDivider() + Group { // app card rows part 2 + SettingsCardDivider() - SettingsCardRow( - configurationReview: .action, - "Desktop Notifications", - subtitle: notificationPermissionSubtitle - ) { - HStack(spacing: 6) { - Text(notificationPermissionStatusText) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(notificationPermissionStatusColor) - .frame(width: 98, alignment: .trailing) - - Button(notificationPermissionActionTitle) { - handleNotificationPermissionAction() - } - .controlSize(.small) + SettingsCardRow( + configurationReview: .action, + "Desktop Notifications", + subtitle: notificationPermissionSubtitle + ) { + HStack(spacing: 6) { + Text(notificationPermissionStatusText) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(notificationPermissionStatusColor) + .frame(width: 98, alignment: .trailing) + + Button(notificationPermissionActionTitle) { + handleNotificationPermissionAction() + } + .controlSize(.small) - Button("Send Test") { - notificationStore.sendSettingsTestNotification() - } - .controlSize(.small) - } - } + Button("Send Test") { + notificationStore.sendSettingsTestNotification() + } + .controlSize(.small) + } + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("notifications.sound", "notifications.customSoundFilePath"), - String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"), - subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."), - controlWidth: notificationSoundControlWidth - ) { - VStack(alignment: .trailing, spacing: 6) { - HStack(spacing: 6) { - Picker("", selection: $notificationSound) { - ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in - Text(sound.label).tag(sound.value) - } - } - .labelsHidden() - Button { - previewNotificationSound() - } label: { - Image(systemName: "play.fill") - .font(.system(size: 9)) - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!canPreviewNotificationSound) - } + SettingsCardRow( + configurationReview: .json("notifications.sound", "notifications.customSoundFilePath"), + String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"), + subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."), + controlWidth: notificationSoundControlWidth + ) { + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { + Picker("", selection: $notificationSound) { + ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in + Text(sound.label).tag(sound.value) + } + } + .labelsHidden() + Button { + previewNotificationSound() + } label: { + Image(systemName: "play.fill") + .font(.system(size: 9)) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!canPreviewNotificationSound) + } - if notificationSound == NotificationSoundSettings.customFileValue { - HStack(spacing: 6) { - Text(notificationSoundCustomFileDisplayName) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .frame(width: 170, alignment: .trailing) - Button( - String( - localized: "settings.notifications.sound.custom.choose.button", - defaultValue: "Choose..." - ) - ) { - chooseNotificationSoundFile() - } - .controlSize(.small) - Button( - String( - localized: "settings.notifications.sound.custom.clear.button", - defaultValue: "Clear" - ) - ) { - notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath - refreshNotificationCustomSoundStatus() - } - .controlSize(.small) - .disabled(!hasCustomNotificationSoundFilePath) - } - if let notificationCustomSoundStatusMessage { - Text(notificationCustomSoundStatusMessage) - .font(.system(size: 11)) - .foregroundStyle(notificationCustomSoundStatusIsError ? Color.red : Color.secondary) - .lineLimit(2) - .multilineTextAlignment(.trailing) - .frame(width: 260, alignment: .trailing) - } - } + if notificationSound == NotificationSoundSettings.customFileValue { + HStack(spacing: 6) { + Text(notificationSoundCustomFileDisplayName) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 170, alignment: .trailing) + Button( + String( + localized: "settings.notifications.sound.custom.choose.button", + defaultValue: "Choose..." + ) + ) { + chooseNotificationSoundFile() } - .frame(maxWidth: .infinity, alignment: .trailing) + .controlSize(.small) + Button( + String( + localized: "settings.notifications.sound.custom.clear.button", + defaultValue: "Clear" + ) + ) { + notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath + refreshNotificationCustomSoundStatus() + } + .controlSize(.small) + .disabled(!hasCustomNotificationSoundFilePath) } + if let notificationCustomSoundStatusMessage { + Text(notificationCustomSoundStatusMessage) + .font(.system(size: 11)) + .foregroundStyle(notificationCustomSoundStatusIsError ? Color.red : Color.secondary) + .lineLimit(2) + .multilineTextAlignment(.trailing) + .frame(width: 260, alignment: .trailing) + } + } + } + .frame(maxWidth: .infinity, alignment: .trailing) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("notifications.command"), - "Notification Command", - subtitle: "Run a shell command when a notification arrives. $CMUX_NOTIFICATION_TITLE, $CMUX_NOTIFICATION_SUBTITLE, $CMUX_NOTIFICATION_BODY are set." - ) { - TextField("say \"done\"", text: $notificationCustomCommand) - .textFieldStyle(.roundedBorder) - .frame(width: 200) - } + SettingsCardRow( + configurationReview: .json("notifications.command"), + "Notification Command", + subtitle: "Run a shell command when a notification arrives. $CMUX_NOTIFICATION_TITLE, $CMUX_NOTIFICATION_SUBTITLE, $CMUX_NOTIFICATION_BODY are set." + ) { + TextField("say \"done\"", text: $notificationCustomCommand) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("app.sendAnonymousTelemetry"), - String(localized: "settings.app.telemetry", defaultValue: "Send anonymous telemetry"), - subtitle: sendAnonymousTelemetry != telemetryValueAtLaunch - ? String(localized: "settings.app.telemetry.subtitleChanged", defaultValue: "Change takes effect on next launch.") - : String(localized: "settings.app.telemetry.subtitle", defaultValue: "Share anonymized crash and usage data to help improve cmux.") - ) { - Toggle("", isOn: $sendAnonymousTelemetry) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + configurationReview: .json("app.sendAnonymousTelemetry"), + String(localized: "settings.app.telemetry", defaultValue: "Send anonymous telemetry"), + subtitle: sendAnonymousTelemetry != telemetryValueAtLaunch + ? String(localized: "settings.app.telemetry.subtitleChanged", defaultValue: "Change takes effect on next launch.") + : String(localized: "settings.app.telemetry.subtitle", defaultValue: "Share anonymized crash and usage data to help improve cmux.") + ) { + Toggle("", isOn: $sendAnonymousTelemetry) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("app.warnBeforeQuit"), - String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"), - subtitle: warnBeforeQuitShortcut - ? String(localized: "settings.app.warnBeforeQuit.subtitleOn", defaultValue: "Show a confirmation before quitting with Cmd+Q.") - : String(localized: "settings.app.warnBeforeQuit.subtitleOff", defaultValue: "Cmd+Q quits immediately without confirmation.") - ) { - Toggle("", isOn: $warnBeforeQuitShortcut) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + configurationReview: .json("app.warnBeforeQuit"), + String(localized: "settings.app.warnBeforeQuit", defaultValue: "Warn Before Quit"), + subtitle: warnBeforeQuitShortcut + ? String(localized: "settings.app.warnBeforeQuit.subtitleOn", defaultValue: "Show a confirmation before quitting with Cmd+Q.") + : String(localized: "settings.app.warnBeforeQuit.subtitleOff", defaultValue: "Cmd+Q quits immediately without confirmation.") + ) { + Toggle("", isOn: $warnBeforeQuitShortcut) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - String(localized: "settings.app.confirmCloseRunningProcess", defaultValue: "Confirm Close Running Process"), - subtitle: confirmCloseRunningProcess - ? String(localized: "settings.app.confirmCloseRunningProcess.subtitleOn", defaultValue: "Show a confirmation before closing a workspace with a running command.") - : String(localized: "settings.app.confirmCloseRunningProcess.subtitleOff", defaultValue: "Close workspaces without confirmation, even with running commands.") - ) { - Toggle("", isOn: $confirmCloseRunningProcess) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + String(localized: "settings.app.confirmCloseRunningProcess", defaultValue: "Confirm Close Running Process"), + subtitle: confirmCloseRunningProcess + ? String(localized: "settings.app.confirmCloseRunningProcess.subtitleOn", defaultValue: "Show a confirmation before closing a workspace with a running command.") + : String(localized: "settings.app.confirmCloseRunningProcess.subtitleOff", defaultValue: "Close workspaces without confirmation, even with running commands.") + ) { + Toggle("", isOn: $confirmCloseRunningProcess) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - String(localized: "settings.app.confirmClosePinnedWorkspace", defaultValue: "Confirm Close Pinned Workspace"), - subtitle: confirmClosePinnedWorkspace - ? String(localized: "settings.app.confirmClosePinnedWorkspace.subtitleOn", defaultValue: "Show a confirmation before closing a pinned workspace.") - : String(localized: "settings.app.confirmClosePinnedWorkspace.subtitleOff", defaultValue: "Close pinned workspaces without extra confirmation.") - ) { - Toggle("", isOn: $confirmClosePinnedWorkspace) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + String(localized: "settings.app.confirmClosePinnedWorkspace", defaultValue: "Confirm Close Pinned Workspace"), + subtitle: confirmClosePinnedWorkspace + ? String(localized: "settings.app.confirmClosePinnedWorkspace.subtitleOn", defaultValue: "Show a confirmation before closing a pinned workspace.") + : String(localized: "settings.app.confirmClosePinnedWorkspace.subtitleOff", defaultValue: "Close pinned workspaces without extra confirmation.") + ) { + Toggle("", isOn: $confirmClosePinnedWorkspace) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - String(localized: "settings.app.confirmCloseWindow", defaultValue: "Confirm Close Window"), - subtitle: confirmCloseWindow - ? String(localized: "settings.app.confirmCloseWindow.subtitleOn", defaultValue: "Show a confirmation before closing a window with workspaces.") - : String(localized: "settings.app.confirmCloseWindow.subtitleOff", defaultValue: "Close windows without confirmation.") - ) { - Toggle("", isOn: $confirmCloseWindow) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + String(localized: "settings.app.confirmCloseWindow", defaultValue: "Confirm Close Window"), + subtitle: confirmCloseWindow + ? String(localized: "settings.app.confirmCloseWindow.subtitleOn", defaultValue: "Show a confirmation before closing a window with workspaces.") + : String(localized: "settings.app.confirmCloseWindow.subtitleOff", defaultValue: "Close windows without confirmation.") + ) { + Toggle("", isOn: $confirmCloseWindow) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - String(localized: "settings.app.confirmBatchClose", defaultValue: "Confirm Batch Close"), - subtitle: confirmBatchClose - ? String(localized: "settings.app.confirmBatchClose.subtitleOn", defaultValue: "Show a confirmation before closing multiple tabs or workspaces at once.") - : String(localized: "settings.app.confirmBatchClose.subtitleOff", defaultValue: "Close multiple tabs or workspaces without confirmation.") - ) { - Toggle("", isOn: $confirmBatchClose) - .labelsHidden() - .controlSize(.small) - } - } // end app card rows part 2 + SettingsCardRow( + String(localized: "settings.app.confirmBatchClose", defaultValue: "Confirm Batch Close"), + subtitle: confirmBatchClose + ? String(localized: "settings.app.confirmBatchClose.subtitleOn", defaultValue: "Show a confirmation before closing multiple tabs or workspaces at once.") + : String(localized: "settings.app.confirmBatchClose.subtitleOff", defaultValue: "Close multiple tabs or workspaces without confirmation.") + ) { + Toggle("", isOn: $confirmBatchClose) + .labelsHidden() + .controlSize(.small) + } + } // end app card rows part 2 - Group { // app card rows part 3 - SettingsCardDivider() + Group { // app card rows part 3 + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("app.renameSelectsExistingName"), - String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), - subtitle: commandPaletteRenameSelectAllOnFocus - ? String(localized: "settings.app.renameSelectsName.subtitleOn", defaultValue: "Command Palette rename starts with all text selected.") - : String(localized: "settings.app.renameSelectsName.subtitleOff", defaultValue: "Command Palette rename keeps the caret at the end.") - ) { - Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + configurationReview: .json("app.renameSelectsExistingName"), + String(localized: "settings.app.renameSelectsName", defaultValue: "Rename Selects Existing Name"), + subtitle: commandPaletteRenameSelectAllOnFocus + ? String(localized: "settings.app.renameSelectsName.subtitleOn", defaultValue: "Command Palette rename starts with all text selected.") + : String(localized: "settings.app.renameSelectsName.subtitleOff", defaultValue: "Command Palette rename keeps the caret at the end.") + ) { + Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("app.commandPaletteSearchesAllSurfaces"), - String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), - subtitle: commandPaletteSearchAllSurfaces - ? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.") - : String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.") - ) { - Toggle("", isOn: $commandPaletteSearchAllSurfaces) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle") - .accessibilityLabel( - String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces") - ) - } + SettingsCardRow( + configurationReview: .json("app.commandPaletteSearchesAllSurfaces"), + String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"), + subtitle: commandPaletteSearchAllSurfaces + ? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.") + : String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.") + ) { + Toggle("", isOn: $commandPaletteSearchAllSurfaces) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle") + .accessibilityLabel( + String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces") + ) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("sidebar.hideAllDetails"), - String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), - subtitle: sidebarHideAllDetails - ? String(localized: "settings.app.hideAllSidebarDetails.subtitleOn", defaultValue: "Show only the workspace title row. Overrides the detail toggles below.") - : String(localized: "settings.app.hideAllSidebarDetails.subtitleOff", defaultValue: "Show secondary workspace details as controlled by the toggles below.") - ) { - Toggle("", isOn: $sidebarHideAllDetails) - .labelsHidden() - .controlSize(.small) - } + SettingsCardRow( + configurationReview: .json("sidebar.hideAllDetails"), + String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), + subtitle: sidebarHideAllDetails + ? String(localized: "settings.app.hideAllSidebarDetails.subtitleOn", defaultValue: "Show only the workspace title row. Overrides the detail toggles below.") + : String(localized: "settings.app.hideAllSidebarDetails.subtitleOff", defaultValue: "Show secondary workspace details as controlled by the toggles below.") + ) { + Toggle("", isOn: $sidebarHideAllDetails) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsPickerRow( - configurationReview: .json("sidebar.branchLayout"), - String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"), - subtitle: sidebarBranchVerticalLayout - ? String(localized: "settings.app.sidebarBranchLayout.subtitleVertical", defaultValue: "Vertical: each branch appears on its own line.") - : String(localized: "settings.app.sidebarBranchLayout.subtitleInline", defaultValue: "Inline: all branches share one line."), - controlWidth: pickerColumnWidth, - selection: $sidebarBranchVerticalLayout - ) { - Text(String(localized: "settings.app.sidebarBranchLayout.vertical", defaultValue: "Vertical")).tag(true) - Text(String(localized: "settings.app.sidebarBranchLayout.inline", defaultValue: "Inline")).tag(false) - } - .disabled(sidebarHideAllDetails) + SettingsPickerRow( + configurationReview: .json("sidebar.branchLayout"), + String(localized: "settings.app.sidebarBranchLayout", defaultValue: "Sidebar Branch Layout"), + subtitle: sidebarBranchVerticalLayout + ? String(localized: "settings.app.sidebarBranchLayout.subtitleVertical", defaultValue: "Vertical: each branch appears on its own line.") + : String(localized: "settings.app.sidebarBranchLayout.subtitleInline", defaultValue: "Inline: all branches share one line."), + controlWidth: pickerColumnWidth, + selection: $sidebarBranchVerticalLayout + ) { + Text(String(localized: "settings.app.sidebarBranchLayout.vertical", defaultValue: "Vertical")).tag(true) + Text(String(localized: "settings.app.sidebarBranchLayout.inline", defaultValue: "Inline")).tag(false) + } + .disabled(sidebarHideAllDetails) - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("sidebar.showNotificationMessage"), - String(localized: "settings.app.showNotificationMessage", defaultValue: "Show Notification Message in Sidebar"), - subtitle: String(localized: "settings.app.showNotificationMessage.subtitle", defaultValue: "Display the latest notification message below the workspace title.") - ) { - Toggle("", isOn: $sidebarShowNotificationMessage) - .labelsHidden() - .controlSize(.small) - } - .disabled(sidebarHideAllDetails) + SettingsCardRow( + configurationReview: .json("sidebar.showNotificationMessage"), + String(localized: "settings.app.showNotificationMessage", defaultValue: "Show Notification Message in Sidebar"), + subtitle: String(localized: "settings.app.showNotificationMessage.subtitle", defaultValue: "Display the latest notification message below the workspace title.") + ) { + Toggle("", isOn: $sidebarShowNotificationMessage) + .labelsHidden() + .controlSize(.small) + } + .disabled(sidebarHideAllDetails) - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("sidebar.showBranchDirectory"), - String(localized: "settings.app.showBranchDirectory", defaultValue: "Show Branch + Directory in Sidebar"), - subtitle: String(localized: "settings.app.showBranchDirectory.subtitle", defaultValue: "Display the built-in git branch and working-directory row.") - ) { - Toggle("", isOn: $sidebarShowBranchDirectory) - .labelsHidden() - .controlSize(.small) - } - .disabled(sidebarHideAllDetails) + SettingsCardRow( + configurationReview: .json("sidebar.showBranchDirectory"), + String(localized: "settings.app.showBranchDirectory", defaultValue: "Show Branch + Directory in Sidebar"), + subtitle: String(localized: "settings.app.showBranchDirectory.subtitle", defaultValue: "Display the built-in git branch and working-directory row.") + ) { + Toggle("", isOn: $sidebarShowBranchDirectory) + .labelsHidden() + .controlSize(.small) + } + .disabled(sidebarHideAllDetails) - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("sidebar.showPullRequests"), - String(localized: "settings.app.showPullRequests", defaultValue: "Show Pull Requests in Sidebar"), - subtitle: String(localized: "settings.app.showPullRequests.subtitle", defaultValue: "Display review items (PR/MR/etc.) with status, number, and clickable link.") - ) { - Toggle("", isOn: $sidebarShowPullRequest) - .labelsHidden() - .controlSize(.small) - } - .disabled(sidebarHideAllDetails) + SettingsCardRow( + configurationReview: .json("sidebar.showPullRequests"), + String(localized: "settings.app.showPullRequests", defaultValue: "Show Pull Requests in Sidebar"), + subtitle: String(localized: "settings.app.showPullRequests.subtitle", defaultValue: "Display review items (PR/MR/etc.) with status, number, and clickable link.") + ) { + Toggle("", isOn: $sidebarShowPullRequest) + .labelsHidden() + .controlSize(.small) + } + .disabled(sidebarHideAllDetails) - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("sidebar.openPullRequestLinksInCmuxBrowser"), - String(localized: "settings.app.openSidebarPRLinks", defaultValue: "Open Sidebar PR Links in cmux Browser"), - subtitle: openSidebarPullRequestLinksInCmuxBrowser - ? String(localized: "settings.app.openSidebarPRLinks.subtitleOn", defaultValue: "Clicks open inside cmux browser.") - : String(localized: "settings.app.openSidebarPRLinks.subtitleOff", defaultValue: "Clicks open in your default browser.") - ) { - Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser) - .labelsHidden() - .controlSize(.small) - } - .disabled(sidebarHideAllDetails) + SettingsCardRow( + configurationReview: .json("sidebar.openPullRequestLinksInCmuxBrowser"), + String(localized: "settings.app.openSidebarPRLinks", defaultValue: "Open Sidebar PR Links in cmux Browser"), + subtitle: openSidebarPullRequestLinksInCmuxBrowser + ? String(localized: "settings.app.openSidebarPRLinks.subtitleOn", defaultValue: "Clicks open inside cmux browser.") + : String(localized: "settings.app.openSidebarPRLinks.subtitleOff", defaultValue: "Clicks open in your default browser.") + ) { + Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + .disabled(sidebarHideAllDetails) - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("sidebar.openPortLinksInCmuxBrowser"), - String(localized: "settings.app.openSidebarPortLinks", defaultValue: "Open Sidebar Port Links in cmux Browser"), - subtitle: openSidebarPortLinksInCmuxBrowser - ? String(localized: "settings.app.openSidebarPortLinks.subtitleOn", defaultValue: "Port clicks open inside cmux browser.") - : String(localized: "settings.app.openSidebarPortLinks.subtitleOff", defaultValue: "Port clicks open in your default browser.") - ) { - Toggle("", isOn: $openSidebarPortLinksInCmuxBrowser) - .labelsHidden() - .controlSize(.small) - } - .disabled(sidebarHideAllDetails) + SettingsCardRow( + configurationReview: .json("sidebar.openPortLinksInCmuxBrowser"), + String(localized: "settings.app.openSidebarPortLinks", defaultValue: "Open Sidebar Port Links in cmux Browser"), + subtitle: openSidebarPortLinksInCmuxBrowser + ? String(localized: "settings.app.openSidebarPortLinks.subtitleOn", defaultValue: "Port clicks open inside cmux browser.") + : String(localized: "settings.app.openSidebarPortLinks.subtitleOff", defaultValue: "Port clicks open in your default browser.") + ) { + Toggle("", isOn: $openSidebarPortLinksInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + .disabled(sidebarHideAllDetails) - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("sidebar.showSSH"), - String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"), - subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.") - ) { - Toggle("", isOn: $sidebarShowSSH) - .labelsHidden() - .controlSize(.small) + SettingsCardRow( + configurationReview: .json("sidebar.showSSH"), + String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"), + subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.") + ) { + Toggle("", isOn: $sidebarShowSSH) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showPorts"), + String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), + subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.") + ) { + Toggle("", isOn: $sidebarShowPorts) + .labelsHidden() + .controlSize(.small) + } + .disabled(sidebarHideAllDetails) + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showLog"), + String(localized: "settings.app.showLog", defaultValue: "Show Latest Log in Sidebar"), + subtitle: String(localized: "settings.app.showLog.subtitle", defaultValue: "Display the latest imperative log/status message.") + ) { + Toggle("", isOn: $sidebarShowLog) + .labelsHidden() + .controlSize(.small) + } + .disabled(sidebarHideAllDetails) + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showProgress"), + String(localized: "settings.app.showProgress", defaultValue: "Show Progress in Sidebar"), + subtitle: String(localized: "settings.app.showProgress.subtitle", defaultValue: "Display the built-in progress bar from set_progress.") + ) { + Toggle("", isOn: $sidebarShowProgress) + .labelsHidden() + .controlSize(.small) + } + .disabled(sidebarHideAllDetails) + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.showCustomMetadata"), + String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"), + subtitle: String(localized: "settings.app.showMetadata.subtitle", defaultValue: "Display custom metadata from report_meta/set_status and report_meta_block.") + ) { + Toggle("", isOn: $sidebarShowMetadata) + .labelsHidden() + .controlSize(.small) + } + .disabled(sidebarHideAllDetails) + } // end app card rows part 3 + } + } // end if sectionVisible(.app) + } + + @ViewBuilder + private var settingsWorkspaceColorsSection: some View { + if sectionVisible(.workspaceColors) { + SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors"), section: .workspaceColors) + .id(SettingsSection.workspaceColors) + SettingsCard { + SettingsPickerRow( + configurationReview: .json("workspaceColors.indicatorStyle"), + String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"), + controlWidth: pickerColumnWidth, + selection: sidebarIndicatorStyleSelection + ) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("workspaceColors.selectionColor"), + String(localized: "settings.workspaceColors.selectionColor", defaultValue: "Selection Highlight"), + subtitle: String(localized: "settings.workspaceColors.selectionColor.subtitle", defaultValue: "Background color of the selected workspace in the sidebar.") + ) { + HStack(spacing: 8) { + if sidebarSelectionColorHex != nil { + Button(String(localized: "settings.workspaceColors.selectionColor.reset", defaultValue: "Reset")) { + sidebarSelectionColorHex = nil } + .buttonStyle(.bordered) + .controlSize(.small) + } - SettingsCardDivider() + ColorPicker( + "", + selection: selectionColorBinding, + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) - SettingsCardRow( - configurationReview: .json("sidebar.showPorts"), - String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), - subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.") - ) { - Toggle("", isOn: $sidebarShowPorts) - .labelsHidden() - .controlSize(.small) + Text(sidebarSelectionColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("workspaceColors.notificationBadgeColor"), + String(localized: "settings.workspaceColors.notificationBadgeColor", defaultValue: "Notification Badge"), + subtitle: String(localized: "settings.workspaceColors.notificationBadgeColor.subtitle", defaultValue: "Color of the unread notification badge on workspace tabs.") + ) { + HStack(spacing: 8) { + if sidebarNotificationBadgeColorHex != nil { + Button(String(localized: "settings.workspaceColors.notificationBadgeColor.reset", defaultValue: "Reset")) { + sidebarNotificationBadgeColorHex = nil } - .disabled(sidebarHideAllDetails) + .buttonStyle(.bordered) + .controlSize(.small) + } - SettingsCardDivider() + ColorPicker( + "", + selection: notificationBadgeColorBinding, + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) - SettingsCardRow( - configurationReview: .json("sidebar.showLog"), - String(localized: "settings.app.showLog", defaultValue: "Show Latest Log in Sidebar"), - subtitle: String(localized: "settings.app.showLog.subtitle", defaultValue: "Display the latest imperative log/status message.") - ) { - Toggle("", isOn: $sidebarShowLog) - .labelsHidden() - .controlSize(.small) - } - .disabled(sidebarHideAllDetails) + Text(sidebarNotificationBadgeColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("sidebar.showProgress"), - String(localized: "settings.app.showProgress", defaultValue: "Show Progress in Sidebar"), - subtitle: String(localized: "settings.app.showProgress.subtitle", defaultValue: "Display the built-in progress bar from set_progress.") - ) { - Toggle("", isOn: $sidebarShowProgress) - .labelsHidden() - .controlSize(.small) - } - .disabled(sidebarHideAllDetails) + SettingsCardNote( + String( + localized: "settings.workspaceColors.dictionaryNote", + defaultValue: "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." + ) + ) + if workspaceTabPaletteEntries.isEmpty { + SettingsCardNote( + String( + localized: "settings.workspaceColors.emptyPalette", + defaultValue: "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." + ) + ) + } else { + ForEach(Array(workspaceTabPaletteEntries.enumerated()), id: \.element.name) { index, entry in + if index > 0 { SettingsCardDivider() + } + SettingsCardRow( + configurationReview: .json("workspaceColors.colors"), + entry.name, + subtitle: baseTabColorHex(for: entry.name).map { + String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") + } ?? String( + localized: "settings.workspaceColors.customEntry", + defaultValue: "Named palette entry." + ) + ) { + HStack(spacing: 8) { + ColorPicker( + "", + selection: tabColorBinding(for: entry.name), + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) - SettingsCardRow( - configurationReview: .json("sidebar.showCustomMetadata"), - String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"), - subtitle: String(localized: "settings.app.showMetadata.subtitle", defaultValue: "Display custom metadata from report_meta/set_status and report_meta_block.") - ) { - Toggle("", isOn: $sidebarShowMetadata) - .labelsHidden() + Text(entry.hex) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + + if baseTabColorHex(for: entry.name) == nil { + Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { + removeWorkspaceColor(named: entry.name) + } + .buttonStyle(.bordered) .controlSize(.small) - } - .disabled(sidebarHideAllDetails) - } // end app card rows part 3 - } - } // end if sectionVisible(.app) - } // end Group: app section - Group { // Group: workspaceColors section - if sectionVisible(.workspaceColors) { - SettingsSectionHeader(title: String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors"), section: .workspaceColors) - .id(SettingsSection.workspaceColors) - SettingsCard { - SettingsPickerRow( - configurationReview: .json("workspaceColors.indicatorStyle"), - String(localized: "settings.workspaceColors.indicator", defaultValue: "Workspace Color Indicator"), - controlWidth: pickerColumnWidth, - selection: sidebarIndicatorStyleSelection - ) { - ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in - Text(style.displayName).tag(style.rawValue) } } + } + } + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("workspaceColors.selectionColor"), - String(localized: "settings.workspaceColors.selectionColor", defaultValue: "Selection Highlight"), - subtitle: String(localized: "settings.workspaceColors.selectionColor.subtitle", defaultValue: "Background color of the selected workspace in the sidebar.") - ) { - HStack(spacing: 8) { - if sidebarSelectionColorHex != nil { - Button(String(localized: "settings.workspaceColors.selectionColor.reset", defaultValue: "Reset")) { - sidebarSelectionColorHex = nil - } - .buttonStyle(.bordered) - .controlSize(.small) - } + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), + subtitle: String( + localized: "settings.workspaceColors.resetPalette.subtitleV2", + defaultValue: "Restore the built-in palette and remove extra named colors." + ) + ) { + Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { + resetWorkspaceTabColors() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } // end if sectionVisible(.workspaceColors) + } - ColorPicker( - "", - selection: selectionColorBinding, - supportsOpacity: false - ) - .labelsHidden() - .frame(width: 38) + @ViewBuilder + private var settingsSidebarAppearanceSection: some View { + if sectionVisible(.sidebarAppearance) { + SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance"), section: .sidebarAppearance) + .id(SettingsSection.sidebarAppearance) + SettingsCard { + SettingsCardRow( + configurationReview: .json("sidebarAppearance.matchTerminalBackground"), + String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), + subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.") + ) { + Toggle("", isOn: $sidebarMatchTerminalBackground) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } - Text(sidebarSelectionColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } + SettingsCardDivider() - SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("sidebarAppearance.lightModeTintColor"), + String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"), + subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.") + ) { + HStack(spacing: 8) { + ColorPicker( + String(localized: "settings.sidebarAppearance.tintColorLight.picker", defaultValue: "Light tint"), + selection: settingsSidebarTintLightBinding, + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) - SettingsCardRow( - configurationReview: .json("workspaceColors.notificationBadgeColor"), - String(localized: "settings.workspaceColors.notificationBadgeColor", defaultValue: "Notification Badge"), - subtitle: String(localized: "settings.workspaceColors.notificationBadgeColor.subtitle", defaultValue: "Color of the unread notification badge on workspace tabs.") - ) { - HStack(spacing: 8) { - if sidebarNotificationBadgeColorHex != nil { - Button(String(localized: "settings.workspaceColors.notificationBadgeColor.reset", defaultValue: "Reset")) { - sidebarNotificationBadgeColorHex = nil - } - .buttonStyle(.bordered) - .controlSize(.small) - } + Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } - ColorPicker( - "", - selection: notificationBadgeColorBinding, - supportsOpacity: false - ) - .labelsHidden() - .frame(width: 38) + SettingsCardDivider() - Text(sidebarNotificationBadgeColorHex ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } + SettingsCardRow( + configurationReview: .json("sidebarAppearance.darkModeTintColor"), + String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"), + subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.") + ) { + HStack(spacing: 8) { + ColorPicker( + String(localized: "settings.sidebarAppearance.tintColorDark.picker", defaultValue: "Dark tint"), + selection: settingsSidebarTintDarkBinding, + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) - SettingsCardDivider() + Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } - SettingsCardNote( - String( - localized: "settings.workspaceColors.dictionaryNote", - defaultValue: "Edit settings.json to add or remove named colors. \"Choose Custom Color...\" still adds local Custom N entries." - ) - ) + SettingsCardDivider() - if workspaceTabPaletteEntries.isEmpty { - SettingsCardNote( - String( - localized: "settings.workspaceColors.emptyPalette", - defaultValue: "No palette entries. Add colors in settings.json or use \"Choose Custom Color...\" from a workspace context menu." - ) - ) - } else { - ForEach(Array(workspaceTabPaletteEntries.enumerated()), id: \.element.name) { index, entry in - if index > 0 { - SettingsCardDivider() - } - SettingsCardRow( - configurationReview: .json("workspaceColors.colors"), - entry.name, - subtitle: baseTabColorHex(for: entry.name).map { - String(localized: "settings.workspaceColors.base", defaultValue: "Base: \($0)") - } ?? String( - localized: "settings.workspaceColors.customEntry", - defaultValue: "Named palette entry." - ) - ) { - HStack(spacing: 8) { - ColorPicker( - "", - selection: tabColorBinding(for: entry.name), - supportsOpacity: false - ) - .labelsHidden() - .frame(width: 38) - - Text(entry.hex) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - - if baseTabColorHex(for: entry.name) == nil { - Button(String(localized: "settings.workspaceColors.remove", defaultValue: "Remove")) { - removeWorkspaceColor(named: entry.name) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } - } - } + SettingsCardRow( + configurationReview: .json("sidebarAppearance.tintOpacity"), + String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"), + subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.") + ) { + HStack(spacing: 8) { + Slider(value: $sidebarTintOpacity, in: 0...1) + .frame(width: 140) + Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + } + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .action, - String(localized: "settings.workspaceColors.resetPalette", defaultValue: "Reset Palette"), - subtitle: String( - localized: "settings.workspaceColors.resetPalette.subtitleV2", - defaultValue: "Restore the built-in palette and remove extra named colors." - ) - ) { - Button(String(localized: "settings.workspaceColors.resetPalette.button", defaultValue: "Reset")) { - resetWorkspaceTabColors() + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"), + subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.") + ) { + Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) { + sidebarTintHexLight = nil + sidebarTintHexDark = nil + sidebarTintHex = SidebarTintDefaults.hex + sidebarTintOpacity = SidebarTintDefaults.opacity + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } // end if sectionVisible(.sidebarAppearance) + } + + @ViewBuilder + private var settingsAutomationSection: some View { + if sectionVisible(.automation) { + SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation"), section: .automation) + .id(SettingsSection.automation) + SettingsCard { + SettingsPickerRow( + configurationReview: .json("automation.socketControlMode"), + String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), + subtitle: selectedSocketControlMode.description, + controlWidth: pickerColumnWidth, + selection: socketModeSelection, + accessibilityId: "AutomationSocketModePicker" + ) { + ForEach(SocketControlMode.uiCases) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } + + SettingsCardDivider() + + SettingsCardNote(String(localized: "settings.automation.socketMode.note", defaultValue: "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.")) + if selectedSocketControlMode == .password { + SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("automation.socketPassword"), + String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), + subtitle: hasSocketPasswordConfigured + ? String(localized: "settings.automation.socketPassword.subtitleSet", defaultValue: "Stored in Application Support.") + : String(localized: "settings.automation.socketPassword.subtitleUnset", defaultValue: "No password set. External clients will be blocked until one is configured.") + ) { + HStack(spacing: 8) { + SecureField(String(localized: "settings.automation.socketPassword.placeholder", defaultValue: "Password"), text: $socketPasswordDraft) + .textFieldStyle(.roundedBorder) + .frame(width: 170) + Button(hasSocketPasswordConfigured ? String(localized: "settings.automation.socketPassword.change", defaultValue: "Change") : String(localized: "settings.automation.socketPassword.set", defaultValue: "Set")) { + saveSocketPassword() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + if hasSocketPasswordConfigured { + Button(String(localized: "settings.automation.socketPassword.clear", defaultValue: "Clear")) { + clearSocketPassword() } .buttonStyle(.bordered) .controlSize(.small) } } - } // end if sectionVisible(.workspaceColors) - } // end Group: workspaceColors section - Group { // Group: sidebarAppearance section - if sectionVisible(.sidebarAppearance) { - SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance"), section: .sidebarAppearance) - .id(SettingsSection.sidebarAppearance) - SettingsCard { - SettingsCardRow( - configurationReview: .json("sidebarAppearance.matchTerminalBackground"), - String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), - subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.") - ) { - Toggle("", isOn: $sidebarMatchTerminalBackground) - .labelsHidden() - .toggleStyle(.switch) - .controlSize(.small) - } + } + if let message = socketPasswordStatusMessage { + Text(message) + .font(.caption) + .foregroundStyle(socketPasswordStatusIsError ? Color.red : Color.secondary) + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + } + if selectedSocketControlMode == .allowAll { + SettingsCardDivider() + Text(String(localized: "settings.automation.openAccessWarning", defaultValue: "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.")) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + SettingsCardNote(String(localized: "settings.automation.socketOverrides.note", defaultValue: "Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).")) + } - SettingsCardDivider() + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.claudeCodeIntegration"), + String(localized: "settings.automation.agentIntegration", defaultValue: "Agent Integration"), + subtitle: claudeCodeHooksEnabled + ? String(localized: "settings.automation.agentIntegration.subtitleOn", defaultValue: "Sidebar shows agent session status and notifications.") + : String(localized: "settings.automation.agentIntegration.subtitleOff", defaultValue: "Agent harness runs without cmux integration.") + ) { + Toggle("", isOn: $claudeCodeHooksEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsAgentHooksToggle") + } - SettingsCardRow( - configurationReview: .json("sidebarAppearance.lightModeTintColor"), - String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"), - subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.") - ) { - HStack(spacing: 8) { - ColorPicker( - String(localized: "settings.sidebarAppearance.tintColorLight.picker", defaultValue: "Light tint"), - selection: settingsSidebarTintLightBinding, - supportsOpacity: false - ) - .labelsHidden() - .frame(width: 38) + SettingsCardDivider() - Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } + SettingsCardNote(String(localized: "settings.automation.agentIntegration.note", defaultValue: "When enabled, cmux wraps the agent command to inject session tracking and notification hooks. Disable if you prefer to manage agent hooks yourself.")) + } - SettingsCardDivider() + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.claudeBinaryPath"), + String(localized: "settings.automation.agentIntegration.binaryPath", defaultValue: "Agent Binary Path"), + subtitle: String(localized: "settings.automation.agentIntegration.binaryPath.subtitle", defaultValue: "Custom path to the agent binary. Leave empty to use PATH.") + ) { + TextField( + String(localized: "settings.automation.agentIntegration.binaryPath.placeholder", defaultValue: "e.g. /usr/local/bin/claude"), + text: $customClaudePath + ) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + } - SettingsCardRow( - configurationReview: .json("sidebarAppearance.darkModeTintColor"), - String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"), - subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.") - ) { - HStack(spacing: 8) { - ColorPicker( - String(localized: "settings.sidebarAppearance.tintColorDark.picker", defaultValue: "Dark tint"), - selection: settingsSidebarTintDarkBinding, - supportsOpacity: false - ) - .labelsHidden() - .frame(width: 38) + SettingsCard { + SettingsCardRow( + String(localized: "settings.automation.warmAgentPool", defaultValue: "Warm Agent Pool"), + subtitle: warmClaudePoolSize > 0 + ? String(localized: "settings.automation.warmAgentPool.subtitleOn", defaultValue: "Pre-spawns agent sessions for instant launch via ⇧⌘K.") + : String(localized: "settings.automation.warmAgentPool.subtitleOff", defaultValue: "Agent sessions launch on demand (no pre-warming).") + ) { + Stepper(value: $warmClaudePoolSize, in: 0...3) { + Text("\(warmClaudePoolSize)") + .monospacedDigit() + } + .frame(width: 100) + } + } - Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default")) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 76, alignment: .trailing) - } - } + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.cursorIntegration"), + String(localized: "settings.automation.cursor", defaultValue: "Cursor Integration"), + subtitle: cursorHooksEnabled + ? String(localized: "settings.automation.cursor.subtitleOn", defaultValue: "Sidebar shows Cursor agent status and notifications.") + : String(localized: "settings.automation.cursor.subtitleOff", defaultValue: "Cursor runs without cmux integration.") + ) { + Toggle("", isOn: $cursorHooksEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsCursorHooksToggle") + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("sidebarAppearance.tintOpacity"), - String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"), - subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.") - ) { - HStack(spacing: 8) { - Slider(value: $sidebarTintOpacity, in: 0...1) - .frame(width: 140) - Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 36, alignment: .trailing) - } - } + SettingsCardNote(String(localized: "settings.automation.cursor.note", defaultValue: "Hooks must be installed with `cmux cursor install-hooks`. They no-op outside cmux terminals.")) + } - SettingsCardDivider() + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.geminiIntegration"), + String(localized: "settings.automation.gemini", defaultValue: "Gemini CLI Integration"), + subtitle: geminiHooksEnabled + ? String(localized: "settings.automation.gemini.subtitleOn", defaultValue: "Sidebar shows Gemini session status and notifications.") + : String(localized: "settings.automation.gemini.subtitleOff", defaultValue: "Gemini runs without cmux integration.") + ) { + Toggle("", isOn: $geminiHooksEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsGeminiHooksToggle") + } - SettingsCardRow( - configurationReview: .action, - String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"), - subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.") - ) { - Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) { - sidebarTintHexLight = nil - sidebarTintHexDark = nil - sidebarTintHex = SidebarTintDefaults.hex - sidebarTintOpacity = SidebarTintDefaults.opacity - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } // end if sectionVisible(.sidebarAppearance) - } // end Group: sidebarAppearance section - Group { // Group: automation section - if sectionVisible(.automation) { - SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation"), section: .automation) - .id(SettingsSection.automation) - SettingsCard { - SettingsPickerRow( - configurationReview: .json("automation.socketControlMode"), - String(localized: "settings.automation.socketMode", defaultValue: "Socket Control Mode"), - subtitle: selectedSocketControlMode.description, - controlWidth: pickerColumnWidth, - selection: socketModeSelection, - accessibilityId: "AutomationSocketModePicker" - ) { - ForEach(SocketControlMode.uiCases) { mode in - Text(mode.displayName).tag(mode.rawValue) - } - } + SettingsCardDivider() - SettingsCardDivider() + SettingsCardNote(String(localized: "settings.automation.gemini.note", defaultValue: "Hooks must be installed with `cmux gemini install-hooks`. They no-op outside cmux terminals.")) + } - SettingsCardNote(String(localized: "settings.automation.socketMode.note", defaultValue: "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model.")) - if selectedSocketControlMode == .password { - SettingsCardDivider() - SettingsCardRow( - configurationReview: .json("automation.socketPassword"), - String(localized: "settings.automation.socketPassword", defaultValue: "Socket Password"), - subtitle: hasSocketPasswordConfigured - ? String(localized: "settings.automation.socketPassword.subtitleSet", defaultValue: "Stored in Application Support.") - : String(localized: "settings.automation.socketPassword.subtitleUnset", defaultValue: "No password set. External clients will be blocked until one is configured.") - ) { - HStack(spacing: 8) { - SecureField(String(localized: "settings.automation.socketPassword.placeholder", defaultValue: "Password"), text: $socketPasswordDraft) - .textFieldStyle(.roundedBorder) - .frame(width: 170) - Button(hasSocketPasswordConfigured ? String(localized: "settings.automation.socketPassword.change", defaultValue: "Change") : String(localized: "settings.automation.socketPassword.set", defaultValue: "Set")) { - saveSocketPassword() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(socketPasswordDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - if hasSocketPasswordConfigured { - Button(String(localized: "settings.automation.socketPassword.clear", defaultValue: "Clear")) { - clearSocketPassword() - } - .buttonStyle(.bordered) - .controlSize(.small) - } - } - } - if let message = socketPasswordStatusMessage { - Text(message) - .font(.caption) - .foregroundStyle(socketPasswordStatusIsError ? Color.red : Color.secondary) - .padding(.horizontal, 14) - .padding(.bottom, 8) - } - } - if selectedSocketControlMode == .allowAll { - SettingsCardDivider() - Text(String(localized: "settings.automation.openAccessWarning", defaultValue: "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging.")) - .font(.caption) - .foregroundStyle(.red) - .padding(.horizontal, 14) - .padding(.vertical, 8) - } - SettingsCardNote(String(localized: "settings.automation.socketOverrides.note", defaultValue: "Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).")) - } + SettingsCard { + SettingsCardRow(configurationReview: .json("automation.portBase"), String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), controlWidth: pickerColumnWidth) { + TextField("", value: $cmuxPortBase, format: .number) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + } - SettingsCard { - SettingsCardRow( - configurationReview: .json("automation.claudeCodeIntegration"), - String(localized: "settings.automation.agentIntegration", defaultValue: "Agent Integration"), - subtitle: claudeCodeHooksEnabled - ? String(localized: "settings.automation.agentIntegration.subtitleOn", defaultValue: "Sidebar shows agent session status and notifications.") - : String(localized: "settings.automation.agentIntegration.subtitleOff", defaultValue: "Agent harness runs without cmux integration.") - ) { - Toggle("", isOn: $claudeCodeHooksEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsAgentHooksToggle") - } + SettingsCardDivider() - SettingsCardDivider() + SettingsCardRow(configurationReview: .json("automation.portRange"), String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), subtitle: String(localized: "settings.automation.portRange.subtitle", defaultValue: "Number of ports per workspace."), controlWidth: pickerColumnWidth) { + TextField("", value: $cmuxPortRange, format: .number) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + } - SettingsCardNote(String(localized: "settings.automation.agentIntegration.note", defaultValue: "When enabled, cmux wraps the agent command to inject session tracking and notification hooks. Disable if you prefer to manage agent hooks yourself.")) - } + SettingsCardDivider() - SettingsCard { - SettingsCardRow( - configurationReview: .json("automation.claudeBinaryPath"), - String(localized: "settings.automation.agentIntegration.binaryPath", defaultValue: "Agent Binary Path"), - subtitle: String(localized: "settings.automation.agentIntegration.binaryPath.subtitle", defaultValue: "Custom path to the agent binary. Leave empty to use PATH.") - ) { - TextField( - String(localized: "settings.automation.agentIntegration.binaryPath.placeholder", defaultValue: "e.g. /usr/local/bin/claude"), - text: $customClaudePath - ) - .textFieldStyle(.roundedBorder) - .frame(width: 200) - } - } + SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) + } + } // end if sectionVisible(.automation) + } - SettingsCard { - SettingsCardRow( - String(localized: "settings.automation.warmAgentPool", defaultValue: "Warm Agent Pool"), - subtitle: warmClaudePoolSize > 0 - ? String(localized: "settings.automation.warmAgentPool.subtitleOn", defaultValue: "Pre-spawns agent sessions for instant launch via ⇧⌘K.") - : String(localized: "settings.automation.warmAgentPool.subtitleOff", defaultValue: "Agent sessions launch on demand (no pre-warming).") - ) { - Stepper(value: $warmClaudePoolSize, in: 0...3) { - Text("\(warmClaudePoolSize)") - .monospacedDigit() - } - .frame(width: 100) - } + @ViewBuilder + private var settingsCustomCommandsAndBrowserSection: some View { + if sectionVisible(.customCommands) { + SettingsSectionHeader(title: String(localized: "settings.section.customCommands", defaultValue: "Custom Commands"), section: .customCommands) + .id(SettingsSection.customCommands) + SettingsCard { + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + configurationReview: .json("customCommands.trustedDirectories"), + String(localized: "settings.customCommands.trustedDirectories", defaultValue: "Trusted Directories"), + subtitle: String(localized: "settings.customCommands.trustedDirectories.subtitle", defaultValue: "Commands from cmux.json in these directories run without confirmation. One path per line.") + ) { + EmptyView() + } + + TextEditor(text: $trustedDirectoriesDraft) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + .onChange(of: trustedDirectoriesDraft) { _ in + saveTrustedDirectories() } + } - SettingsCard { - SettingsCardRow( - configurationReview: .json("automation.cursorIntegration"), - String(localized: "settings.automation.cursor", defaultValue: "Cursor Integration"), - subtitle: cursorHooksEnabled - ? String(localized: "settings.automation.cursor.subtitleOn", defaultValue: "Sidebar shows Cursor agent status and notifications.") - : String(localized: "settings.automation.cursor.subtitleOff", defaultValue: "Cursor runs without cmux integration.") - ) { - Toggle("", isOn: $cursorHooksEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsCursorHooksToggle") - } + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.customCommands.trustedDirectories.note", defaultValue: "Place a cmux.json in your project root to define custom commands. Trust a directory from the confirmation dialog, or add paths here. For git repos, trusting the root covers all subdirectories.")) + } + } // end if sectionVisible(.customCommands) + + if sectionVisible(.browser) { + SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser"), section: .browser) + .id(SettingsSection.browser) + .accessibilityIdentifier("SettingsBrowserSection") + SettingsCard { + SettingsPickerRow( + configurationReview: .json("browser.defaultSearchEngine"), + String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), + subtitle: String(localized: "settings.browser.searchEngine.subtitle", defaultValue: "Used by the browser address bar when input is not a URL."), + controlWidth: pickerColumnWidth, + selection: $browserSearchEngine + ) { + ForEach(BrowserSearchEngine.allCases) { engine in + Text(engine.displayName).tag(engine.rawValue) + } + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardNote(String(localized: "settings.automation.cursor.note", defaultValue: "Hooks must be installed with `cmux cursor install-hooks`. They no-op outside cmux terminals.")) - } + SettingsCardRow(configurationReview: .json("browser.showSearchSuggestions"), String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions")) { + Toggle("", isOn: $browserSearchSuggestionsEnabled) + .labelsHidden() + .controlSize(.small) + } - SettingsCard { - SettingsCardRow( - configurationReview: .json("automation.geminiIntegration"), - String(localized: "settings.automation.gemini", defaultValue: "Gemini CLI Integration"), - subtitle: geminiHooksEnabled - ? String(localized: "settings.automation.gemini.subtitleOn", defaultValue: "Sidebar shows Gemini session status and notifications.") - : String(localized: "settings.automation.gemini.subtitleOff", defaultValue: "Gemini runs without cmux integration.") - ) { - Toggle("", isOn: $geminiHooksEnabled) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsGeminiHooksToggle") - } + SettingsCardDivider() - SettingsCardDivider() + SettingsPickerRow( + configurationReview: .json("browser.theme"), + String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), + subtitle: selectedBrowserThemeMode == .system + ? String(localized: "settings.browser.theme.subtitleSystem", defaultValue: "System follows app and macOS appearance.") + : String(localized: "settings.browser.theme.subtitleForced", defaultValue: "\(selectedBrowserThemeMode.displayName) forces that color scheme for compatible pages."), + controlWidth: pickerColumnWidth, + selection: browserThemeModeSelection + ) { + ForEach(BrowserThemeMode.allCases) { mode in + Text(mode.displayName).tag(mode.rawValue) + } + } - SettingsCardNote(String(localized: "settings.automation.gemini.note", defaultValue: "Hooks must be installed with `cmux gemini install-hooks`. They no-op outside cmux terminals.")) - } + SettingsCardDivider() - SettingsCard { - SettingsCardRow(configurationReview: .json("automation.portBase"), String(localized: "settings.automation.portBase", defaultValue: "Port Base"), subtitle: String(localized: "settings.automation.portBase.subtitle", defaultValue: "Starting port for CMUX_PORT env var."), controlWidth: pickerColumnWidth) { - TextField("", value: $cmuxPortBase, format: .number) - .textFieldStyle(.roundedBorder) - .multilineTextAlignment(.trailing) - } + SettingsCardRow( + configurationReview: .json("browser.openTerminalLinksInCmuxBrowser"), + String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in cmux Browser"), + subtitle: String(localized: "settings.browser.openTerminalLinks.subtitle", defaultValue: "When off, links clicked in terminal output open in your default browser.") + ) { + Toggle("", isOn: $openTerminalLinksInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow(configurationReview: .json("automation.portRange"), String(localized: "settings.automation.portRange", defaultValue: "Port Range Size"), subtitle: String(localized: "settings.automation.portRange.subtitle", defaultValue: "Number of ports per workspace."), controlWidth: pickerColumnWidth) { - TextField("", value: $cmuxPortRange, format: .number) - .textFieldStyle(.roundedBorder) - .multilineTextAlignment(.trailing) - } + SettingsCardRow( + configurationReview: .json("browser.interceptTerminalOpenCommandInCmuxBrowser"), + String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), + subtitle: String(localized: "settings.browser.interceptOpen.subtitle", defaultValue: "When off, `open https://...` and `open http://...` always use your default browser.") + ) { + Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } - SettingsCardDivider() + if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser { + SettingsCardDivider() - SettingsCardNote(String(localized: "settings.automation.port.note", defaultValue: "Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")) + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + configurationReview: .json("browser.hostsToOpenInEmbeddedBrowser"), + String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), + subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") + ) { + EmptyView() } - } // end if sectionVisible(.automation) - } // end Group: automation section - Group { // Group: customCommands + browser sections - if sectionVisible(.customCommands) { - SettingsSectionHeader(title: String(localized: "settings.section.customCommands", defaultValue: "Custom Commands"), section: .customCommands) - .id(SettingsSection.customCommands) - SettingsCard { - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - configurationReview: .json("customCommands.trustedDirectories"), - String(localized: "settings.customCommands.trustedDirectories", defaultValue: "Trusted Directories"), - subtitle: String(localized: "settings.customCommands.trustedDirectories.subtitle", defaultValue: "Commands from cmux.json in these directories run without confirmation. One path per line.") - ) { - EmptyView() - } - TextEditor(text: $trustedDirectoriesDraft) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) - .onChange(of: trustedDirectoriesDraft) { _ in - saveTrustedDirectories() - } - } + TextEditor(text: $browserHostWhitelist) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } - SettingsCardDivider() - SettingsCardNote(String(localized: "settings.customCommands.trustedDirectories.note", defaultValue: "Place a cmux.json in your project root to define custom commands. Trust a directory from the confirmation dialog, or add paths here. For git repos, trusting the root covers all subdirectories.")) + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + configurationReview: .json("browser.urlsToAlwaysOpenExternally"), + String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), + subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage)).") + ) { + EmptyView() } - } // end if sectionVisible(.customCommands) - - if sectionVisible(.browser) { - SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser"), section: .browser) - .id(SettingsSection.browser) - .accessibilityIdentifier("SettingsBrowserSection") - SettingsCard { - SettingsPickerRow( - configurationReview: .json("browser.defaultSearchEngine"), - String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"), - subtitle: String(localized: "settings.browser.searchEngine.subtitle", defaultValue: "Used by the browser address bar when input is not a URL."), - controlWidth: pickerColumnWidth, - selection: $browserSearchEngine - ) { - ForEach(BrowserSearchEngine.allCases) { engine in - Text(engine.displayName).tag(engine.rawValue) - } - } - SettingsCardDivider() + TextEditor(text: $browserExternalOpenPatterns) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + } - SettingsCardRow(configurationReview: .json("browser.showSearchSuggestions"), String(localized: "settings.browser.searchSuggestions", defaultValue: "Show Search Suggestions")) { - Toggle("", isOn: $browserSearchSuggestionsEnabled) - .labelsHidden() - .controlSize(.small) - } + SettingsCardDivider() - SettingsCardDivider() + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.httpAllowlist", defaultValue: "HTTP Hosts Allowed in Embedded Browser")) + .font(.system(size: 13, weight: .semibold)) - SettingsPickerRow( - configurationReview: .json("browser.theme"), - String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), - subtitle: selectedBrowserThemeMode == .system - ? String(localized: "settings.browser.theme.subtitleSystem", defaultValue: "System follows app and macOS appearance.") - : String(localized: "settings.browser.theme.subtitleForced", defaultValue: "\(selectedBrowserThemeMode.displayName) forces that color scheme for compatible pages."), - controlWidth: pickerColumnWidth, - selection: browserThemeModeSelection - ) { - ForEach(BrowserThemeMode.allCases) { mode in - Text(mode.displayName).tag(mode.rawValue) - } - } + Text(String(localized: "settings.browser.httpAllowlist.description", defaultValue: "Controls which HTTP (non-HTTPS) hosts can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.")) + .font(.caption) + .foregroundStyle(.secondary) - SettingsCardDivider() + TextEditor(text: $browserInsecureHTTPAllowlistDraft) + .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") - SettingsCardRow( - configurationReview: .json("browser.openTerminalLinksInCmuxBrowser"), - String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in cmux Browser"), - subtitle: String(localized: "settings.browser.openTerminalLinks.subtitle", defaultValue: "When off, links clicked in terminal output open in your default browser.") - ) { - Toggle("", isOn: $openTerminalLinksInCmuxBrowser) - .labelsHidden() - .controlSize(.small) - } + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 10) { + Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) - SettingsCardDivider() + Spacer(minLength: 0) - SettingsCardRow( - configurationReview: .json("browser.interceptTerminalOpenCommandInCmuxBrowser"), - String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), - subtitle: String(localized: "settings.browser.interceptOpen.subtitle", defaultValue: "When off, `open https://...` and `open http://...` always use your default browser.") - ) { - Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser) - .labelsHidden() - .controlSize(.small) + Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { + saveBrowserInsecureHTTPAllowlist() } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } - if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser { - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - configurationReview: .json("browser.hostsToOpenInEmbeddedBrowser"), - String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), - subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") - ) { - EmptyView() - } - - TextEditor(text: $browserHostWhitelist) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } - - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - configurationReview: .json("browser.urlsToAlwaysOpenExternally"), - String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), - subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage)).") - ) { - EmptyView() - } + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) + .font(.caption) + .foregroundStyle(.secondary) - TextEditor(text: $browserExternalOpenPatterns) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) + HStack { + Spacer(minLength: 0) + Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { + saveBrowserInsecureHTTPAllowlist() } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") } + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.httpAllowlist", defaultValue: "HTTP Hosts Allowed in Embedded Browser")) - .font(.system(size: 13, weight: .semibold)) - - Text(String(localized: "settings.browser.httpAllowlist.description", defaultValue: "Controls which HTTP (non-HTTPS) hosts can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.")) - .font(.caption) - .foregroundStyle(.secondary) - - TextEditor(text: $browserInsecureHTTPAllowlistDraft) - .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") + SettingsCardDivider() - ViewThatFits(in: .horizontal) { - HStack(alignment: .center, spacing: 10) { - Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) + .font(.system(size: 13, weight: .semibold)) - Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) + .font(.system(size: 12.5, weight: .semibold)) - Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { - saveBrowserInsecureHTTPAllowlist() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) - .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") - } + Text(browserImportSubtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.httpAllowlist.hint", defaultValue: "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).")) - .font(.caption) - .foregroundStyle(.secondary) + Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1) + ) + } - HStack { - Spacer(minLength: 0) - Button(String(localized: "settings.browser.httpAllowlist.save", defaultValue: "Save")) { - saveBrowserInsecureHTTPAllowlist() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) - .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") - } - } - } + HStack(spacing: 8) { + Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + refreshDetectedImportBrowsers() } - .padding(.horizontal, 14) - .padding(.vertical, 10) - - SettingsCardDivider() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportChooseButton") - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) - .font(.system(size: 13, weight: .semibold)) + Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { + refreshDetectedImportBrowsers() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .accessibilityIdentifier("SettingsBrowserImportActions") - VStack(alignment: .leading, spacing: 6) { - Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) - .font(.system(size: 12.5, weight: .semibold)) + Toggle( + String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), + isOn: browserImportHintVisibilityBinding + ) + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserImportHintToggle") - Text(browserImportSubtitle) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + Text(browserImportHintSettingsNote) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .id(SettingsNavigationTarget.browserImport) + .accessibilityIdentifier("SettingsBrowserImportSection") + .padding(.horizontal, 14) + .padding(.vertical, 10) - Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) - .font(.system(size: 10.5)) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1) - ) - } + SettingsCardDivider() - HStack(spacing: 8) { - Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() - refreshDetectedImportBrowsers() - } - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsBrowserImportChooseButton") + SettingsCardRow( + configurationReview: .json("browser.reactGrabVersion"), + String(localized: "settings.browser.reactGrabVersion", defaultValue: "React Grab Version"), + subtitle: String(localized: "settings.browser.reactGrabVersion.subtitle", defaultValue: "Pinned npm version of react-grab injected by the toolbar button (Cmd+Shift+G). Only versions with a known integrity hash are accepted.") + ) { + TextField("", text: $reactGrabVersion) + .textFieldStyle(.roundedBorder) + .frame(width: 100) + .font(.system(.body, design: .monospaced)) + .accessibilityIdentifier("SettingsReactGrabVersionField") + } - Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { - refreshDetectedImportBrowsers() - } - .buttonStyle(.bordered) - .controlSize(.small) - } - .accessibilityIdentifier("SettingsBrowserImportActions") + SettingsCardDivider() - Toggle( - String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), - isOn: browserImportHintVisibilityBinding - ) - .controlSize(.small) - .accessibilityIdentifier("SettingsBrowserImportHintToggle") + SettingsCardRow(configurationReview: .action, String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { + Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { + showClearBrowserHistoryConfirmation = true + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(browserHistoryEntryCount == 0) + } + } + } // end if sectionVisible(.browser) + } - Text(browserImportHintSettingsNote) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .id(SettingsNavigationTarget.browserImport) - .accessibilityIdentifier("SettingsBrowserImportSection") - .padding(.horizontal, 14) - .padding(.vertical, 10) + @ViewBuilder + private var settingsHotkeyAndShortcutsSection: some View { + GlobalHotkeySection() - SettingsCardDivider() + if sectionVisible(.keyboardShortcuts) { + SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), section: .keyboardShortcuts) + .id(SettingsSection.keyboardShortcuts) + .accessibilityIdentifier("SettingsKeyboardShortcutsSection") + SettingsCard { + SettingsCardRow( + configurationReview: .action, + String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), + subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"].") + ) { + HStack(spacing: 8) { + Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) + .font(.caption) + .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") - SettingsCardRow( - configurationReview: .json("browser.reactGrabVersion"), - String(localized: "settings.browser.reactGrabVersion", defaultValue: "React Grab Version"), - subtitle: String(localized: "settings.browser.reactGrabVersion.subtitle", defaultValue: "Pinned npm version of react-grab injected by the toolbar button (Cmd+Shift+G). Only versions with a known integrity hash are accepted.") - ) { - TextField("", text: $reactGrabVersion) - .textFieldStyle(.roundedBorder) - .frame(width: 100) - .font(.system(.body, design: .monospaced)) - .accessibilityIdentifier("SettingsReactGrabVersionField") - } + Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { + openCmuxSettingsFileInTextEdit() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsKeyboardShortcutsOpenSettingsFileButton") + } + } - SettingsCardDivider() + SettingsCardDivider() - SettingsCardRow(configurationReview: .action, String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { - Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { - showClearBrowserHistoryConfirmation = true - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(browserHistoryEntryCount == 0) - } - } - } // end if sectionVisible(.browser) - } // end Group: customCommands + browser sections - Group { // Group: hotkey + keyboardShortcuts + reset sections - GlobalHotkeySection() - - if sectionVisible(.keyboardShortcuts) { - SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), section: .keyboardShortcuts) - .id(SettingsSection.keyboardShortcuts) - .accessibilityIdentifier("SettingsKeyboardShortcutsSection") - SettingsCard { - SettingsCardRow( - configurationReview: .action, - String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), - subtitle: String(localized: "settings.shortcuts.chords.subtitle", defaultValue: "Add tmux-style multi-step shortcuts in settings.json, for example [\"ctrl+b\", \"c\"].") - ) { - HStack(spacing: 8) { - Link(String(localized: "settings.shortcuts.chords.docsButton", defaultValue: "Chord docs"), destination: shortcutChordsDocsURL) - .font(.caption) - .accessibilityIdentifier("SettingsKeyboardShortcutsChordDocsLink") + SettingsCardRow( + configurationReview: .json("shortcuts.showModifierHoldHints"), + String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), + subtitle: showShortcutHintsOnCommandHold + ? String(localized: "settings.shortcuts.showHints.subtitleOn", defaultValue: "Holding Cmd (sidebar/titlebar) or Ctrl/Cmd (pane tabs) shows shortcut hint pills.") + : String(localized: "settings.shortcuts.showHints.subtitleOff", defaultValue: "Holding Cmd or Ctrl keeps shortcut hint pills hidden.") + ) { + Toggle("", isOn: $showShortcutHintsOnCommandHold) + .labelsHidden() + .controlSize(.small) + } - Button(String(localized: "settings.app.settingsFile.openButton", defaultValue: "Open settings.json")) { - openCmuxSettingsFileInTextEdit() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsKeyboardShortcutsOpenSettingsFileButton") - } - } + SettingsCardDivider() - SettingsCardDivider() + let actions = KeyboardShortcutSettings.Action.allCases.filter { + $0 != SystemWideHotkeySettings.action + } + ForEach(Array(actions.enumerated()), id: \.element.id) { index, action in + ShortcutSettingRow(action: action) + .padding(.horizontal, 14) + .padding(.vertical, 9) + if index < actions.count - 1 { + SettingsCardDivider() + } + } + } + .id(shortcutResetToken) - SettingsCardRow( - configurationReview: .json("shortcuts.showModifierHoldHints"), - String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"), - subtitle: showShortcutHintsOnCommandHold - ? String(localized: "settings.shortcuts.showHints.subtitleOn", defaultValue: "Holding Cmd (sidebar/titlebar) or Ctrl/Cmd (pane tabs) shows shortcut hint pills.") - : String(localized: "settings.shortcuts.showHints.subtitleOff", defaultValue: "Holding Cmd or Ctrl keeps shortcut hint pills hidden.") - ) { - Toggle("", isOn: $showShortcutHintsOnCommandHold) - .labelsHidden() - .controlSize(.small) - } + Text(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record a new shortcut.")) + .font(.caption) + .foregroundColor(.secondary) + .padding(.leading, 2) + .accessibilityIdentifier("ShortcutRecordingHint") + } // end if sectionVisible(.keyboardShortcuts) - SettingsCardDivider() + if sectionVisible(.reset) { + SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset"), section: .reset) + .id(SettingsSection.reset) + SettingsCard { + HStack { + Spacer(minLength: 0) + Button(String(localized: "settings.reset.resetAll", defaultValue: "Reset All Settings")) { + resetAllSettings() + } + .buttonStyle(.bordered) + .controlSize(.regular) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + } // end if sectionVisible(.reset) + } - let actions = KeyboardShortcutSettings.Action.allCases.filter { - $0 != SystemWideHotkeySettings.action - } - ForEach(Array(actions.enumerated()), id: \.element.id) { index, action in - ShortcutSettingRow(action: action) - .padding(.horizontal, 14) - .padding(.vertical, 9) - if index < actions.count - 1 { - SettingsCardDivider() - } - } - } - .id(shortcutResetToken) - Text(String(localized: "settings.shortcuts.recordHint", defaultValue: "Click a shortcut value to record a new shortcut.")) - .font(.caption) - .foregroundColor(.secondary) - .padding(.leading, 2) - .accessibilityIdentifier("ShortcutRecordingHint") - } // end if sectionVisible(.keyboardShortcuts) - - if sectionVisible(.reset) { - SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset"), section: .reset) - .id(SettingsSection.reset) - SettingsCard { - HStack { - Spacer(minLength: 0) - Button(String(localized: "settings.reset.resetAll", defaultValue: "Reset All Settings")) { - resetAllSettings() - } - .buttonStyle(.bordered) - .controlSize(.regular) - Spacer(minLength: 0) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) + var body: some View { + let _ = keyboardShortcutSettingsObserver.revision + let _ = Self.validateBypassedSettingsConfigurationReviews() + ScrollViewReader { proxy in + HStack(spacing: 0) { + SettingsTOCSidebar( + searchText: $settingsSearchText, + activeSection: activeSection, + onSelect: { section in + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(section, anchor: .top) } - } // end if sectionVisible(.reset) - } // end Group: hotkey + keyboardShortcuts + reset sections + } + ) + Divider() + ZStack(alignment: .top) { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + settingsAppSection + settingsWorkspaceColorsSection + settingsSidebarAppearanceSection + settingsAutomationSection + settingsCustomCommandsAndBrowserSection + settingsHotkeyAndShortcutsSection } .padding(.horizontal, 20) .padding(.bottom, 20) From 56231af78940bfc86dc9cce5d73de787a16724a0 Mon Sep 17 00:00:00 2001 From: Jess Sullivan <jess@sulliwood.org> Date: Sat, 11 Apr 2026 19:23:51 -0400 Subject: [PATCH 89/89] fix(macOS): add missing configurationReview to fork-specific settings rows Five fork-specific SettingsCardRow calls were missing the configurationReview parameter added by upstream. Uses .settingsOnly since these are GUI-only settings not exposed in settings.json. --- Sources/cmuxApp.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 862577680352..2b43db0c5f4b 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5467,6 +5467,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .settingsOnly, String(localized: "settings.app.confirmCloseRunningProcess", defaultValue: "Confirm Close Running Process"), subtitle: confirmCloseRunningProcess ? String(localized: "settings.app.confirmCloseRunningProcess.subtitleOn", defaultValue: "Show a confirmation before closing a workspace with a running command.") @@ -5480,6 +5481,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .settingsOnly, String(localized: "settings.app.confirmClosePinnedWorkspace", defaultValue: "Confirm Close Pinned Workspace"), subtitle: confirmClosePinnedWorkspace ? String(localized: "settings.app.confirmClosePinnedWorkspace.subtitleOn", defaultValue: "Show a confirmation before closing a pinned workspace.") @@ -5493,6 +5495,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .settingsOnly, String(localized: "settings.app.confirmCloseWindow", defaultValue: "Confirm Close Window"), subtitle: confirmCloseWindow ? String(localized: "settings.app.confirmCloseWindow.subtitleOn", defaultValue: "Show a confirmation before closing a window with workspaces.") @@ -5506,6 +5509,7 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( + configurationReview: .settingsOnly, String(localized: "settings.app.confirmBatchClose", defaultValue: "Confirm Batch Close"), subtitle: confirmBatchClose ? String(localized: "settings.app.confirmBatchClose.subtitleOn", defaultValue: "Show a confirmation before closing multiple tabs or workspaces at once.") @@ -6078,6 +6082,7 @@ struct SettingsView: View { SettingsCard { SettingsCardRow( + configurationReview: .settingsOnly, String(localized: "settings.automation.warmAgentPool", defaultValue: "Warm Agent Pool"), subtitle: warmClaudePoolSize > 0 ? String(localized: "settings.automation.warmAgentPool.subtitleOn", defaultValue: "Pre-spawns agent sessions for instant launch via ⇧⌘K.")