From 9dc605b95bf0355d8338680c24c207070c2d041f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:23:05 -0700 Subject: [PATCH 01/10] test: right-sidebar digit shortcuts should follow visible tab positions With Feed and Dock feature-gated off, Cloud is the 4th visible tab in the right sidebar's mode bar, but ctrl+4 was pinned to the invisible Feed and did nothing while Cloud answered only ctrl+6. The new test presses ctrl+4 in that configuration and expects the Cloud (machines) tab. Also pins the mode gates and tab preferences in the existing digit-default tests so the test host's own settings cannot shift the expected digits. --- .../ShortcutAndCommandPaletteTests.swift | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index 31499d08017d..c73aed86f765 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1191,9 +1191,22 @@ final class RightSidebarModeShortcutHintTests: XCTestCase { .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock, + .switchRightSidebarToMachines, + ] + /// The digit defaults are positional over the visible tabs, so the + /// expectations below pin every mode gate on and clear any tab + /// customization; otherwise the test host's own settings would shift the + /// digits. + private let touchedTabEnvironmentKeys: [String] = [ + RightSidebarBetaFeatureSettings.feedEnabledKey, + RightSidebarBetaFeatureSettings.dockEnabledKey, + RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey, + "rightSidebar.tabs.order", + "rightSidebar.tabs.hidden", ] private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! private var savedShortcutData: [KeyboardShortcutSettings.Action: Data?] = [:] + private var savedTabEnvironment: [String: Any?] = [:] private var temporaryDirectoryURL: URL? override func setUpWithError() throws { @@ -1204,6 +1217,16 @@ final class RightSidebarModeShortcutHintTests: XCTestCase { (action, UserDefaults.standard.data(forKey: action.defaultsKey)) } ) + savedTabEnvironment = Dictionary( + uniqueKeysWithValues: touchedTabEnvironmentKeys.map { key in + (key, UserDefaults.standard.object(forKey: key)) + } + ) + UserDefaults.standard.set(true, forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) + UserDefaults.standard.set(true, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + UserDefaults.standard.set(true, forKey: RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey) + UserDefaults.standard.removeObject(forKey: "rightSidebar.tabs.order") + UserDefaults.standard.removeObject(forKey: "rightSidebar.tabs.hidden") let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -1228,6 +1251,13 @@ final class RightSidebarModeShortcutHintTests: XCTestCase { UserDefaults.standard.removeObject(forKey: action.defaultsKey) } } + for key in touchedTabEnvironmentKeys { + if case let .some(.some(value)) = savedTabEnvironment[key] { + UserDefaults.standard.set(value, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore KeyboardShortcutSettings.notifySettingsFileDidChange() if let temporaryDirectoryURL { @@ -1266,6 +1296,26 @@ final class RightSidebarModeShortcutHintTests: XCTestCase { RightSidebarMode.modeShortcut(for: makeKeyDownEvent(key: "5", modifiers: [.control], keyCode: 23)), .dock ) + XCTAssertEqual( + RightSidebarMode.modeShortcut(for: makeKeyDownEvent(key: "6", modifiers: [.control], keyCode: 22)), + .machines + ) + } + + /// The reported bug: Feed and Dock hidden leaves Cloud as the 4th visible + /// tab, so ctrl+4 must select it (the old static table pinned Cloud to + /// ctrl+6 while ctrl+4 fell on the invisible Feed and did nothing). + func testModeShortcutDigitsFollowVisibleTabPositions() { + UserDefaults.standard.set(false, forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) + UserDefaults.standard.set(false, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + + XCTAssertEqual( + RightSidebarMode.modeShortcut(for: makeKeyDownEvent(key: "4", modifiers: [.control], keyCode: 21)), + .machines + ) + XCTAssertNil( + RightSidebarMode.modeShortcut(for: makeKeyDownEvent(key: "5", modifiers: [.control], keyCode: 23)) + ) } func testModeShortcutUsesConfiguredBindings() { From b13fdc42daaed0f0e558e659788a1b3a9b04a4e0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:23:29 -0700 Subject: [PATCH 02/10] Right sidebar: customizable tabs and positional digit shortcuts The ctrl+digit mode shortcuts now default to the mode's position among the VISIBLE tabs instead of a fixed per-mode table. With Feed and Dock hidden, Cloud is the 4th tab and answers ctrl+4 (it was pinned to ctrl+6 while ctrl+4 fell on the invisible Feed and did nothing). Explicit user bindings still win, hints and Settings show the resolved values, and CmuxSettings gets a host-installed default-stroke override so the package's effective-shortcut resolution agrees with the app. Tabs are now user-customizable: a Right Sidebar Tabs card in Settings > Sidebar (visibility toggles, reorder arrows, live shortcut labels) and a right-click menu on the mode bar (show/hide plus a jump to that card). Preferences live in rightSidebar.tabs.order / rightSidebar.tabs.hidden; hiding the last visible tab is refused. Explicit selection of a hidden tab (CLI, palette, notification routing) still works and reveals the tab while it is active; restore and preference changes re-land on a visible tab. Also adds the missing setting:betaFeatures:cloudMachines anchor to the package's reachability mirror list (pre-existing red test on main). --- .../Values/ShortcutAction+Defaults.swift | 13 ++ .../Values/ShortcutDefaultOverrides.swift | 41 ++++ .../Environment/SettingsHostActions.swift | 57 ++++++ .../SidebarSection+RightSidebarTabs.swift | 99 ++++++++++ .../Sections/SidebarSection.swift | 10 +- .../SettingsRowAnchorResolutionTests.swift | 1 + Resources/Localizable.xcstrings | 85 ++++++++ Sources/FileExplorerState.swift | 18 +- Sources/HostSettingsActions.swift | 72 +++++++ Sources/KeyboardShortcutSettings.swift | 30 ++- Sources/RightSidebarMode+Availability.swift | 26 +++ Sources/RightSidebarPanelView.swift | 50 ++++- Sources/RightSidebarTabPreferences.swift | 87 +++++++++ Sources/SettingsNavigation.swift | 1 + Sources/cmuxApp.swift | 25 +++ cmux.xcodeproj/project.pbxproj | 8 + .../RightSidebarTabCustomizationTests.swift | 182 ++++++++++++++++++ .../ShortcutAndCommandPaletteTests.swift | 8 +- web/messages/en.json | 2 +- web/messages/ja.json | 2 +- 20 files changed, 801 insertions(+), 16 deletions(-) create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift create mode 100644 Sources/RightSidebarTabPreferences.swift create mode 100644 cmuxTests/RightSidebarTabCustomizationTests.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 3f8b0f191b2f..2f7200d58bb2 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -32,7 +32,20 @@ extension ShortcutAction { /// next to unbound rows, and so the Reset action in the Settings /// UI can restore a row by writing the default stroke through /// the JSON store. + /// + /// A host-installed ``ShortcutDefaultOverrides`` provider takes + /// precedence: cmux computes the right-sidebar digit defaults + /// positionally from the visible tab order, which this package + /// cannot read. The static values below for those actions are the + /// factory tab-order fallback used when no provider is installed. public var defaultStroke: ShortcutStroke? { + if case .stroke(let stroke) = ShortcutDefaultOverrides.result(for: self) { + return stroke + } + return builtInDefaultStroke + } + + private var builtInDefaultStroke: ShortcutStroke? { switch self { case .openSettings: return ShortcutStroke(key: ",", command: true) case .reloadConfiguration: return ShortcutStroke(key: ",", command: true, shift: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift new file mode 100644 index 000000000000..05cc5b41d391 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Host-installed dynamic default strokes. +/// +/// cmux resolves the right-sidebar digit defaults positionally from the user's +/// visible tab order, which lives in host-owned state this package cannot +/// read. The host installs a provider at launch; every package consumer of +/// ``ShortcutAction/defaultStroke`` (the Settings UI's effective-shortcut +/// resolution, legacy conflict checks) then agrees with the app's runtime +/// defaults. Without a provider (package tests, other hosts) the static table +/// in `ShortcutAction+Defaults.swift` answers unchanged. +public enum ShortcutDefaultOverrides { + /// What the host wants for one action's factory default. + public enum Result: Sendable { + /// Use the package's built-in static table. + case useBuiltIn + /// Use this stroke; `nil` means the action defaults to unbound. + case stroke(ShortcutStroke?) + } + + public typealias Provider = @Sendable (ShortcutAction) -> Result + + private static let lock = NSLock() + nonisolated(unsafe) private static var _provider: Provider? + + /// Installs (or clears) the host provider. Call once at launch, before + /// shortcut resolution begins; the provider itself must read live state so + /// later preference changes need no re-install. + public static func install(_ provider: Provider?) { + lock.lock() + defer { lock.unlock() } + _provider = provider + } + + static func result(for action: ShortcutAction) -> Result { + lock.lock() + let provider = _provider + lock.unlock() + return provider?(action) ?? .useBuiltIn + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index 325312b2fe8c..3015a93c8cb9 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -127,6 +127,28 @@ public protocol SettingsHostActions: AnyObject { @discardableResult func setSidebarFontSize(_ points: Double) async -> Bool + /// The customizable right-sidebar tabs in the user's order, hidden tabs + /// included. Backed by host-owned mode metadata and tab preferences the + /// package cannot read; empty when the host has no right sidebar + /// (previews/tests). + func rightSidebarTabs() -> [RightSidebarTabSettingsItem] + + /// Shows or hides one right-sidebar tab. + /// + /// - Returns: `false` when the host refused the change (hiding the last + /// visible tab); the card re-reads state so the toggle snaps back. + @discardableResult + func setRightSidebarTabVisible(id: String, visible: Bool) -> Bool + + /// Moves one right-sidebar tab by `offset` within the ordered tab list + /// (negative is toward the front). Hidden tabs keep their slot. + func moveRightSidebarTab(id: String, offset: Int) + + /// Yields a fresh tab list whenever the tabs change from any entrypoint + /// (this card, the mode bar's context menu, shortcut rebinds that change + /// the displayed digit hints). + func rightSidebarTabsUpdates() -> AsyncStream<[RightSidebarTabSettingsItem]> + /// The current workspace tab-bar font size with its range + default. /// Backed by the Ghostty config file (`surface-tab-bar-font-size`). func surfaceTabBarFontSize() -> SettingsFontSize @@ -287,6 +309,31 @@ public struct CloudMachinesPlanSummary: Equatable, Sendable { } } +/// One right-sidebar tab as the Sidebar section's customization card renders +/// it. `id` is the host's stable mode identifier (the mode raw value). +public struct RightSidebarTabSettingsItem: Identifiable, Equatable, Sendable { + public let id: String + public let title: String + public let symbolName: String + public let isVisible: Bool + /// Resolved switch-shortcut label (e.g. `⌃4`); empty when unbound. + public let shortcutLabel: String + + public init( + id: String, + title: String, + symbolName: String, + isVisible: Bool, + shortcutLabel: String + ) { + self.id = id + self.title = title + self.symbolName = symbolName + self.isVisible = isVisible + self.shortcutLabel = shortcutLabel + } +} + public extension SettingsHostActions { /// Returns the registry-backed agent choices shown by notification sound settings. func notificationSoundAgentOptions() -> [NotificationSoundAgentOption] { [] } @@ -297,6 +344,16 @@ public extension SettingsHostActions { /// Default no-op for previews and tests without a live control socket. func socketControlConfigurationDidChange() {} + /// Right-sidebar tab defaults for previews, tests, and package-only + /// hosts: no tabs, refuse mutations, no updates. + func rightSidebarTabs() -> [RightSidebarTabSettingsItem] { [] } + @discardableResult + func setRightSidebarTabVisible(id: String, visible: Bool) -> Bool { false } + func moveRightSidebarTab(id: String, offset: Int) {} + func rightSidebarTabsUpdates() -> AsyncStream<[RightSidebarTabSettingsItem]> { + AsyncStream { $0.finish() } + } + /// Cloud Machines defaults for previews, tests, and package-only hosts: /// unavailable, no plan, no-op actions. var isCloudMachinesAvailable: Bool { false } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift new file mode 100644 index 000000000000..2825021a7766 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift @@ -0,0 +1,99 @@ +import CmuxFoundation +import SwiftUI + +extension SidebarSection { + /// **Right Sidebar Tabs** card: one row per customizable tab with its + /// resolved switch shortcut, reorder arrows, and a visibility toggle. + /// State lives in the host (`rightSidebarTabs()` and the mutation + /// methods); the card re-reads after every mutation and stays live via + /// `rightSidebarTabsUpdates()` when the mode bar's context menu or a + /// shortcut rebind changes the tabs from outside Settings. + @ViewBuilder + var rightSidebarTabsCard: some View { + if !rightSidebarTabs.isEmpty { + SettingsCard { + SettingsCardRow( + configurationReview: .settingsOnly, + searchAnchorID: "setting:sidebarAppearance:right-sidebar-tabs", + String(localized: "settings.sidebar.rightTabs", defaultValue: "Right Sidebar Tabs"), + subtitle: String(localized: "settings.sidebar.rightTabs.subtitle", defaultValue: "Choose which tabs the right sidebar shows and their order. The ⌃1–⌃9 shortcuts follow the visible order.") + ) { + EmptyView() + } + ForEach(rightSidebarTabs) { tab in + SettingsCardDivider() + rightSidebarTabRow(tab) + } + } + } + } + + @ViewBuilder + private func rightSidebarTabRow(_ tab: RightSidebarTabSettingsItem) -> some View { + let index = rightSidebarTabs.firstIndex(of: tab) + let visibleCount = rightSidebarTabs.filter(\.isVisible).count + HStack(alignment: .center, spacing: 12) { + Image(systemName: tab.symbolName) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: 18) + Text(tab.title) + .cmuxFont(size: 13, weight: .medium) + .foregroundColor(tab.isVisible ? .primary : .secondary) + if !tab.shortcutLabel.isEmpty { + Text(tab.shortcutLabel) + .cmuxFont(size: 11, monospacedDigit: true) + .foregroundColor(.secondary) + } + Spacer(minLength: 12) + HStack(spacing: 4) { + Button { + hostActions.moveRightSidebarTab(id: tab.id, offset: -1) + rightSidebarTabs = hostActions.rightSidebarTabs() + } label: { + Image(systemName: "chevron.up") + } + .buttonStyle(.borderless) + .disabled(index == rightSidebarTabs.startIndex) + .accessibilityLabel( + String.localizedStringWithFormat( + String(localized: "settings.sidebar.rightTabs.moveUp", defaultValue: "Move %@ Up"), + tab.title + ) + ) + .accessibilityIdentifier("SettingsRightSidebarTabMoveUp.\(tab.id)") + Button { + hostActions.moveRightSidebarTab(id: tab.id, offset: 1) + rightSidebarTabs = hostActions.rightSidebarTabs() + } label: { + Image(systemName: "chevron.down") + } + .buttonStyle(.borderless) + .disabled(index == rightSidebarTabs.index(before: rightSidebarTabs.endIndex)) + .accessibilityLabel( + String.localizedStringWithFormat( + String(localized: "settings.sidebar.rightTabs.moveDown", defaultValue: "Move %@ Down"), + tab.title + ) + ) + .accessibilityIdentifier("SettingsRightSidebarTabMoveDown.\(tab.id)") + } + Toggle("", isOn: Binding( + get: { tab.isVisible }, + set: { visible in + hostActions.setRightSidebarTabVisible(id: tab.id, visible: visible) + rightSidebarTabs = hostActions.rightSidebarTabs() + } + )) + .labelsHidden() + .controlSize(.small) + .disabled(tab.isVisible && visibleCount == 1) + .accessibilityIdentifier("SettingsRightSidebarTabVisibleToggle.\(tab.id)") + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SettingsRightSidebarTabRow.\(tab.id)") + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift index 0ae5b19ccde8..0822bbec5fca 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift @@ -4,7 +4,8 @@ import SwiftUI @MainActor public struct SidebarSection: View { private let catalog: SettingCatalog - private let hostActions: SettingsHostActions + let hostActions: SettingsHostActions + @State var rightSidebarTabs: [RightSidebarTabSettingsItem] private let rightSidebarWidthSettings = RightSidebarWidthSettings() @State private var sidebarFont: SettingsFontSize @State private var fontSaveFailed = false @@ -37,6 +38,7 @@ public struct SidebarSection: View { public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog, hostActions: SettingsHostActions) { self.catalog = catalog self.hostActions = hostActions + _rightSidebarTabs = State(initialValue: hostActions.rightSidebarTabs()) _sidebarFont = State(initialValue: hostActions.sidebarFontSize()) _matchTerminal = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.matchTerminalBackground)) _hideAll = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.hideAllDetails)) @@ -69,8 +71,14 @@ public struct SidebarSection: View { Group { SettingsSectionHeader(String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar"), section: .sidebarAppearance) mainCard + rightSidebarTabsCard } .task { startObservingSettings() } + .task { + for await tabs in hostActions.rightSidebarTabsUpdates() { + rightSidebarTabs = tabs + } + } } private func startObservingSettings() { diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 0f1c972fd2e8..670a03c43940 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -172,6 +172,7 @@ struct SettingsRowAnchorResolutionTests { "setting:mobile:iOSPairingDisplayName", "setting:betaFeatures:feed", "setting:betaFeatures:dock", + "setting:betaFeatures:cloudMachines", "setting:betaFeatures:customSidebars", "setting:betaFeatures:remoteTmux", "setting:customSidebars:enabled", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 571e77a6e42e..c14a16a9b825 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -166212,6 +166212,23 @@ } } }, + "rightSidebar.tabs.customize": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Customize Tabs…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タブをカスタマイズ…" + } + } + } + }, "rightSidebar.toggle.tooltip": { "extractionState": "manual", "localizations": { @@ -222686,6 +222703,74 @@ } } }, + "settings.sidebar.rightTabs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Right Sidebar Tabs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右サイドバーのタブ" + } + } + } + }, + "settings.sidebar.rightTabs.moveDown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move %@ Down" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@を下に移動" + } + } + } + }, + "settings.sidebar.rightTabs.moveUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move %@ Up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@を上に移動" + } + } + } + }, + "settings.sidebar.rightTabs.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose which tabs the right sidebar shows and their order. The ⌃1–⌃9 shortcuts follow the visible order." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右サイドバーに表示するタブとその順序を選択します。⌃1〜⌃9 のショートカットは表示順に従います。" + } + } + } + }, "settings.sidebarAppearance.defaultLabel": { "extractionState": "manual", "localizations": { diff --git a/Sources/FileExplorerState.swift b/Sources/FileExplorerState.swift index 7ea9737aee80..7c6a8523c690 100644 --- a/Sources/FileExplorerState.swift +++ b/Sources/FileExplorerState.swift @@ -58,12 +58,16 @@ final class FileExplorerState: ObservableObject { let customSidebarName = defaults.string(forKey: Self.customSidebarNameKey)?.nilIfEmpty self.storedCustomSidebarName = customSidebarName let storedMode = RightSidebarMode(rawValue: defaults.string(forKey: Self.modeKey) ?? "") ?? .files - self.storedMode = Self.availableMode(storedMode, defaults: defaults) + self.storedMode = Self.visibleMode(storedMode, defaults: defaults) defaults.set(self.storedMode.rawValue, forKey: Self.modeKey) } + /// Re-lands the sidebar on a tab the mode bar shows. Unlike an explicit + /// `mode` set (which may reveal a user-hidden tab: CLI, palette, + /// notification routing), restore and preference changes never resurrect a + /// hidden tab. func refreshModeAvailability(defaults: UserDefaults = .standard) { - setMode(storedMode, defaults: defaults) + setMode(Self.visibleMode(storedMode, defaults: defaults), defaults: defaults) } func selectCustomSidebar(name rawName: String, defaults: UserDefaults = .standard) { @@ -123,4 +127,14 @@ final class FileExplorerState: ObservableObject { ) -> RightSidebarMode { mode.isAvailable(defaults: defaults) ? mode : .files } + + private static func visibleMode( + _ mode: RightSidebarMode, + defaults: UserDefaults + ) -> RightSidebarMode { + let candidate = availableMode(mode, defaults: defaults) + let visible = RightSidebarMode.visibleModes(defaults: defaults) + if visible.contains(candidate) { return candidate } + return visible.first ?? candidate + } } diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index 19f7f9ef16d7..c2c5a82eca67 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -237,6 +237,78 @@ final class HostSettingsActions: SettingsHostActions { TerminalNotificationStore.shared.refreshAuthorizationStatus() } + // MARK: - Right sidebar tabs + + func rightSidebarTabs() -> [RightSidebarTabSettingsItem] { + Self.rightSidebarTabItems() + } + + @discardableResult + func setRightSidebarTabVisible(id: String, visible: Bool) -> Bool { + guard let mode = RightSidebarMode(rawValue: id) else { return false } + return RightSidebarTabPreferences.setHidden(!visible, mode: mode) + } + + func moveRightSidebarTab(id: String, offset: Int) { + guard let mode = RightSidebarMode(rawValue: id) else { return } + RightSidebarTabPreferences.move(mode, offset: offset) + } + + func rightSidebarTabsUpdates() -> AsyncStream<[RightSidebarTabSettingsItem]> { + AsyncStream { continuation in + let (signals, signalContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + // Shortcut rebinds change the displayed digit labels, so both + // notifications refresh the card. Tab-preference mutations post + // both; the newest-1 buffer coalesces the pair into one refresh. + let observers = [ + RightSidebarTabPreferences.didChangeNotification, + KeyboardShortcutSettings.didChangeNotification, + ].map { name in + MobileHostStatusObserverToken( + NotificationCenter.default.addObserver( + forName: name, + object: nil, + queue: nil + ) { _ in + signalContinuation.yield(()) + } + ) + } + let drainTask = Task { @MainActor in + for await _ in signals { + if Task.isCancelled { break } + continuation.yield(Self.rightSidebarTabItems()) + } + continuation.finish() + } + continuation.onTermination = { _ in + drainTask.cancel() + signalContinuation.finish() + observers.forEach { $0.remove() } + } + } + } + + private static func rightSidebarTabItems() -> [RightSidebarTabSettingsItem] { + let available = RightSidebarMode.availableModes() + let hidden = RightSidebarTabPreferences.hiddenModes() + return RightSidebarTabPreferences.orderedModes() + .filter(available.contains) + .map { mode in + let shortcut = mode.shortcutAction.map { KeyboardShortcutSettings.shortcut(for: $0) } + ?? .unbound + return RightSidebarTabSettingsItem( + id: mode.rawValue, + title: mode.label, + symbolName: mode.symbolName, + isVisible: !hidden.contains(mode), + shortcutLabel: shortcut.isUnbound ? "" : shortcut.displayString + ) + } + } + func restartApp() { let bundlePath = Bundle.main.bundlePath let task = Process() diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index a9f66502b47e..34fb86ab9d9a 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -17,6 +17,24 @@ enum KeyboardShortcutSettings { static var shortcutLookupObserver: ((Action) -> Void)? #endif + /// Default binding for one right-sidebar mode-switch action: `ctrl+N` + /// where N is the mode's 1-based position among the *visible* tabs + /// (`RightSidebarMode.visibleModes`), so the digits always match the mode + /// bar the user sees, whatever they hid or reordered. A hidden or + /// unavailable tab (and any position past 9) defaults to unbound; an + /// explicit user binding still wins over this default. + /// `RightSidebarTabPreferences` posts `didChangeNotification` on every + /// mutation so matcher and hint caches pick up the new positions. + nonisolated static func rightSidebarPositionalDefaultShortcut( + for mode: RightSidebarMode, + defaults: UserDefaults = .standard + ) -> StoredShortcut { + guard let digit = RightSidebarMode.positionalDigit(for: mode, defaults: defaults) else { + return .unbound + } + return StoredShortcut(key: String(digit), command: false, shift: false, option: false, control: true) + } + static var publicShortcutActions: [Action] { Action.allCases.filter(\.isPublicShortcutAction) } @@ -440,17 +458,17 @@ enum KeyboardShortcutSettings { case .focusRightSidebar: return StoredShortcut(key: "e", command: true, shift: true, option: false, control: false) case .switchRightSidebarToFiles: - return StoredShortcut(key: "1", command: false, shift: false, option: false, control: true) + return KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .files) case .switchRightSidebarToFind: - return StoredShortcut(key: "2", command: false, shift: false, option: false, control: true) + return KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .find) case .switchRightSidebarToSessions: - return StoredShortcut(key: "3", command: false, shift: false, option: false, control: true) + return KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .sessions) case .switchRightSidebarToFeed: - return StoredShortcut(key: "4", command: false, shift: false, option: false, control: true) + return KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .feed) case .switchRightSidebarToDock: - return StoredShortcut(key: "5", command: false, shift: false, option: false, control: true) + return KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .dock) case .switchRightSidebarToMachines: - return StoredShortcut(key: "6", command: false, shift: false, option: false, control: true) + return KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .machines) case .triggerFlash: return StoredShortcut(key: "h", command: true, shift: true, option: false, control: false) case .nextSidebarTab: diff --git a/Sources/RightSidebarMode+Availability.swift b/Sources/RightSidebarMode+Availability.swift index f538264d2a6e..f8f7ab1947ff 100644 --- a/Sources/RightSidebarMode+Availability.swift +++ b/Sources/RightSidebarMode+Availability.swift @@ -49,6 +49,32 @@ extension RightSidebarMode { ) } + /// The tabs the mode bar actually shows: feature-available modes in the + /// user's configured order, minus the ones the user hid. This list also + /// defines the positional `ctrl+1…9` digit-shortcut defaults, so the Nth + /// visible tab always answers ctrl+N unless the user rebound it. + nonisolated static func visibleModes(defaults: UserDefaults = .standard) -> [RightSidebarMode] { + let hidden = RightSidebarTabPreferences.hiddenModes(defaults: defaults) + let visible = RightSidebarTabPreferences.orderedModes(defaults: defaults) + .filter { $0.isAvailable(defaults: defaults) && !hidden.contains($0) } + // A hidden set written directly to defaults can hide everything; the + // sidebar still needs tabs, so fall back to every available mode. + return visible.isEmpty ? availableModes(defaults: defaults) : visible + } + + /// 1-based `ctrl+digit` position of `mode` among the visible tabs, or nil + /// when the mode is hidden, unavailable, or past position 9. Single source + /// for the app's positional shortcut defaults and the CmuxSettings + /// default-stroke override. + nonisolated static func positionalDigit( + for mode: RightSidebarMode, + defaults: UserDefaults = .standard + ) -> Int? { + let visible = visibleModes(defaults: defaults) + guard let index = visible.firstIndex(of: mode), index < 9 else { return nil } + return index + 1 + } + func isAvailable(feedEnabled: Bool, dockEnabled: Bool, machinesEnabled: Bool) -> Bool { switch self { case .files, .find, .sessions: diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index bd4ab6111440..f15f3af34074 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -164,7 +164,7 @@ struct RightSidebarPanelView: View { FeedCoordinator.shared.store?.pending.count ?? 0 } - private var availableModes: [RightSidebarMode] { + private var featureAvailableModes: [RightSidebarMode] { RightSidebarMode.availableModes( feedEnabled: feedEnabled, dockEnabled: dockEnabled, @@ -172,6 +172,29 @@ struct RightSidebarPanelView: View { ) } + /// Feature-available tabs in the user's order, for the customization + /// context menu: hidden tabs stay listed so they can be re-shown. + private var customizableModes: [RightSidebarMode] { + let featureAvailable = featureAvailableModes + return RightSidebarTabPreferences.orderedModes().filter(featureAvailable.contains) + } + + private var availableModes: [RightSidebarMode] { + // Tab-preference mutations post the shortcuts didChange notification, + // which bumps this revision; reading it keeps the bar live when tabs + // are hidden, shown, or reordered. + _ = keyboardShortcutSettingsObserver.revision + let featureAvailable = featureAvailableModes + let hidden = RightSidebarTabPreferences.hiddenModes() + // An explicitly selected hidden tab (CLI, palette, notification + // routing) stays revealed in its own slot while it is active. + let active = fileExplorerState.mode + let modes = RightSidebarTabPreferences.orderedModes().filter { mode in + featureAvailable.contains(mode) && (!hidden.contains(mode) || mode == active) + } + return modes.isEmpty ? featureAvailable : modes + } + private var modeBarItems: [RightSidebarModeBarItem] { availableModes.map { RightSidebarModeBarItem(kind: .mode($0)) } } @@ -241,6 +264,9 @@ struct RightSidebarPanelView: View { .onChange(of: feedEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } .onChange(of: dockEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } .onChange(of: cloudMachinesBetaEnabled) { _, _ in refreshModeAvailabilityAndFocusIfNeeded() } + .onReceive(NotificationCenter.default.publisher(for: RightSidebarTabPreferences.didChangeNotification)) { _ in + refreshModeAvailabilityAndFocusIfNeeded() + } } private var modeBar: some View { @@ -283,6 +309,7 @@ struct RightSidebarPanelView: View { } } .rightSidebarChromeBar(leadingPadding: 4, trailingPadding: 6, height: titlebarHeight) + .contextMenu { tabCustomizationMenu } .overlay(alignment: .topLeading) { focusShortcutHintOverlay } @@ -295,6 +322,27 @@ struct RightSidebarPanelView: View { ) } + /// Right-click menu on the mode bar: show/hide each tab in place, plus a + /// jump to the Settings card that also reorders them. + @ViewBuilder + private var tabCustomizationMenu: some View { + let visibleCount = RightSidebarMode.visibleModes().count + ForEach(customizableModes, id: \.self) { mode in + let isShown = !RightSidebarTabPreferences.isHidden(mode) + Toggle(isOn: Binding( + get: { isShown }, + set: { RightSidebarTabPreferences.setHidden(!$0, mode: mode) } + )) { + Text(mode.label) + } + .disabled(isShown && visibleCount == 1) + } + Divider() + Button(String(localized: "rightSidebar.tabs.customize", defaultValue: "Customize Tabs…")) { + SettingsWindowPresenter.show(navigationTarget: .sidebarAppearance) + } + } + private func openAsPaneButton(mode: RightSidebarMode) -> some View { Button { onOpenAsPane(mode) diff --git a/Sources/RightSidebarTabPreferences.swift b/Sources/RightSidebarTabPreferences.swift new file mode 100644 index 000000000000..66aa673b9c68 --- /dev/null +++ b/Sources/RightSidebarTabPreferences.swift @@ -0,0 +1,87 @@ +import Foundation + +/// User customization of the right sidebar's mode tabs: which tabs are shown +/// and in what order. Feature availability (beta toggles, Cloud rollout) stays +/// in `RightSidebarMode.isAvailable`; this layer only stores the user's +/// choices on top of it, so a tab hidden here can still be revealed by an +/// explicit selection (CLI, command palette, notification routing). +enum RightSidebarTabPreferences { + static let orderKey = "rightSidebar.tabs.order" + static let hiddenKey = "rightSidebar.tabs.hidden" + + /// Posted after any mutation. Mutations also post + /// `KeyboardShortcutSettings.didChangeNotification` because the positional + /// digit-shortcut defaults (`ctrl+1…9` follow the visible tab order) change + /// with these preferences and every shortcut matcher/hint cache keys off + /// that notification. + static let didChangeNotification = Notification.Name("RightSidebarTabPreferencesDidChange") + + /// Every customizable tab in the user's order. Tabs missing from the + /// stored order (new modes shipped after the user last reordered) keep + /// their canonical position relative to the stored ones by appending in + /// declaration order. `customSidebar` is not a bar tab and never appears. + nonisolated static func orderedModes(defaults: UserDefaults = .standard) -> [RightSidebarMode] { + let canonical = RightSidebarMode.allCases.filter { $0 != .customSidebar } + let stored = (defaults.stringArray(forKey: orderKey) ?? []) + .compactMap(RightSidebarMode.init(rawValue:)) + var result: [RightSidebarMode] = [] + for mode in stored where canonical.contains(mode) && !result.contains(mode) { + result.append(mode) + } + for mode in canonical where !result.contains(mode) { + result.append(mode) + } + return result + } + + nonisolated static func hiddenModes(defaults: UserDefaults = .standard) -> Set { + Set((defaults.stringArray(forKey: hiddenKey) ?? []).compactMap(RightSidebarMode.init(rawValue:))) + } + + nonisolated static func isHidden(_ mode: RightSidebarMode, defaults: UserDefaults = .standard) -> Bool { + hiddenModes(defaults: defaults).contains(mode) + } + + /// Hides or shows one tab. Refuses to hide the last visible tab so the + /// sidebar always has a mode to land on. + @discardableResult + static func setHidden(_ hidden: Bool, mode: RightSidebarMode, defaults: UserDefaults = .standard) -> Bool { + guard mode != .customSidebar else { return false } + var hiddenSet = hiddenModes(defaults: defaults) + if hidden { + guard hiddenSet.insert(mode).inserted else { return true } + guard RightSidebarMode.visibleModes(defaults: defaults).contains(where: { $0 != mode }) else { + return false + } + } else { + guard hiddenSet.remove(mode) != nil else { return true } + } + defaults.set(hiddenSet.map(\.rawValue).sorted(), forKey: hiddenKey) + notifyChanged() + return true + } + + /// Moves one tab by `offset` within the full ordered tab list (hidden tabs + /// keep their slot so re-showing one restores its place). + static func move(_ mode: RightSidebarMode, offset: Int, defaults: UserDefaults = .standard) { + var order = orderedModes(defaults: defaults) + guard let index = order.firstIndex(of: mode) else { return } + let target = max(0, min(order.count - 1, index + offset)) + guard target != index else { return } + order.remove(at: index) + order.insert(mode, at: target) + defaults.set(order.map(\.rawValue), forKey: orderKey) + notifyChanged() + } + + static func resetToDefaults(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: orderKey) + defaults.removeObject(forKey: hiddenKey) + notifyChanged() + } + + private static func notifyChanged() { + NotificationCenter.default.post(name: didChangeNotification, object: nil) + NotificationCenter.default.post(name: KeyboardShortcutSettings.didChangeNotification, object: nil) + } +} diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 0de0b0d0a013..e619aea0f25a 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -453,6 +453,7 @@ enum SettingsSearchIndex { setting(.sidebarAppearance, "notification-badge-position", String(localized: "settings.app.notificationBadgePosition", defaultValue: "Notification Badge Position"), "sidebar.notificationBadgePosition notification unread badge position left right leading trailing side"), setting(.sidebarAppearance, "show-metadata", String(localized: "settings.app.showMetadata", defaultValue: "Show Custom Metadata in Sidebar"), "report meta status block"), setting(.sidebarAppearance, "right-max-width", String(localized: "settings.sidebar.rightMaxWidth", defaultValue: "Dock Max Width"), "dock right sidebar max width terminal reservation cap logs lazygit"), + setting(.sidebarAppearance, "right-sidebar-tabs", String(localized: "settings.sidebar.rightTabs", defaultValue: "Right Sidebar Tabs"), "right sidebar tabs show hide reorder order files find vault feed dock cloud mode switcher customize"), setting(.customSidebars, "enabled", String(localized: "settings.customSidebars.enabled", defaultValue: "Show Custom Sidebars"), "custom sidebars enable show vibe swift json interpreted picker"), setting(.customSidebars, "renderer", String(localized: "settings.customSidebars.renderer", defaultValue: "Renderer"), "renderer in-process in app remote worker isolated process hover focus typing input"), setting(.betaFeatures, "feed", String(localized: "settings.betaFeatures.feed", defaultValue: "Feed"), "feed right sidebar agent decisions permissions questions"), diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 4776debe45fe..f7b470d2975c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -79,6 +79,31 @@ struct cmuxApp: App { } init() { + // The right-sidebar digit shortcuts default to the mode's position + // among the visible tabs, and the visible tab order is app state the + // CmuxSettings package cannot read. Install the override before any + // shortcut resolution so the package's Settings UI and the runtime + // matcher agree on the same defaults. The provider reads live defaults + // on every call, so tab-preference changes need no re-install. + ShortcutDefaultOverrides.install { action in + let mode: RightSidebarMode + switch action { + case .switchRightSidebarToFiles: mode = .files + case .switchRightSidebarToFind: mode = .find + case .switchRightSidebarToSessions: mode = .sessions + case .switchRightSidebarToFeed: mode = .feed + case .switchRightSidebarToDock: mode = .dock + case .switchRightSidebarToMachines: mode = .machines + default: return .useBuiltIn + } + guard let digit = RightSidebarMode.positionalDigit(for: mode) else { + return .stroke(nil) + } + // Fully qualified: the app target declares a legacy ShortcutStroke + // of its own, which this initializer must not resolve to. + return .stroke(CmuxSettings.ShortcutStroke(key: String(digit), control: true)) + } + // Gather settings package dependencies once. The runtime itself // is assigned after the saved language override below, because // it owns localized search-index text for the process lifetime. diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 718f03883eab..dd6424ff5691 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2163,6 +2163,8 @@ C57570010000000000000002 /* RightSidebarPanelViewTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57570010000000000000001 /* RightSidebarPanelViewTestSupport.swift */; }; B37A0000000000000000000D /* RightSidebarRemoteCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = B37A0000000000000000000E /* RightSidebarRemoteCommand.swift */; }; 5F5553CB5553CB5553CB0001 /* RightSidebarRemoteCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F5553CB5553CB5553CB0002 /* RightSidebarRemoteCommandTests.swift */; }; + 07EB8695682FE486A366F22E /* RightSidebarTabCustomizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2217672A4FBF3048C6C17EA3 /* RightSidebarTabCustomizationTests.swift */; }; + 1C0259F302A2495DC4CF701E /* RightSidebarTabPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477466F318D44362449A491B /* RightSidebarTabPreferences.swift */; }; FE0031A3 /* RightSidebarToolPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0030A3 /* RightSidebarToolPanel.swift */; }; C46790000000000000000005 /* RosettaNativeRelaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000006 /* RosettaNativeRelaunch.swift */; }; C46790000000000000000007 /* RosettaNativeRelaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000008 /* RosettaNativeRelaunchTests.swift */; }; @@ -5381,6 +5383,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C57570010000000000000001 /* RightSidebarPanelViewTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarPanelViewTestSupport.swift; sourceTree = ""; }; B37A0000000000000000000E /* RightSidebarRemoteCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarRemoteCommand.swift; sourceTree = ""; }; 5F5553CB5553CB5553CB0002 /* RightSidebarRemoteCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarRemoteCommandTests.swift; sourceTree = ""; }; + 2217672A4FBF3048C6C17EA3 /* RightSidebarTabCustomizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarTabCustomizationTests.swift; sourceTree = ""; }; + 477466F318D44362449A491B /* RightSidebarTabPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarTabPreferences.swift; sourceTree = ""; }; FE0030A3 /* RightSidebarToolPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarToolPanel.swift; sourceTree = ""; }; C46790000000000000000006 /* RosettaNativeRelaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/RosettaNativeRelaunch.swift; sourceTree = ""; }; C46790000000000000000008 /* RosettaNativeRelaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RosettaNativeRelaunchTests.swift; sourceTree = ""; }; @@ -8718,6 +8722,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef FE003003 /* RightSidebarPanelView.swift */, 46814000000000000000000E /* RightSidebarModeShortcutEntry.swift */, 468110000000000000000006 /* RightSidebarModeShortcutMatcher.swift */, + 477466F318D44362449A491B /* RightSidebarTabPreferences.swift */, FE0030A3 /* RightSidebarToolPanel.swift */, DCDC1000000000000000B002 /* DockConfigurationLoadResult.swift */, DCDC1000000000000000B004 /* DockConfigFile.swift */, @@ -9450,6 +9455,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B7758A020000000000000002 /* TerminationWatchdogTests.swift */, 6042B0016042B0016042B001 /* ShortcutHintModifierPolicyTests.swift */, C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */, + 2217672A4FBF3048C6C17EA3 /* RightSidebarTabCustomizationTests.swift */, 78F49DA9F80DC43173341410 /* MachinesPanelModelTests.swift */, 763D217526D3832668F47568 /* CloudAgentSkillLauncherTests.swift */, 5130177E80C2A18E09DBBBC4 /* NewMachineModelTests.swift */, @@ -11694,6 +11700,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 468110000000000000000005 /* RightSidebarModeShortcutMatcher.swift in Sources */, FE003103 /* RightSidebarPanelView.swift in Sources */, B37A0000000000000000000D /* RightSidebarRemoteCommand.swift in Sources */, + 1C0259F302A2495DC4CF701E /* RightSidebarTabPreferences.swift in Sources */, FE0031A3 /* RightSidebarToolPanel.swift in Sources */, C46790000000000000000005 /* RosettaNativeRelaunch.swift in Sources */, FE003105 /* RovoDevIndex.swift in Sources */, @@ -13278,6 +13285,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */, C57570010000000000000002 /* RightSidebarPanelViewTestSupport.swift in Sources */, 5F5553CB5553CB5553CB0001 /* RightSidebarRemoteCommandTests.swift in Sources */, + 07EB8695682FE486A366F22E /* RightSidebarTabCustomizationTests.swift in Sources */, C46790000000000000000007 /* RosettaNativeRelaunchTests.swift in Sources */, F5310000A1B2C3D4E5F60718 /* RovoDevHookConfigTests.swift in Sources */, F5300000A1B2C3D4E5F60718 /* RovoDevSessionIndexTests.swift in Sources */, diff --git a/cmuxTests/RightSidebarTabCustomizationTests.swift b/cmuxTests/RightSidebarTabCustomizationTests.swift new file mode 100644 index 000000000000..b85c347ec71e --- /dev/null +++ b/cmuxTests/RightSidebarTabCustomizationTests.swift @@ -0,0 +1,182 @@ +import Foundation +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Behavior of the customizable right-sidebar tabs: user-defined order and +/// visibility, and the positional `ctrl+digit` shortcut defaults that follow +/// the visible order (the Nth visible tab answers ctrl+N). +@MainActor +final class RightSidebarTabCustomizationTests: XCTestCase { + private var defaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "RightSidebarTabCustomizationTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + super.tearDown() + } + + private func enableAllModeGates() { + defaults.set(true, forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) + defaults.set(true, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) + defaults.set(true, forKey: RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey) + } + + private func enableMachinesGate() { + defaults.set(true, forKey: RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey) + } + + // MARK: - Ordering and visibility + + func testDefaultOrderIsCanonical() { + XCTAssertEqual( + RightSidebarTabPreferences.orderedModes(defaults: defaults), + [.files, .find, .sessions, .feed, .dock, .machines] + ) + } + + func testStoredOrderIgnoresUnknownEntriesAndAppendsMissingModes() { + defaults.set(["machines", "bogus", "files", "custom-sidebar"], forKey: RightSidebarTabPreferences.orderKey) + XCTAssertEqual( + RightSidebarTabPreferences.orderedModes(defaults: defaults), + [.machines, .files, .find, .sessions, .feed, .dock] + ) + } + + func testVisibleModesDropUserHiddenTabs() { + enableAllModeGates() + XCTAssertTrue(RightSidebarTabPreferences.setHidden(true, mode: .find, defaults: defaults)) + XCTAssertEqual( + RightSidebarMode.visibleModes(defaults: defaults), + [.files, .sessions, .feed, .dock, .machines] + ) + } + + func testHidingLastVisibleTabIsRefused() { + // Feed and Dock are feature-gated off in this suite; Cloud may be on + // through the process-global rollout flag, so hide it explicitly. + XCTAssertTrue(RightSidebarTabPreferences.setHidden(true, mode: .files, defaults: defaults)) + XCTAssertTrue(RightSidebarTabPreferences.setHidden(true, mode: .find, defaults: defaults)) + XCTAssertTrue(RightSidebarTabPreferences.setHidden(true, mode: .machines, defaults: defaults)) + XCTAssertFalse( + RightSidebarTabPreferences.setHidden(true, mode: .sessions, defaults: defaults), + "the last visible tab must stay visible" + ) + XCTAssertEqual(RightSidebarMode.visibleModes(defaults: defaults), [.sessions]) + } + + func testMoveReordersAndClampsAtEdges() { + RightSidebarTabPreferences.move(.machines, offset: -5, defaults: defaults) + XCTAssertEqual( + RightSidebarTabPreferences.orderedModes(defaults: defaults), + [.machines, .files, .find, .sessions, .feed, .dock] + ) + RightSidebarTabPreferences.move(.machines, offset: -1, defaults: defaults) + XCTAssertEqual( + RightSidebarTabPreferences.orderedModes(defaults: defaults).first, + .machines, + "moving past the front clamps" + ) + } + + func testResetRestoresCanonicalOrderAndVisibility() { + RightSidebarTabPreferences.move(.machines, offset: -5, defaults: defaults) + RightSidebarTabPreferences.setHidden(true, mode: .find, defaults: defaults) + RightSidebarTabPreferences.resetToDefaults(defaults: defaults) + XCTAssertEqual( + RightSidebarTabPreferences.orderedModes(defaults: defaults), + [.files, .find, .sessions, .feed, .dock, .machines] + ) + XCTAssertTrue(RightSidebarTabPreferences.hiddenModes(defaults: defaults).isEmpty) + } + + // MARK: - Positional shortcut defaults + + /// The reported bug: with Feed and Dock hidden (their beta gates default + /// off), Cloud is the 4th visible tab, so ctrl+4 must focus it. The old + /// static table pinned Cloud to ctrl+6, three positions past what the mode + /// bar showed. + func testCloudDefaultsToControlFourWhenFeedAndDockAreHidden() { + enableMachinesGate() + XCTAssertEqual( + RightSidebarMode.visibleModes(defaults: defaults), + [.files, .find, .sessions, .machines] + ) + XCTAssertEqual( + KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .machines, defaults: defaults), + StoredShortcut(key: "4", command: false, shift: false, option: false, control: true) + ) + } + + func testAllTabsVisibleKeepsHistoricDigits() { + enableAllModeGates() + let expected: [(RightSidebarMode, String)] = [ + (.files, "1"), (.find, "2"), (.sessions, "3"), (.feed, "4"), (.dock, "5"), (.machines, "6"), + ] + for (mode, digit) in expected { + XCTAssertEqual( + KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: mode, defaults: defaults), + StoredShortcut(key: digit, command: false, shift: false, option: false, control: true), + "\(mode) should default to ctrl+\(digit)" + ) + } + } + + func testHiddenTabDefaultsToUnboundAndLaterDigitsShift() { + enableAllModeGates() + RightSidebarTabPreferences.setHidden(true, mode: .find, defaults: defaults) + XCTAssertEqual( + KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .find, defaults: defaults), + .unbound + ) + XCTAssertEqual( + KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .sessions, defaults: defaults), + StoredShortcut(key: "2", command: false, shift: false, option: false, control: true) + ) + } + + func testReorderMovesDigitsWithTheTabs() { + enableAllModeGates() + RightSidebarTabPreferences.move(.machines, offset: -5, defaults: defaults) + XCTAssertEqual( + KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .machines, defaults: defaults), + StoredShortcut(key: "1", command: false, shift: false, option: false, control: true) + ) + XCTAssertEqual( + KeyboardShortcutSettings.rightSidebarPositionalDefaultShortcut(for: .files, defaults: defaults), + StoredShortcut(key: "2", command: false, shift: false, option: false, control: true) + ) + } + + func testPositionalDigitStopsAtNine() { + enableAllModeGates() + for (index, mode) in RightSidebarMode.visibleModes(defaults: defaults).enumerated() { + XCTAssertEqual( + RightSidebarMode.positionalDigit(for: mode, defaults: defaults), + index < 9 ? index + 1 : nil + ) + } + } + + func testMutationsPostShortcutSettingsDidChange() { + enableAllModeGates() + let expectation = expectation( + forNotification: KeyboardShortcutSettings.didChangeNotification, + object: nil + ) + RightSidebarTabPreferences.setHidden(true, mode: .feed, defaults: defaults) + wait(for: [expectation], timeout: 1) + } +} diff --git a/cmuxTests/ShortcutAndCommandPaletteTests.swift b/cmuxTests/ShortcutAndCommandPaletteTests.swift index c73aed86f765..b59000544ee8 100644 --- a/cmuxTests/ShortcutAndCommandPaletteTests.swift +++ b/cmuxTests/ShortcutAndCommandPaletteTests.swift @@ -1201,8 +1201,8 @@ final class RightSidebarModeShortcutHintTests: XCTestCase { RightSidebarBetaFeatureSettings.feedEnabledKey, RightSidebarBetaFeatureSettings.dockEnabledKey, RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey, - "rightSidebar.tabs.order", - "rightSidebar.tabs.hidden", + RightSidebarTabPreferences.orderKey, + RightSidebarTabPreferences.hiddenKey, ] private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! private var savedShortcutData: [KeyboardShortcutSettings.Action: Data?] = [:] @@ -1225,8 +1225,8 @@ final class RightSidebarModeShortcutHintTests: XCTestCase { UserDefaults.standard.set(true, forKey: RightSidebarBetaFeatureSettings.feedEnabledKey) UserDefaults.standard.set(true, forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) UserDefaults.standard.set(true, forKey: RightSidebarBetaFeatureSettings.cloudMachinesEnabledKey) - UserDefaults.standard.removeObject(forKey: "rightSidebar.tabs.order") - UserDefaults.standard.removeObject(forKey: "rightSidebar.tabs.hidden") + UserDefaults.standard.removeObject(forKey: RightSidebarTabPreferences.orderKey) + UserDefaults.standard.removeObject(forKey: RightSidebarTabPreferences.hiddenKey) let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/web/messages/en.json b/web/messages/en.json index c2ca46727e8d..baa4f6e2294a 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -2312,7 +2312,7 @@ "shortcutsWhenBooleanKeys": "boolean keys. An unknown or absent key reads as false; the literals true and false are also accepted.", "shortcutsWhenTypedKeys": "typed keys for comparisons: the right sidebar's active mode (a string) plus pane and workspace counts (integers).", "shortcutsWhenOperators": "boolean operators, typed comparisons, regex match, and list membership. Comparisons bind tighter than && and ||.", - "shortcutsWhenExample": "For example, this makes Ctrl+1–9 select workspaces everywhere except when the right sidebar is focused (leaving Ctrl+1–5 free for the sidebar's mode switcher), and scopes surface selection to the Find sidebar when the workspace has multiple panes:" + "shortcutsWhenExample": "For example, this makes Ctrl+1–9 select workspaces everywhere except when the right sidebar is focused (leaving the Ctrl+digit tab shortcuts free for the sidebar's mode switcher), and scopes surface selection to the Find sidebar when the workspace has multiple panes:" }, "customCommands": { "title": "Custom Commands", diff --git a/web/messages/ja.json b/web/messages/ja.json index 14b877134f2e..5002077f5340 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -2235,7 +2235,7 @@ "shortcutsWhenBooleanKeys": "boolean キー。未知または存在しないキーは false として扱われ、リテラル true / false も使えます。", "shortcutsWhenTypedKeys": "比較用の型付きキー。右サイドバーのアクティブモード(文字列)と、ペイン数・ワークスペース数(整数)です。", "shortcutsWhenOperators": "boolean 演算子、型付き比較、正規表現マッチ、リスト所属。比較は && や || より強く結合します。", - "shortcutsWhenExample": "例えば次の設定では、右サイドバーにフォーカスがある場合を除いて Ctrl+1–9 でワークスペースを選択し(Ctrl+1–5 はサイドバーのモード切り替えに残します)、ワークスペースに複数のペインがあるときはサーフェス選択を Find サイドバーに限定します。" + "shortcutsWhenExample": "例えば次の設定では、右サイドバーにフォーカスがある場合を除いて Ctrl+1–9 でワークスペースを選択し(Ctrl+数字 はサイドバーのモード切り替えに残します)、ワークスペースに複数のペインがあるときはサーフェス選択を Find サイドバーに限定します。" }, "customCommands": { "title": "カスタムコマンド", From bc99270f19ce9366e52cf3b2665e913473a20aed Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:41:48 -0700 Subject: [PATCH 03/10] Rebuild shortcut matcher snapshots after installing the default-stroke provider The KeyboardShortcutSettingsObserver.shared @State stored property builds its matcher snapshot before cmuxApp's init body runs, so the right-sidebar digit entries were cached from the builtin table and ctrl+4 still fell on the invisible Feed. Posting the standard shortcut-settings change notification after installing the provider rebuilds those snapshots against the positional defaults. --- Sources/cmuxApp.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f7b470d2975c..05ddf7db3a04 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -103,6 +103,11 @@ struct cmuxApp: App { // of its own, which this initializer must not resolve to. return .stroke(CmuxSettings.ShortcutStroke(key: String(digit), control: true)) } + // Stored-property initializers above this init body (the + // KeyboardShortcutSettingsObserver.shared @State member) already built + // matcher snapshots against the builtin table; the standard change + // notification rebuilds them against the provider. + NotificationCenter.default.post(name: KeyboardShortcutSettings.didChangeNotification, object: nil) // Gather settings package dependencies once. The runtime itself // is assigned after the saved language override below, because From ff99843a73cc305976de720002da383201325de5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:15:01 -0700 Subject: [PATCH 04/10] Right sidebar: drag a mode-bar pill to reorder tabs inline Each pill is draggable (in-process custom UTI, declared in Info.plist like the workspace-tab reorder type). Hovering another pill commits the new order through RightSidebarTabPreferences.setDisplayedOrder, which permutes only the displayed tabs' slots so hidden tabs keep their place, and the existing change notification re-renders the bar and shifts the ctrl+digit defaults with the tabs. --- Resources/Info.plist | 10 ++ Sources/RightSidebarPanelView.swift | 94 +++++++++++++++++++ Sources/RightSidebarTabPreferences.swift | 19 ++++ .../RightSidebarTabCustomizationTests.swift | 35 +++++++ 4 files changed, 158 insertions(+) diff --git a/Resources/Info.plist b/Resources/Info.plist index 3965afc5566d..b04100afbc70 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -370,6 +370,16 @@ public.data + + UTTypeIdentifier + com.cmux.right-sidebar-mode-reorder + UTTypeDescription + cmux Right Sidebar Tab Reorder + UTTypeConformsTo + + public.data + + UTTypeIdentifier com.cmux.filepreview.transfer diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index f15f3af34074..2dc1d65bea38 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -10,6 +10,7 @@ import CmuxSidebarRemoteRender import CmuxSwiftRender import CmuxSwiftRenderUI import SwiftUI +import UniformTypeIdentifiers private func rightSidebarDebugResponder(_ responder: NSResponder?) -> String { guard let responder else { return "nil" } @@ -138,6 +139,7 @@ struct RightSidebarPanelView: View { @State private var focusShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var closeShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var hasMountedRightSidebarContent = false + @State private var draggingModeBarMode: RightSidebarMode? @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let alwaysShowShortcutHints = ShortcutHintDebugSettings().alwaysShowHints private let closeShortcutHintXOffset = ShortcutHintDebugSettings.defaultRightSidebarCloseHintX @@ -275,6 +277,7 @@ struct RightSidebarPanelView: View { WindowDragHandleView() HStack(spacing: RightSidebarChromeMetrics.headerControlSpacing) { + let displayedModes = availableModes ForEach(modeBarItems) { item in let shortcut = item.shortcutAction.map { KeyboardShortcutSettings.shortcut(for: $0) } ?? .unbound ModeBarButton( @@ -300,6 +303,18 @@ struct RightSidebarPanelView: View { selectMode(mode) } } + .onDrag { + draggingModeBarMode = item.mode + return RightSidebarModeDragPayload.provider(for: item.mode) + } + .onDrop( + of: [RightSidebarModeDragPayload.dropContentType], + delegate: RightSidebarModeBarDropDelegate( + targetMode: item.mode, + displayedModes: displayedModes, + draggingMode: $draggingModeBarMode + ) + ) } Spacer(minLength: 0) if fileExplorerState.mode.canOpenAsPane { @@ -694,3 +709,82 @@ extension NSView { return true } } + +/// Drag payload for reordering the mode bar's tabs in place. Same shape as +/// `SidebarTabDragPayload`: an in-process custom UTI (declared in +/// `Resources/Info.plist` under `UTExportedTypeDeclarations`) carrying the +/// dragged mode's raw value. +enum RightSidebarModeDragPayload { + static let typeIdentifier = "com.cmux.right-sidebar-mode-reorder" + static let dropContentType = UTType(exportedAs: typeIdentifier) + + static func provider(for mode: RightSidebarMode) -> NSItemProvider { + let provider = NSItemProvider() + let data = Data(mode.rawValue.utf8) + provider.registerDataRepresentation( + forTypeIdentifier: typeIdentifier, + visibility: .ownProcess + ) { completion in + completion(data, nil) + return nil + } + return provider + } +} + +/// Pure hover-reorder math for the mode bar, kept UI-free so unit tests cover +/// the move without a drag session. +enum RightSidebarModeBarReorderPolicy { + /// The displayed order after dragging `dragged` over `target`, or nil when + /// the hover changes nothing (same pill, or either mode absent). + static func displayedOrder( + moving dragged: RightSidebarMode, + over target: RightSidebarMode, + in displayed: [RightSidebarMode] + ) -> [RightSidebarMode]? { + guard dragged != target, + let from = displayed.firstIndex(of: dragged), + let to = displayed.firstIndex(of: target), + from != to else { + return nil + } + var next = displayed + next.remove(at: from) + next.insert(dragged, at: to) + return next + } +} + +/// Reorders the mode bar while a pill drags across its siblings. Like the +/// workspace-tab reorder, the order commits live on every hover step +/// (`RightSidebarTabPreferences` is the single mutation path and its change +/// notification re-renders the bar), so there is no separate cancel state to +/// reconcile. +struct RightSidebarModeBarDropDelegate: DropDelegate { + let targetMode: RightSidebarMode + let displayedModes: [RightSidebarMode] + @Binding var draggingMode: RightSidebarMode? + + func dropEntered(info: DropInfo) { + guard let dragging = draggingMode, + let next = RightSidebarModeBarReorderPolicy.displayedOrder( + moving: dragging, + over: targetMode, + in: displayedModes + ) else { + return + } + withAnimation(.easeInOut(duration: 0.15)) { + RightSidebarTabPreferences.setDisplayedOrder(next) + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + draggingMode = nil + return true + } +} diff --git a/Sources/RightSidebarTabPreferences.swift b/Sources/RightSidebarTabPreferences.swift index 66aa673b9c68..e8339c41b475 100644 --- a/Sources/RightSidebarTabPreferences.swift +++ b/Sources/RightSidebarTabPreferences.swift @@ -61,6 +61,25 @@ enum RightSidebarTabPreferences { return true } + /// Rewrites the relative order of the modes in `displayed` (the mode bar's + /// pills, left to right) while every other tab keeps its slot in the full + /// order. Used by drag-to-reorder: the bar shows a subset (hidden tabs are + /// absent, except a revealed active one), so only that subset's slots are + /// permuted. + static func setDisplayedOrder(_ displayed: [RightSidebarMode], defaults: UserDefaults = .standard) { + var queue = displayed.filter { $0 != .customSidebar } + let displayedSet = Set(queue) + let currentOrder = orderedModes(defaults: defaults) + var order = currentOrder + for index in order.indices where displayedSet.contains(order[index]) { + guard !queue.isEmpty else { break } + order[index] = queue.removeFirst() + } + guard order != currentOrder else { return } + defaults.set(order.map(\.rawValue), forKey: orderKey) + notifyChanged() + } + /// Moves one tab by `offset` within the full ordered tab list (hidden tabs /// keep their slot so re-showing one restores its place). static func move(_ mode: RightSidebarMode, offset: Int, defaults: UserDefaults = .standard) { diff --git a/cmuxTests/RightSidebarTabCustomizationTests.swift b/cmuxTests/RightSidebarTabCustomizationTests.swift index b85c347ec71e..a1f6db086b77 100644 --- a/cmuxTests/RightSidebarTabCustomizationTests.swift +++ b/cmuxTests/RightSidebarTabCustomizationTests.swift @@ -91,6 +91,41 @@ final class RightSidebarTabCustomizationTests: XCTestCase { ) } + func testSetDisplayedOrderPermutesOnlyTheDisplayedSlots() { + // Hide Feed; Dock stays hidden-by-gate but keeps its slot in the full + // order. Dragging Cloud before Files must not move Feed or Dock. + enableAllModeGates() + RightSidebarTabPreferences.setHidden(true, mode: .feed, defaults: defaults) + RightSidebarTabPreferences.setDisplayedOrder( + [.machines, .files, .find, .sessions, .dock], + defaults: defaults + ) + XCTAssertEqual( + RightSidebarTabPreferences.orderedModes(defaults: defaults), + [.machines, .files, .find, .feed, .sessions, .dock], + "hidden Feed keeps its 4th slot while the displayed tabs permute around it" + ) + } + + func testModeBarReorderPolicyMovesDraggedPillOverTarget() { + let displayed: [RightSidebarMode] = [.files, .find, .sessions, .machines] + XCTAssertEqual( + RightSidebarModeBarReorderPolicy.displayedOrder(moving: .machines, over: .files, in: displayed), + [.machines, .files, .find, .sessions] + ) + XCTAssertEqual( + RightSidebarModeBarReorderPolicy.displayedOrder(moving: .files, over: .sessions, in: displayed), + [.find, .sessions, .files, .machines] + ) + XCTAssertNil( + RightSidebarModeBarReorderPolicy.displayedOrder(moving: .files, over: .files, in: displayed) + ) + XCTAssertNil( + RightSidebarModeBarReorderPolicy.displayedOrder(moving: .feed, over: .files, in: displayed), + "a mode absent from the bar cannot reorder it" + ) + } + func testResetRestoresCanonicalOrderAndVisibility() { RightSidebarTabPreferences.move(.machines, offset: -5, defaults: defaults) RightSidebarTabPreferences.setHidden(true, mode: .find, defaults: defaults) From 59c0c4fa585f8313d122676da6e2d2a80070331d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:03:55 -0700 Subject: [PATCH 05/10] fix(sidebar): refresh gated shortcuts and preserve visible tab --- Sources/HostSettingsActions.swift | 1 + Sources/KeyboardShortcutSettingsObserver.swift | 17 +++++++++++++++++ Sources/RightSidebarTabPreferences.swift | 5 ++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index c2c5a82eca67..d616851bbefa 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -265,6 +265,7 @@ final class HostSettingsActions: SettingsHostActions { let observers = [ RightSidebarTabPreferences.didChangeNotification, KeyboardShortcutSettings.didChangeNotification, + UserDefaults.didChangeNotification, ].map { name in MobileHostStatusObserverToken( NotificationCenter.default.addObserver( diff --git a/Sources/KeyboardShortcutSettingsObserver.swift b/Sources/KeyboardShortcutSettingsObserver.swift index 154cb50b1403..a04dcf7c23f3 100644 --- a/Sources/KeyboardShortcutSettingsObserver.swift +++ b/Sources/KeyboardShortcutSettingsObserver.swift @@ -20,6 +20,8 @@ final class KeyboardShortcutSettingsObserver { @ObservationIgnored private var settingsObserver: NSObjectProtocol? @ObservationIgnored + private var featureGateObserver: NSObjectProtocol? + @ObservationIgnored private var recorderObserver: NSObjectProtocol? @ObservationIgnored private var inputSourceObserver: NSObjectProtocol? @@ -45,6 +47,18 @@ final class KeyboardShortcutSettingsObserver { self?.reloadCachedShortcuts() } } + // Feature-gated sidebar defaults are also stored in UserDefaults. The + // matcher must rebuild when a gate changes, even when no shortcut was + // edited. + featureGateObserver = notificationCenter.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + Self.deliverOnMainActor { [weak self] in + self?.reloadCachedShortcuts() + } + } recorderObserver = notificationCenter.addObserver( forName: KeyboardShortcutRecorderActivity.didChangeNotification, object: nil, @@ -71,6 +85,9 @@ final class KeyboardShortcutSettingsObserver { if let settingsObserver { notificationCenter.removeObserver(settingsObserver) } + if let featureGateObserver { + notificationCenter.removeObserver(featureGateObserver) + } if let recorderObserver { notificationCenter.removeObserver(recorderObserver) } diff --git a/Sources/RightSidebarTabPreferences.swift b/Sources/RightSidebarTabPreferences.swift index e8339c41b475..7ccd65715511 100644 --- a/Sources/RightSidebarTabPreferences.swift +++ b/Sources/RightSidebarTabPreferences.swift @@ -50,7 +50,10 @@ enum RightSidebarTabPreferences { var hiddenSet = hiddenModes(defaults: defaults) if hidden { guard hiddenSet.insert(mode).inserted else { return true } - guard RightSidebarMode.visibleModes(defaults: defaults).contains(where: { $0 != mode }) else { + let remainingVisible = orderedModes(defaults: defaults).contains { + $0 != mode && $0.isAvailable(defaults: defaults) && !hiddenSet.contains($0) + } + guard remainingVisible else { return false } } else { From 37e26cb01374909d624122e3346cf478f33259b2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:40:24 -0700 Subject: [PATCH 06/10] fix(sidebar): remove duplicate defaults observer --- Sources/HostSettingsActions.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index d616851bbefa..c2c5a82eca67 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -265,7 +265,6 @@ final class HostSettingsActions: SettingsHostActions { let observers = [ RightSidebarTabPreferences.didChangeNotification, KeyboardShortcutSettings.didChangeNotification, - UserDefaults.didChangeNotification, ].map { name in MobileHostStatusObserverToken( NotificationCenter.default.addObserver( From 518b173d459b42e19c4782913e4d17387f8bd4d0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:42:11 -0700 Subject: [PATCH 07/10] fix settings shortcut override synchronization --- .../Values/ShortcutDefaultOverrides.swift | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift index 05cc5b41d391..3027406ac569 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization /// Host-installed dynamic default strokes. /// @@ -9,7 +10,7 @@ import Foundation /// resolution, legacy conflict checks) then agrees with the app's runtime /// defaults. Without a provider (package tests, other hosts) the static table /// in `ShortcutAction+Defaults.swift` answers unchanged. -public enum ShortcutDefaultOverrides { +public struct ShortcutDefaultOverrides { /// What the host wants for one action's factory default. public enum Result: Sendable { /// Use the package's built-in static table. @@ -20,22 +21,18 @@ public enum ShortcutDefaultOverrides { public typealias Provider = @Sendable (ShortcutAction) -> Result - private static let lock = NSLock() - nonisolated(unsafe) private static var _provider: Provider? + private static let provider = Mutex(nil) /// Installs (or clears) the host provider. Call once at launch, before /// shortcut resolution begins; the provider itself must read live state so /// later preference changes need no re-install. public static func install(_ provider: Provider?) { - lock.lock() - defer { lock.unlock() } - _provider = provider + Self.provider.withLock { $0 = provider } } static func result(for action: ShortcutAction) -> Result { - lock.lock() - let provider = _provider - lock.unlock() - return provider?(action) ?? .useBuiltIn + Self.provider.withLock { provider in + provider?(action) ?? .useBuiltIn + } } } From 4b93b379caf8cd64f95c49b47f507a8308fc477f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:13:59 -0700 Subject: [PATCH 08/10] inject host-scoped shortcut defaults --- .../Values/ShortcutAction+Defaults.swift | 34 +++++++++--- ...ortcutAction+LegacyDefaultResolution.swift | 16 +++++- .../Values/ShortcutBindingPolicyResult.swift | 14 ++++- .../Values/ShortcutDefaultOverrides.swift | 38 ------------- .../Values/ShortcutDefaultResolver.swift | 36 ++++++++++++ .../ShortcutActionNumberedDigitTests.swift | 40 ++++++++++++++ .../ShortcutListModel+Resolution.swift | 4 ++ .../Bindings/ShortcutListModel.swift | 6 ++ .../Environment/SettingsRuntime.swift | 6 ++ .../Scene/SettingsWindowScene.swift | 6 +- .../Sections/GlobalHotkeySection.swift | 5 +- .../Sections/KeyboardShortcutsSection.swift | 5 +- .../ShortcutListModelTests.swift | 45 +++++++++++++++ ...tcutSettings+PersistedShortcutPolicy.swift | 1 + Sources/KeyboardShortcutSettingsLookup.swift | 1 + Sources/cmuxApp.swift | 55 ++++++++----------- 16 files changed, 228 insertions(+), 84 deletions(-) delete mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultResolver.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 2f7200d58bb2..be70f3a67879 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -24,6 +24,22 @@ extension ShortcutAction { } } + /// Returns this action's factory default using a host-owned resolver. + /// + /// Chords are package-owned and therefore do not consult the resolver. + /// A resolver may return ``ShortcutDefaultResolver.Result/stroke(_:)`` with + /// `nil` to explicitly make an action unbound for the host. When it returns + /// ``ShortcutDefaultResolver.Result/useBuiltIn``, this method falls back to + /// the package table. + public func defaultShortcut(using resolver: ShortcutDefaultResolver) -> StoredShortcut? { + switch self { + case .diffViewerScrollToTop, .diffViewerNextFile, .diffViewerPreviousFile: + return defaultShortcut + default: + return defaultStroke(using: resolver).map { StoredShortcut(first: $0) } + } + } + /// The factory-default ``ShortcutStroke`` for this action. /// /// Mirrors the table in @@ -33,16 +49,20 @@ extension ShortcutAction { /// UI can restore a row by writing the default stroke through /// the JSON store. /// - /// A host-installed ``ShortcutDefaultOverrides`` provider takes - /// precedence: cmux computes the right-sidebar digit defaults - /// positionally from the visible tab order, which this package - /// cannot read. The static values below for those actions are the - /// factory tab-order fallback used when no provider is installed. + /// The package-owned default table. Hosts with dynamic defaults should use + /// ``defaultStroke(using:)`` and pass their resolver explicitly. public var defaultStroke: ShortcutStroke? { - if case .stroke(let stroke) = ShortcutDefaultOverrides.result(for: self) { + return builtInDefaultStroke + } + + /// Returns this action's stroke after applying a host-owned resolver. + public func defaultStroke(using resolver: ShortcutDefaultResolver) -> ShortcutStroke? { + switch resolver.result(for: self) { + case .useBuiltIn: + return builtInDefaultStroke + case .stroke(let stroke): return stroke } - return builtInDefaultStroke } private var builtInDefaultStroke: ShortcutStroke? { diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+LegacyDefaultResolution.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+LegacyDefaultResolution.swift index 740f54657e3a..625f06e74d32 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+LegacyDefaultResolution.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+LegacyDefaultResolution.swift @@ -1,8 +1,14 @@ extension ShortcutAction { /// Resolves a persisted shortcut while preserving an explicitly configured /// binding that predates a built-in default migration. + /// + /// - Parameters: + /// - candidate: The configured shortcut, or `nil` when no override exists. + /// - hostDefault: An optional host-owned default. Pass + /// ``StoredShortcut/unbound`` to explicitly disable the built-in value. public func effectivePersistedShortcutResolvingLegacyConflicts( _ candidate: StoredShortcut?, + defaultShortcut hostDefault: StoredShortcut? = nil, explicitlyConfiguredShortcut: (ShortcutAction) -> StoredShortcut?, bindingsConflict: ( _ proposed: StoredShortcut, @@ -13,6 +19,7 @@ extension ShortcutAction { ) -> StoredShortcut? { effectivePersistedShortcutResolvingLegacyConflicts( candidate, + defaultShortcut: hostDefault, normalizing: { shortcut in shortcutBindingPolicyResult(for: shortcut) == .accepted ? shortcut.canonicalized() @@ -25,8 +32,14 @@ extension ShortcutAction { } /// Consumer-normalized variant used by the app runtime and Settings UI. + /// + /// - Parameters: + /// - candidate: The configured shortcut, or `nil` when no override exists. + /// - hostDefault: An optional host-owned default. Pass + /// ``StoredShortcut/unbound`` to explicitly disable the built-in value. public func effectivePersistedShortcutResolvingLegacyConflicts( _ candidate: StoredShortcut?, + defaultShortcut hostDefault: StoredShortcut? = nil, normalizing: (StoredShortcut) -> StoredShortcut?, conflictsWithReservedShortcut: (StoredShortcut) -> Bool, explicitlyConfiguredShortcut: (ShortcutAction) -> StoredShortcut?, @@ -38,13 +51,14 @@ extension ShortcutAction { ) -> StoredShortcut? { guard let resolved = effectivePersistedShortcut( candidate, + defaultShortcut: hostDefault, normalizing: normalizing, conflictsWithReservedShortcut: conflictsWithReservedShortcut ) else { return nil } guard candidate != resolved, - let normalizedDefault = defaultShortcut.flatMap(normalizing), + let normalizedDefault = (hostDefault ?? defaultShortcut).flatMap(normalizing), resolved == normalizedDefault, let legacyAction = legacyActionDisplacingBuiltInDefault, let legacyShortcut = explicitlyConfiguredShortcut(legacyAction), diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingPolicyResult.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingPolicyResult.swift index 71a3d2e0acea..286815cb3c0f 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingPolicyResult.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingPolicyResult.swift @@ -72,15 +72,19 @@ extension ShortcutAction { /// /// - Parameters: /// - candidate: The configured shortcut, or `nil` when no override exists. + /// - hostDefault: An optional host-owned default. Pass + /// ``StoredShortcut/unbound`` to explicitly disable the built-in value. /// - conflictsWithReservedShortcut: Whether a normalized shortcut is reserved /// by a higher-priority system-wide binding. /// - Returns: The executable shortcut, or `nil` when the action is unbound. public func effectivePersistedShortcut( _ candidate: StoredShortcut?, + defaultShortcut hostDefault: StoredShortcut? = nil, conflictsWithReservedShortcut: (StoredShortcut) -> Bool = { _ in false } ) -> StoredShortcut? { effectivePersistedShortcut( candidate, + defaultShortcut: hostDefault, normalizing: { shortcut in shortcutBindingPolicyResult(for: shortcut) == .accepted ? shortcut.canonicalized() @@ -94,6 +98,8 @@ extension ShortcutAction { /// /// - Parameters: /// - candidate: The configured shortcut, or `nil` when no override exists. + /// - hostDefault: An optional host-owned default. Pass + /// ``StoredShortcut/unbound`` to explicitly disable the built-in value. /// - normalizing: Returns the executable representation of a shortcut, or /// `nil` when the consumer cannot execute it. /// - conflictsWithReservedShortcut: Whether a normalized shortcut is reserved @@ -101,6 +107,7 @@ extension ShortcutAction { /// - Returns: The executable shortcut, or `nil` when the action is unbound. public func effectivePersistedShortcut( _ candidate: StoredShortcut?, + defaultShortcut hostDefault: StoredShortcut? = nil, normalizing: (StoredShortcut) -> StoredShortcut?, conflictsWithReservedShortcut: (StoredShortcut) -> Bool ) -> StoredShortcut? { @@ -117,9 +124,10 @@ extension ShortcutAction { } } - guard let defaultShortcut, - !defaultShortcut.isUnbound, - let normalizedDefault = normalizing(defaultShortcut), + let fallback = hostDefault ?? defaultShortcut + guard let fallback, + !fallback.isUnbound, + let normalizedDefault = normalizing(fallback), !conflictsWithReservedShortcut(normalizedDefault) else { return nil } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift deleted file mode 100644 index 3027406ac569..000000000000 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultOverrides.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation -import Synchronization - -/// Host-installed dynamic default strokes. -/// -/// cmux resolves the right-sidebar digit defaults positionally from the user's -/// visible tab order, which lives in host-owned state this package cannot -/// read. The host installs a provider at launch; every package consumer of -/// ``ShortcutAction/defaultStroke`` (the Settings UI's effective-shortcut -/// resolution, legacy conflict checks) then agrees with the app's runtime -/// defaults. Without a provider (package tests, other hosts) the static table -/// in `ShortcutAction+Defaults.swift` answers unchanged. -public struct ShortcutDefaultOverrides { - /// What the host wants for one action's factory default. - public enum Result: Sendable { - /// Use the package's built-in static table. - case useBuiltIn - /// Use this stroke; `nil` means the action defaults to unbound. - case stroke(ShortcutStroke?) - } - - public typealias Provider = @Sendable (ShortcutAction) -> Result - - private static let provider = Mutex(nil) - - /// Installs (or clears) the host provider. Call once at launch, before - /// shortcut resolution begins; the provider itself must read live state so - /// later preference changes need no re-install. - public static func install(_ provider: Provider?) { - Self.provider.withLock { $0 = provider } - } - - static func result(for action: ShortcutAction) -> Result { - Self.provider.withLock { provider in - provider?(action) ?? .useBuiltIn - } - } -} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultResolver.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultResolver.swift new file mode 100644 index 000000000000..065e0fde41c5 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutDefaultResolver.swift @@ -0,0 +1,36 @@ +/// Resolves host-specific shortcut defaults without shared mutable state. +/// +/// The settings package owns the built-in table, but a host may have a more +/// specific default. For example, cmux assigns right-sidebar digit shortcuts +/// from the visible tab order. The host constructs one resolver at its +/// composition root and passes it to the settings owner that needs it. A +/// resolver is a value, so previews and multiple hosts can use different +/// defaults in the same process without affecting one another. +public struct ShortcutDefaultResolver: Sendable { + /// The result of resolving one action's host default. + public enum Result: Sendable { + /// Use the package's built-in default. + case useBuiltIn + /// Use `stroke`; `nil` explicitly means the action is unbound. + case stroke(ShortcutStroke?) + } + + /// A host callback that computes a default from current host state. + public typealias Provider = @Sendable (ShortcutAction) -> Result + + private let provider: Provider + + /// Creates a resolver backed by `provider`. + public init(provider: @escaping Provider) { + self.provider = provider + } + + /// A resolver that always uses the package's built-in defaults. + public static let builtIn = Self(provider: { _ in .useBuiltIn }) + + /// Resolves `action`, falling back to ``Result/useBuiltIn`` when the host + /// provider has no override. + func result(for action: ShortcutAction) -> Result { + provider(action) + } +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift index 6374bbe5bbcc..561bf958fc77 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutActionNumberedDigitTests.swift @@ -79,4 +79,44 @@ struct ShortcutActionNumberedDigitTests { #expect(!ShortcutAction.fileExplorerOpenSelection.allowsChordShortcut) #expect(!ShortcutAction.fileExplorerOpenSelectionFinderAlias.allowsChordShortcut) } + + @Test func hostDefaultResolversDoNotShareState() { + let first = ShortcutDefaultResolver { action in + action == .switchRightSidebarToFiles + ? .stroke(ShortcutStroke(key: "7", control: true)) + : .useBuiltIn + } + let second = ShortcutDefaultResolver { action in + action == .switchRightSidebarToFiles + ? .stroke(ShortcutStroke(key: "2", control: true)) + : .useBuiltIn + } + + #expect( + ShortcutAction.switchRightSidebarToFiles.defaultStroke(using: first) + == ShortcutStroke(key: "7", control: true) + ) + #expect( + ShortcutAction.switchRightSidebarToFiles.defaultStroke(using: second) + == ShortcutStroke(key: "2", control: true) + ) + #expect( + ShortcutAction.switchRightSidebarToFiles.defaultStroke(using: first) + == ShortcutStroke(key: "7", control: true) + ) + #expect( + ShortcutAction.openSettings.defaultStroke(using: first) + == ShortcutAction.openSettings.defaultStroke + ) + } + + @Test func explicitHostUnboundDefaultDoesNotFallBackToBuiltIn() { + let hostDefault = StoredShortcut.unbound + let resolved = ShortcutAction.switchRightSidebarToFiles.effectivePersistedShortcut( + nil, + defaultShortcut: hostDefault + ) + + #expect(resolved == nil) + } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Resolution.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Resolution.swift index d8f4561cbca6..0fb97476045e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Resolution.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Resolution.swift @@ -12,6 +12,10 @@ extension ShortcutListModel { } return action.effectivePersistedShortcutResolvingLegacyConflicts( candidate, + // The policy's optional means "use the package default". Convert + // a resolver's explicit nil stroke to the persisted unbound marker + // so a hidden host action cannot silently regain its built-in key. + defaultShortcut: action.defaultShortcut(using: defaultShortcutResolver) ?? .unbound, normalizing: { shortcut in guard action.shortcutBindingPolicyResult(for: shortcut) == .accepted else { return nil diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift index dc802333c782..6206202a8c3b 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift @@ -36,6 +36,10 @@ final class ShortcutListModel { @ObservationIgnored let errorLog: SettingsErrorLog @ObservationIgnored let onShortcutsChanged: @MainActor () -> Void @ObservationIgnored let canRegisterSystemWideHotkey: @MainActor (StoredShortcut) -> Bool + /// Host-owned, value-typed factory defaults. Each model retains its own + /// resolver, so separate settings windows and previews cannot overwrite + /// one another's defaults. + @ObservationIgnored let defaultShortcutResolver: ShortcutDefaultResolver @ObservationIgnored private let bindingsDriver = SettingReadDriver() @ObservationIgnored private let legacyBindingsDriver = SettingReadDriver<[String: StoredShortcut]>() @ObservationIgnored private let whenDriver = SettingReadDriver<[String: String]>() @@ -52,6 +56,7 @@ final class ShortcutListModel { canRegisterSystemWideHotkey: @escaping @MainActor (StoredShortcut) -> Bool = { ShortcutAction.showHideAllWindows.shortcutBindingPolicyResult(for: $0) == .accepted }, + defaultShortcutResolver: ShortcutDefaultResolver = .builtIn, onShortcutsChanged: @escaping @MainActor () -> Void = {} ) { self.jsonStore = jsonStore @@ -60,6 +65,7 @@ final class ShortcutListModel { self.catalog = catalog self.errorLog = errorLog self.canRegisterSystemWideHotkey = canRegisterSystemWideHotkey + self.defaultShortcutResolver = defaultShortcutResolver self.onShortcutsChanged = onShortcutsChanged } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift index 3b5d272cd536..a872f9d34902 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsRuntime.swift @@ -28,6 +28,8 @@ public struct SettingsRuntime: @unchecked Sendable { public let accountFlow: AccountFlow? /// Host callbacks for actions the package cannot perform itself. public let hostActions: SettingsHostActions + /// Host-scoped factory-default resolver for dynamic shortcut actions. + public let shortcutDefaultResolver: ShortcutDefaultResolver /// Creates the settings runtime bundle injected into the settings UI. /// @@ -39,6 +41,8 @@ public struct SettingsRuntime: @unchecked Sendable { /// - errorLog: Rolling settings error log displayed as alerts. /// - accountFlow: Optional host-owned account flow actions. /// - hostActions: Host callbacks for actions the package cannot perform itself. + /// - shortcutDefaultResolver: Value-typed defaults supplied by the host; + /// defaults to the package table for previews and package-only hosts. /// - searchIndex: Prebuilt search index to share across settings roots. When `nil`, /// the runtime builds one index from `catalog` and keeps it for its own lifetime. @MainActor @@ -50,6 +54,7 @@ public struct SettingsRuntime: @unchecked Sendable { errorLog: SettingsErrorLog, accountFlow: AccountFlow? = nil, hostActions: SettingsHostActions = NoopSettingsHostActions(), + shortcutDefaultResolver: ShortcutDefaultResolver = .builtIn, searchIndex: SettingsSearchIndex? = nil ) { self.catalog = catalog @@ -60,6 +65,7 @@ public struct SettingsRuntime: @unchecked Sendable { self.errorLog = errorLog self.accountFlow = accountFlow self.hostActions = hostActions + self.shortcutDefaultResolver = shortcutDefaultResolver } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift index b8543cb4f563..78811dfb8644 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift @@ -515,7 +515,8 @@ public struct SettingsWindowRoot: View { defaultsStore: defaultsStore, jsonStore: jsonStore, catalog: catalog, errorLog: runtime.errorLog, - hostActions: hostActions + hostActions: hostActions, + defaultShortcutResolver: runtime.shortcutDefaultResolver ) .id(anchorID(for: .globalHotkey)) @@ -523,7 +524,8 @@ public struct SettingsWindowRoot: View { jsonStore: jsonStore, userDefaultsStore: defaultsStore, catalog: catalog, errorLog: runtime.errorLog, - hostActions: hostActions + hostActions: hostActions, + defaultShortcutResolver: runtime.shortcutDefaultResolver ) .id(anchorID(for: .keyboardShortcuts)) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift index c3bd5576fc5e..c8a85d810fd8 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift @@ -22,12 +22,14 @@ public struct GlobalHotkeySection: View { /// - catalog: The settings key catalog shared by both stores. /// - errorLog: Records persistence failures. /// - hostActions: Invalidates host-owned shortcut caches after successful writes. + /// - defaultShortcutResolver: Host-scoped factory defaults for dynamic actions. public init( defaultsStore: UserDefaultsSettingsStore, jsonStore: JSONConfigStore, catalog: SettingCatalog, errorLog: SettingsErrorLog, - hostActions: SettingsHostActions = NoopSettingsHostActions() + hostActions: SettingsHostActions = NoopSettingsHostActions(), + defaultShortcutResolver: ShortcutDefaultResolver = .builtIn ) { _enabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.app.systemWideHotkeyEnabled)) _shortcutModel = State(initialValue: ShortcutListModel( @@ -38,6 +40,7 @@ public struct GlobalHotkeySection: View { canRegisterSystemWideHotkey: { hostActions.canRegisterSystemWideHotkey($0) }, + defaultShortcutResolver: defaultShortcutResolver, onShortcutsChanged: { hostActions.notifyShortcutSettingsDidChange() } )) } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index 033dd79b5c3a..91c6f71b6af5 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -20,12 +20,14 @@ public struct KeyboardShortcutsSection: View { /// - catalog: The settings key catalog shared with the stores. /// - errorLog: The error sink for failed JSON writes. /// - hostActions: Host callbacks for opening the external configuration editor. + /// - defaultShortcutResolver: Host-scoped factory defaults for dynamic actions. public init( jsonStore: JSONConfigStore, userDefaultsStore: UserDefaultsSettingsStore? = nil, catalog: SettingCatalog, errorLog: SettingsErrorLog, - hostActions: SettingsHostActions + hostActions: SettingsHostActions, + defaultShortcutResolver: ShortcutDefaultResolver = .builtIn ) { self.hostActions = hostActions _model = State(initialValue: ShortcutListModel( @@ -36,6 +38,7 @@ public struct KeyboardShortcutsSection: View { canRegisterSystemWideHotkey: { hostActions.canRegisterSystemWideHotkey($0) }, + defaultShortcutResolver: defaultShortcutResolver, onShortcutsChanged: { hostActions.notifyShortcutSettingsDidChange() } )) } diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift index 7075ed95d32a..8c1d68a6dc74 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift @@ -68,6 +68,51 @@ import CmuxSettings #expect(model.numberedDigitRejections.contains(action.rawValue)) } + @Test func hostDefaultResolversStayIsolated() async throws { + let (firstStore, firstCatalog, firstErrorLog) = makeStore() + let (secondStore, secondCatalog, secondErrorLog) = makeStore() + let action = ShortcutAction.switchRightSidebarToFiles + let firstDefault = StoredShortcut(first: ShortcutStroke(key: "2", control: true)) + let secondDefault = StoredShortcut(first: ShortcutStroke(key: "7", control: true)) + + let first = ShortcutListModel( + jsonStore: firstStore, + catalog: firstCatalog, + errorLog: firstErrorLog, + defaultShortcutResolver: ShortcutDefaultResolver { candidate in + candidate == action + ? .stroke(firstDefault.first) + : .useBuiltIn + } + ) + let second = ShortcutListModel( + jsonStore: secondStore, + catalog: secondCatalog, + errorLog: secondErrorLog, + defaultShortcutResolver: ShortcutDefaultResolver { candidate in + candidate == action + ? .stroke(secondDefault.first) + : .useBuiltIn + } + ) + let hidden = ShortcutListModel( + jsonStore: JSONConfigStore( + fileURL: FileManager.default.temporaryDirectory + .appendingPathComponent("shortcut-list-hidden-(UUID().uuidString).json") + ), + catalog: SettingCatalog(), + errorLog: SettingsErrorLog(), + defaultShortcutResolver: ShortcutDefaultResolver { candidate in + candidate == action ? .stroke(nil) : .useBuiltIn + } + ) + + #expect(first.effective(for: action) == firstDefault) + #expect(second.effective(for: action) == secondDefault) + #expect(first.effective(for: action) == firstDefault) + #expect(hidden.effective(for: action) == nil) + } + @Test func assignRejectsBareKeyAtModelBoundary() async throws { // WHY: the model is the persistence boundary; callers must not bypass // recorder UI validation and write bare app-level shortcuts. diff --git a/Sources/KeyboardShortcutSettings+PersistedShortcutPolicy.swift b/Sources/KeyboardShortcutSettings+PersistedShortcutPolicy.swift index 00cd918ac1e5..2e46fe73fbcd 100644 --- a/Sources/KeyboardShortcutSettings+PersistedShortcutPolicy.swift +++ b/Sources/KeyboardShortcutSettings+PersistedShortcutPolicy.swift @@ -49,6 +49,7 @@ extension KeyboardShortcutSettings { return settingsAction.effectivePersistedShortcut( candidate?.cmuxSettingsStoredShortcut, + defaultShortcut: action.defaultShortcut.cmuxSettingsStoredShortcut, normalizing: { shortcut in normalizedExecutableShortcut( StoredShortcut(cmuxSettingsStoredShortcut: shortcut), diff --git a/Sources/KeyboardShortcutSettingsLookup.swift b/Sources/KeyboardShortcutSettingsLookup.swift index 5a5f94c9696b..972eff8838be 100644 --- a/Sources/KeyboardShortcutSettingsLookup.swift +++ b/Sources/KeyboardShortcutSettingsLookup.swift @@ -37,6 +37,7 @@ extension KeyboardShortcutSettings { } return settingsAction.effectivePersistedShortcutResolvingLegacyConflicts( nil, + defaultShortcut: action.defaultShortcut.cmuxSettingsStoredShortcut, explicitlyConfiguredShortcut: { settingsConfiguredAction in guard let configuredAction = Action(rawValue: settingsConfiguredAction.rawValue) else { return nil diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 05ddf7db3a04..05f314db5ecd 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -79,36 +79,6 @@ struct cmuxApp: App { } init() { - // The right-sidebar digit shortcuts default to the mode's position - // among the visible tabs, and the visible tab order is app state the - // CmuxSettings package cannot read. Install the override before any - // shortcut resolution so the package's Settings UI and the runtime - // matcher agree on the same defaults. The provider reads live defaults - // on every call, so tab-preference changes need no re-install. - ShortcutDefaultOverrides.install { action in - let mode: RightSidebarMode - switch action { - case .switchRightSidebarToFiles: mode = .files - case .switchRightSidebarToFind: mode = .find - case .switchRightSidebarToSessions: mode = .sessions - case .switchRightSidebarToFeed: mode = .feed - case .switchRightSidebarToDock: mode = .dock - case .switchRightSidebarToMachines: mode = .machines - default: return .useBuiltIn - } - guard let digit = RightSidebarMode.positionalDigit(for: mode) else { - return .stroke(nil) - } - // Fully qualified: the app target declares a legacy ShortcutStroke - // of its own, which this initializer must not resolve to. - return .stroke(CmuxSettings.ShortcutStroke(key: String(digit), control: true)) - } - // Stored-property initializers above this init body (the - // KeyboardShortcutSettingsObserver.shared @State member) already built - // matcher snapshots against the builtin table; the standard change - // notification rebuilds them against the provider. - NotificationCenter.default.post(name: KeyboardShortcutSettings.didChangeNotification, object: nil) - // Gather settings package dependencies once. The runtime itself // is assigned after the saved language override below, because // it owns localized search-index text for the process lifetime. @@ -255,7 +225,8 @@ struct cmuxApp: App { hostActions: HostSettingsActions( configFileURL: configFileURL, computerUseRuntimeService: computerUseRuntimeService - ) + ), + shortcutDefaultResolver: Self.makeShortcutDefaultResolver() ) StartupBreadcrumbLog.append("app.init.settingsRuntime.created") @@ -361,6 +332,28 @@ struct cmuxApp: App { StartupBreadcrumbLog.append("app.init.delegate.configured") } + /// Builds the host-owned resolver used by Settings UI shortcut models. + /// Dynamic right-sidebar defaults depend on app state and must not be + /// installed into the settings package as process-global mutable state. + private static func makeShortcutDefaultResolver() -> CmuxSettings.ShortcutDefaultResolver { + CmuxSettings.ShortcutDefaultResolver { action in + let mode: RightSidebarMode + switch action { + case .switchRightSidebarToFiles: mode = .files + case .switchRightSidebarToFind: mode = .find + case .switchRightSidebarToSessions: mode = .sessions + case .switchRightSidebarToFeed: mode = .feed + case .switchRightSidebarToDock: mode = .dock + case .switchRightSidebarToMachines: mode = .machines + default: return .useBuiltIn + } + guard let digit = RightSidebarMode.positionalDigit(for: mode) else { + return .stroke(nil) + } + return .stroke(CmuxSettings.ShortcutStroke(key: String(digit), control: true)) + } + } + private static func terminateForMissingLaunchTag() -> Never { let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag (or set CMUX_TAG for test harnesses)" fputs("\(message)\n", stderr) From 7e841b3c21f41824ed353eb547c36737ce63e8d5 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:07:09 -0700 Subject: [PATCH 09/10] test: update sidebar shortcut snapshot count --- cmuxTests/TypingHotPathRegressionTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmuxTests/TypingHotPathRegressionTests.swift b/cmuxTests/TypingHotPathRegressionTests.swift index cac8c74c7757..9c2d821baa0d 100644 --- a/cmuxTests/TypingHotPathRegressionTests.swift +++ b/cmuxTests/TypingHotPathRegressionTests.swift @@ -402,7 +402,7 @@ struct RightSidebarModeShortcutMatcherTests { #expect(matcher.modeShortcut(for: event, allowingAction: { _ in true }) == nil) } - #expect(initialLookupCount == 5) + #expect(initialLookupCount == 6) #expect(shortcutLookupCount == initialLookupCount) #expect(layoutLookupCount == 0) } @@ -418,11 +418,11 @@ struct RightSidebarModeShortcutMatcherTests { layoutCharacterProvider: { _, _ in nil } ) - #expect(shortcutLookupCount == 5) + #expect(shortcutLookupCount == 6) matcher.reload() - #expect(shortcutLookupCount == 10) + #expect(shortcutLookupCount == 12) _ = matcher.modeShortcut(for: makeKeyEvent(characters: "x", modifiers: []), allowingAction: { _ in true }) - #expect(shortcutLookupCount == 10) + #expect(shortcutLookupCount == 12) } private func makeKeyEvent( From f6bcab4a36d3da92594f1a8fb08eba72d89178d6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:30:50 -0700 Subject: [PATCH 10/10] fix sidebar settings refresh and accessibility --- .../Sections/BetaFeaturesSection.swift | 5 ++++- .../Sections/SidebarSection+RightSidebarTabs.swift | 6 ++++++ Sources/App/WorkspaceRuntimeSettings.swift | 1 + .../CommandPaletteSettingsToggle.swift | 10 ++++++++-- Sources/HostSettingsActions.swift | 1 + Sources/KeyboardShortcutSettingsObserver.swift | 2 +- Sources/cmuxApp.swift | 13 +++---------- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift index 2cc3528cd790..fbab6b8c4d98 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift @@ -154,7 +154,10 @@ public struct BetaFeaturesSection: View { ? String(localized: "settings.betaFeatures.cloudMachines.subtitleOn", defaultValue: "Shows Cloud in the right sidebar plus the Cloud Machines settings, palette commands, and new-workspace entries.") : String(localized: "settings.betaFeatures.cloudMachines.subtitleOff", defaultValue: "Hides every Cloud Machines surface unless remote rollout enables it.") ) { - Toggle("", isOn: Binding(get: { cloudMachines.current }, set: { cloudMachines.set($0) })) + Toggle("", isOn: Binding(get: { cloudMachines.current }, set: { + cloudMachines.set($0) + NotificationCenter.default.post(name: Notification.Name("rightSidebarBetaFeatureDidChange"), object: nil) + })) .labelsHidden() .controlSize(.small) .accessibilityIdentifier("SettingsBetaCloudMachinesToggle") diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift index 2825021a7766..2cc28784ddef 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift @@ -88,6 +88,12 @@ extension SidebarSection { .labelsHidden() .controlSize(.small) .disabled(tab.isVisible && visibleCount == 1) + .accessibilityLabel( + String.localizedStringWithFormat( + String(localized: "settings.sidebar.rightTabs.toggleVisible", defaultValue: "Show %@"), + tab.title + ) + ) .accessibilityIdentifier("SettingsRightSidebarTabVisibleToggle.\(tab.id)") } .padding(.horizontal, 14) diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index efc34453d37d..17d0e1bf2850 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -484,6 +484,7 @@ enum RightSidebarBetaFeatureSettings { static let defaultFeedEnabled = false static let defaultDockEnabled = false static let defaultCloudMachinesEnabled = false + static let didChangeNotification = Notification.Name("rightSidebarBetaFeatureDidChange") nonisolated static func isFeedEnabled(defaults: UserDefaults = .standard) -> Bool { guard defaults.object(forKey: feedEnabledKey) != nil else { return defaultFeedEnabled } diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index 8bc78555f69d..4260dbdbde7e 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -780,7 +780,10 @@ enum CommandPaletteSettingsToggleCommands { sectionTitle: beta, keywords: ["betaFeatures.feed", "feed", "right", "sidebar", "beta", "agent", "decisions", "permissions"], defaultValue: RightSidebarBetaFeatureSettings.defaultFeedEnabled, - defaultsKey: RightSidebarBetaFeatureSettings.feedEnabledKey + defaultsKey: RightSidebarBetaFeatureSettings.feedEnabledKey, + didSet: { _, _, notificationCenter in + notificationCenter.post(name: RightSidebarBetaFeatureSettings.didChangeNotification, object: nil) + } ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "rightSidebarDock", @@ -791,7 +794,10 @@ enum CommandPaletteSettingsToggleCommands { sectionTitle: beta, keywords: ["betaFeatures.dock", "dock", "right", "sidebar", "beta", "terminal", "controls"], defaultValue: RightSidebarBetaFeatureSettings.defaultDockEnabled, - defaultsKey: RightSidebarBetaFeatureSettings.dockEnabledKey + defaultsKey: RightSidebarBetaFeatureSettings.dockEnabledKey, + didSet: { _, _, notificationCenter in + notificationCenter.post(name: RightSidebarBetaFeatureSettings.didChangeNotification, object: nil) + } ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "claudeCodeIntegration", diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index c2c5a82eca67..375e56ac6bdf 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -277,6 +277,7 @@ final class HostSettingsActions: SettingsHostActions { ) } let drainTask = Task { @MainActor in + continuation.yield(Self.rightSidebarTabItems()) for await _ in signals { if Task.isCancelled { break } continuation.yield(Self.rightSidebarTabItems()) diff --git a/Sources/KeyboardShortcutSettingsObserver.swift b/Sources/KeyboardShortcutSettingsObserver.swift index a04dcf7c23f3..20d002172698 100644 --- a/Sources/KeyboardShortcutSettingsObserver.swift +++ b/Sources/KeyboardShortcutSettingsObserver.swift @@ -51,7 +51,7 @@ final class KeyboardShortcutSettingsObserver { // matcher must rebuild when a gate changes, even when no shortcut was // edited. featureGateObserver = notificationCenter.addObserver( - forName: UserDefaults.didChangeNotification, + forName: RightSidebarBetaFeatureSettings.didChangeNotification, object: nil, queue: nil ) { [weak self] _ in diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 05f314db5ecd..628d4e71eb51 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -337,16 +337,9 @@ struct cmuxApp: App { /// installed into the settings package as process-global mutable state. private static func makeShortcutDefaultResolver() -> CmuxSettings.ShortcutDefaultResolver { CmuxSettings.ShortcutDefaultResolver { action in - let mode: RightSidebarMode - switch action { - case .switchRightSidebarToFiles: mode = .files - case .switchRightSidebarToFind: mode = .find - case .switchRightSidebarToSessions: mode = .sessions - case .switchRightSidebarToFeed: mode = .feed - case .switchRightSidebarToDock: mode = .dock - case .switchRightSidebarToMachines: mode = .machines - default: return .useBuiltIn - } + guard let mode = RightSidebarMode.allCases.first(where: { + $0.shortcutAction?.rawValue == action.rawValue + }) else { return .useBuiltIn } guard let digit = RightSidebarMode.positionalDigit(for: mode) else { return .stroke(nil) }