diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 3f8b0f191b2f..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 @@ -32,7 +48,24 @@ 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. + /// + /// The package-owned default table. Hosts with dynamic defaults should use + /// ``defaultStroke(using:)`` and pass their resolver explicitly. public var defaultStroke: ShortcutStroke? { + 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 + } + } + + 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/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/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/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/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/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/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/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift new file mode 100644 index 000000000000..2cc28784ddef --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightSidebarTabs.swift @@ -0,0 +1,105 @@ +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) + .accessibilityLabel( + String.localizedStringWithFormat( + String(localized: "settings.sidebar.rightTabs.toggleVisible", defaultValue: "Show %@"), + tab.title + ) + ) + .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/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/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/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/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/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..375e56ac6bdf 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -237,6 +237,79 @@ 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 + continuation.yield(Self.rightSidebarTabItems()) + 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+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/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/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/KeyboardShortcutSettingsObserver.swift b/Sources/KeyboardShortcutSettingsObserver.swift index 154cb50b1403..20d002172698 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: RightSidebarBetaFeatureSettings.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/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..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 @@ -164,7 +166,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 +174,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 +266,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 { @@ -249,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( @@ -274,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 { @@ -283,6 +324,7 @@ struct RightSidebarPanelView: View { } } .rightSidebarChromeBar(leadingPadding: 4, trailingPadding: 6, height: titlebarHeight) + .contextMenu { tabCustomizationMenu } .overlay(alignment: .topLeading) { focusShortcutHintOverlay } @@ -295,6 +337,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) @@ -646,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 new file mode 100644 index 000000000000..7ccd65715511 --- /dev/null +++ b/Sources/RightSidebarTabPreferences.swift @@ -0,0 +1,109 @@ +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 } + let remainingVisible = orderedModes(defaults: defaults).contains { + $0 != mode && $0.isAvailable(defaults: defaults) && !hiddenSet.contains($0) + } + guard remainingVisible else { + return false + } + } else { + guard hiddenSet.remove(mode) != nil else { return true } + } + defaults.set(hiddenSet.map(\.rawValue).sorted(), forKey: hiddenKey) + notifyChanged() + 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) { + 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..628d4e71eb51 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -225,7 +225,8 @@ struct cmuxApp: App { hostActions: HostSettingsActions( configFileURL: configFileURL, computerUseRuntimeService: computerUseRuntimeService - ) + ), + shortcutDefaultResolver: Self.makeShortcutDefaultResolver() ) StartupBreadcrumbLog.append("app.init.settingsRuntime.created") @@ -331,6 +332,21 @@ 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 + 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) + } + 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) 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..a1f6db086b77 --- /dev/null +++ b/cmuxTests/RightSidebarTabCustomizationTests.swift @@ -0,0 +1,217 @@ +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 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) + 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 31499d08017d..b59000544ee8 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, + RightSidebarTabPreferences.orderKey, + RightSidebarTabPreferences.hiddenKey, ] 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: RightSidebarTabPreferences.orderKey) + UserDefaults.standard.removeObject(forKey: RightSidebarTabPreferences.hiddenKey) 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() { 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( 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": "カスタムコマンド",