diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift index 8a2144512798..32ccf6b6e312 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/KeyboardShortcutsCatalogSection.swift @@ -25,6 +25,19 @@ public struct KeyboardShortcutsCatalogSection: SettingCatalogSection { defaultValue: [:] ) + /// Read projection that preserves every managed action ID even when its + /// binding is malformed. Derived from ``bindings`` so the catalog retains + /// one stored declaration for the `shortcuts.bindings` path. + public var bindingSnapshot: JSONKey { + JSONKey( + id: bindings.id, + defaultValue: ShortcutBindingsSnapshot( + bindings: [:], + managedActionIDs: [] + ) + ) + } + /// Per-action focus predicates (`shortcuts.when`), keyed by action id, as /// raw expression strings. The app target owns parsing/evaluation; the /// Settings UI only needs to know which actions are context-scoped so its diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index 7487d42633e0..fef42d494788 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -161,6 +161,7 @@ extension ShortcutAction { case .toggleBrowserDeveloperTools: return ShortcutStroke(key: "i", command: true, option: true) case .showBrowserJavaScriptConsole: return ShortcutStroke(key: "c", command: true, option: true) case .toggleBrowserFocusMode: return ShortcutStroke(key: "\r", command: true, option: true) + case .toggleBrowserDesignMode: return ShortcutStroke(key: "d", command: true, option: true, control: true) case .toggleReactGrab: return ShortcutStroke(key: "g", command: true, shift: true) case .diffViewerScrollDown: return ShortcutStroke(key: "j") case .diffViewerScrollUp: return ShortcutStroke(key: "k") diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift index b75aed7270ed..41ce154511ea 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift @@ -156,6 +156,7 @@ extension ShortcutAction { case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools" case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console" case .toggleBrowserFocusMode: return "Enter Browser Focus Mode" + case .toggleBrowserDesignMode: return String(localized: "shortcut.toggleBrowserDesignMode.label", defaultValue: "Toggle Browser Design Mode") case .toggleReactGrab: return "Toggle React Grab" case .diffViewerScrollDown: return String(localized: "shortcut.diffViewerScrollDown.label", defaultValue: "Viewers: Scroll Down") diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift index ec2c1a27d24d..b3eb7a386d13 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -45,7 +45,8 @@ extension ShortcutAction { .markdownZoomIn, .markdownZoomOut, .markdownZoomReset, .find, .findInDirectory, .findNext, .findPrevious, .hideFind, .useSelectionForFind, .toggleBrowserDeveloperTools, - .showBrowserJavaScriptConsole, .toggleBrowserFocusMode, .toggleReactGrab, + .showBrowserJavaScriptConsole, .toggleBrowserFocusMode, + .toggleBrowserDesignMode, .toggleReactGrab, .diffViewerScrollDown, .diffViewerScrollUp, .diffViewerScrollHalfPageDown, .diffViewerScrollHalfPageUp, .diffViewerScrollDownEmacs, .diffViewerScrollUpEmacs, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Validation.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Validation.swift new file mode 100644 index 000000000000..adc7a1e8c558 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Validation.swift @@ -0,0 +1,52 @@ +extension ShortcutAction { + /// Whether this action rejects `shortcut` because it contains a system-defined media key. + /// + /// Global Search is handled by the foreground AppKit key path, which cannot + /// receive media-key events. Other actions retain their existing validation. + /// + /// - Parameter shortcut: The shortcut being validated. + /// - Returns: `true` when the shortcut cannot execute for this action. + public func rejectsSystemDefinedMediaKey(_ shortcut: StoredShortcut) -> Bool { + rejectsSystemDefinedMediaKey( + firstKey: shortcut.first.key, + secondKey: shortcut.second?.key + ) + } + + /// Whether this action rejects shortcut keys from a legacy binding representation. + /// + /// - Parameters: + /// - firstKey: The persisted key identifier for the first stroke. + /// - secondKey: The persisted key identifier for the optional chord suffix. + /// - Returns: `true` when either key is a media key this action cannot execute. + public func rejectsSystemDefinedMediaKey( + firstKey: String, + secondKey: String? + ) -> Bool { + guard self == .globalSearch else { return false } + return firstKey.lowercased().hasPrefix("media.") + || secondKey?.lowercased().hasPrefix("media.") == true + } + + /// Whether the system reserves this complete shortcut before the action can execute it. + /// + /// Command-Period is AppKit's standard cancel keystroke for modal alerts + /// and open/save panels. The system-wide Show/Hide action must not capture + /// that first instinctive cancel press. + /// + /// - Parameter shortcut: The shortcut being validated. + /// - Returns: `true` when the shortcut is reserved for system interaction. + public func rejectsSystemReservedShortcut( + _ shortcut: StoredShortcut + ) -> Bool { + guard self == .showHideAllWindows, !shortcut.hasChord else { + return false + } + let first = shortcut.first.canonicalized() + return first.key == "." + && first.command + && !first.shift + && !first.option + && !first.control + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 2f13d26aa821..e5b5cfb8ecf0 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -160,6 +160,8 @@ public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCod case toggleBrowserDeveloperTools case showBrowserJavaScriptConsole case toggleBrowserFocusMode + /// Toggles design-mode editing for the focused browser. + case toggleBrowserDesignMode case toggleReactGrab /// Scrolls the focused diff viewer down one step. case diffViewerScrollDown @@ -245,7 +247,8 @@ extension ShortcutAction { /// Whether this action supports a two-stroke shortcut chord. public var allowsChordShortcut: Bool { - self != .fileExplorerOpenSelection + self != .showHideAllWindows + && self != .fileExplorerOpenSelection && self != .fileExplorerOpenSelectionFinderAlias && self != .cycleTextBoxSubmitAction } @@ -272,7 +275,8 @@ extension ShortcutAction { return .and(.not(.atom(.browserFocus)), .not(.atom(.sidebarFocus))) case .browserBack, .browserForward, .browserReload, .browserHardReload, .toggleBrowserDeveloperTools, .showBrowserJavaScriptConsole, .toggleBrowserFocusMode, - .diffViewerOpenFileSearch, .diffViewerNextFile, .diffViewerPreviousFile: + .toggleBrowserDesignMode, .diffViewerOpenFileSearch, .diffViewerNextFile, + .diffViewerPreviousFile: return .atom(.browserFocus) case .diffViewerScrollDown, .diffViewerScrollUp, .diffViewerScrollHalfPageDown, .diffViewerScrollHalfPageUp, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingPolicyResult.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingPolicyResult.swift new file mode 100644 index 000000000000..71a3d2e0acea --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingPolicyResult.swift @@ -0,0 +1,128 @@ +/// The action-owned validity of a persisted shortcut's shape. +/// +/// Persistence adapters use this result before accepting a binding so Settings, +/// `cmux.json`, and legacy `UserDefaults` cannot disagree about whether an +/// action can execute the stored shortcut. +public enum ShortcutBindingPolicyResult: Sendable, Equatable { + /// The action can execute the shortcut shape. + case accepted + + /// The action requires a modifier on this first stroke. + case bareFirstStrokeNotAllowed + + /// The system-wide action requires Command, Option, or Control. + case primaryModifierRequired + + /// The action does not support two-stroke shortcuts. + case chordNotAllowed + + /// The foreground action cannot receive a system-defined media key. + case systemDefinedMediaKeyNotAllowed + + /// The system reserves this shortcut before the action can execute it. + case systemReservedShortcutNotAllowed +} + +extension ShortcutAction { + /// Validates the representation-independent shape of a persisted shortcut. + /// + /// This policy intentionally excludes conflicts with other actions because + /// conflict checks require the caller's complete effective-binding snapshot. + /// + /// - Parameter shortcut: The shortcut loaded or proposed by a persistence adapter. + /// - Returns: The action-owned validity of the shortcut shape. + public func shortcutBindingPolicyResult( + for shortcut: StoredShortcut + ) -> ShortcutBindingPolicyResult { + guard !shortcut.isUnbound else { return .accepted } + + if shortcut.hasChord && !allowsChordShortcut { + return .chordNotAllowed + } + if rejectsSystemDefinedMediaKey(shortcut) { + return .systemDefinedMediaKeyNotAllowed + } + if rejectsSystemReservedShortcut(shortcut) { + return .systemReservedShortcutNotAllowed + } + + let first = shortcut.first + if self == .showHideAllWindows, + !first.command, + !first.option, + !first.control { + return .primaryModifierRequired + } + let supportsLegacyBareSpace = first.key.lowercased() == "space" + && self != .globalSearch + guard allowsBareFirstStroke + || first.hasAnyModifier + || supportsLegacyBareSpace else { + return .bareFirstStrokeNotAllowed + } + + return .accepted + } + + /// Resolves a persisted candidate into the shortcut the action executes. + /// + /// Unsupported Show/Hide candidates fail closed because silently registering + /// its default would create an unexpected system-wide hotkey. Other actions + /// fall back to their valid built-in default. + /// + /// - Parameters: + /// - candidate: The configured shortcut, or `nil` when no override exists. + /// - 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?, + conflictsWithReservedShortcut: (StoredShortcut) -> Bool = { _ in false } + ) -> StoredShortcut? { + effectivePersistedShortcut( + candidate, + normalizing: { shortcut in + shortcutBindingPolicyResult(for: shortcut) == .accepted + ? shortcut.canonicalized() + : nil + }, + conflictsWithReservedShortcut: conflictsWithReservedShortcut + ) + } + + /// Resolves a persisted candidate using the consumer's runtime normalizer. + /// + /// - Parameters: + /// - candidate: The configured shortcut, or `nil` when no override exists. + /// - normalizing: Returns the executable representation of a shortcut, or + /// `nil` when the consumer cannot execute it. + /// - 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?, + normalizing: (StoredShortcut) -> StoredShortcut?, + conflictsWithReservedShortcut: (StoredShortcut) -> Bool + ) -> StoredShortcut? { + if let candidate { + if candidate.isUnbound { + return nil + } + if let normalized = normalizing(candidate), + !conflictsWithReservedShortcut(normalized) { + return normalized + } + if self == .showHideAllWindows { + return nil + } + } + + guard let defaultShortcut, + !defaultShortcut.isUnbound, + let normalizedDefault = normalizing(defaultShortcut), + !conflictsWithReservedShortcut(normalizedDefault) else { + return nil + } + return normalizedDefault + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingsSnapshot.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingsSnapshot.swift new file mode 100644 index 000000000000..111100fd5f97 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutBindingsSnapshot.swift @@ -0,0 +1,64 @@ +import Foundation + +/// A decoded shortcut-binding map plus every action ID managed by that map. +/// +/// Shortcut values are decoded independently so one malformed binding cannot +/// hide valid siblings. ``managedActionIDs`` retains malformed entries as +/// managed, allowing consumers to suppress lower-precedence legacy values. +public struct ShortcutBindingsSnapshot: Sendable, Equatable, SettingCodable { + /// Successfully decoded bindings, keyed by shortcut action ID. + public let bindings: [String: StoredShortcut] + + /// Every action ID present in the persisted map, including invalid entries. + public let managedActionIDs: Set + + /// Creates a shortcut-binding snapshot. + /// + /// - Parameters: + /// - bindings: Successfully decoded shortcut bindings. + /// - managedActionIDs: Every action ID present in the persisted map. + public init( + bindings: [String: StoredShortcut], + managedActionIDs: Set + ) { + self.bindings = bindings + self.managedActionIDs = managedActionIDs + } + + /// Decodes a property-list binding map while retaining every action ID. + public static func decodeFromUserDefaults(_ raw: Any?) -> ShortcutBindingsSnapshot? { + decode(raw, using: StoredShortcut.decodeFromUserDefaults(_:)) + } + + /// Encodes the successfully decoded bindings for property-list storage. + public func encodeForUserDefaults() -> Any { + bindings.mapValues { $0.encodeForUserDefaults() } + } + + /// Decodes a JSON binding map while retaining every action ID. + public static func decodeFromJSON(_ raw: Any?) -> ShortcutBindingsSnapshot? { + decode(raw, using: StoredShortcut.decodeFromJSON(_:)) + } + + /// Encodes the successfully decoded bindings for JSON storage. + public func encodeForJSON() -> Any { + bindings.mapValues { $0.encodeForJSON() } + } + + private static func decode( + _ raw: Any?, + using decodeValue: (Any?) -> StoredShortcut? + ) -> ShortcutBindingsSnapshot? { + guard let dictionary = raw as? [String: Any] else { return nil } + + var bindings: [String: StoredShortcut] = [:] + bindings.reserveCapacity(dictionary.count) + for (actionID, rawValue) in dictionary { + bindings[actionID] = decodeValue(rawValue) + } + return ShortcutBindingsSnapshot( + bindings: bindings, + managedActionIDs: Set(dictionary.keys) + ) + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut+ConfigParsing.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut+ConfigParsing.swift new file mode 100644 index 000000000000..eb4d8380d743 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut+ConfigParsing.swift @@ -0,0 +1,174 @@ +import Foundation + +extension StoredShortcut { + /// Parses the human-editable shortcut forms accepted by `cmux.json`. + static func parseConfig( + _ rawValue: String, + allowBareFirstStroke: Bool = false + ) -> StoredShortcut? { + if isUnboundConfigToken(rawValue) { + return .unbound + } + return parseConfig( + strokes: [rawValue], + allowBareFirstStroke: allowBareFirstStroke + ) + } + + /// Parses a one- or two-stroke human-editable shortcut binding. + static func parseConfig( + strokes: [String], + allowBareFirstStroke: Bool = false + ) -> StoredShortcut? { + guard !strokes.isEmpty, strokes.count <= 2 else { return nil } + if strokes.count == 1, + let rawValue = strokes.first, + isUnboundConfigToken(rawValue) { + return .unbound + } + + let parsedStrokes = strokes.compactMap(ShortcutStroke.parseConfig(_:)) + guard parsedStrokes.count == strokes.count, + let firstStroke = parsedStrokes.first else { + return nil + } + guard allowBareFirstStroke + || firstStroke.hasAnyModifier + || firstStroke.key == "space" else { + return nil + } + + let secondStroke = parsedStrokes.count == 2 ? parsedStrokes[1] : nil + return StoredShortcut(first: firstStroke, second: secondStroke) + } + + private static func isUnboundConfigToken(_ rawValue: String) -> Bool { + if rawValue.isEmpty { return true } + if rawValue == " " { return false } + let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return true } + return normalized == "none" + || normalized == "clear" + || normalized == "unbound" + || normalized == "disabled" + } +} + +private extension ShortcutStroke { + static func parseConfig(_ rawValue: String) -> ShortcutStroke? { + guard !rawValue.isEmpty else { return nil } + + let rawParts = rawValue.split( + separator: "+", + omittingEmptySubsequences: false + ).map(String.init) + let parts = rawParts.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard !parts.isEmpty, + let lastRawPart = rawParts.last, + !lastRawPart.isEmpty else { + return nil + } + + var command = false + var shift = false + var option = false + var control = false + + for modifier in parts.dropLast() { + switch modifier.lowercased() { + case "cmd", "command", "⌘": + command = true + case "shift", "⇧": + shift = true + case "opt", "option", "alt", "⌥": + option = true + case "ctrl", "control", "ctl", "⌃": + control = true + default: + return nil + } + } + + guard let key = parseConfigKeyToken(lastRawPart) else { return nil } + return ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control + ) + } + + static func parseConfigKeyToken(_ rawValue: String) -> String? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return rawValue == " " ? "space" : nil + } + + let lowered = trimmed.lowercased() + switch lowered { + case "left", "arrowleft", "leftarrow", "←": + return "←" + case "right", "arrowright", "rightarrow", "→": + return "→" + case "up", "arrowup", "uparrow", "↑": + return "↑" + case "down", "arrowdown", "downarrow", "↓": + return "↓" + case "tab": + return "\t" + case "return", "enter", "↩": + return "\r" + case "space", "spacebar", "": + return "space" + case "comma": + return "," + case "period", "dot": + return "." + case "slash": + return "/" + case "backslash": + return "\\" + case "semicolon": + return ";" + case "quote", "apostrophe": + return "'" + case "backtick", "grave": + return "`" + case "minus", "hyphen": + return "-" + case "plus", "equals": + return "=" + case "leftbracket", "openbracket": + return "[" + case "rightbracket", "closebracket": + return "]" + case "volumeup", "mediavolumeup", "media.volumeup": + return "media.volumeUp" + case "volumedown", "mediavolumedown", "media.volumedown": + return "media.volumeDown" + case "brightnessup", "mediabrightnessup", "media.brightnessup": + return "media.brightnessUp" + case "brightnessdown", "mediabrightnessdown", "media.brightnessdown": + return "media.brightnessDown" + case "mute", "mediamute", "media.mute": + return "media.mute" + case "playpause", "mediaplaypause", "media.playpause": + return "media.playPause" + case "nexttrack", "medianext", "media.next", "media.nexttrack": + return "media.next" + case "previoustrack", "mediaprevious", "media.previous", "media.previoustrack": + return "media.previous" + default: + if lowered.hasPrefix("f"), + let number = Int(lowered.dropFirst()), + (1...20).contains(number) { + return "f\(number)" + } + guard lowered.count == 1 else { return nil } + return lowered + } + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift index 48d2b6fc49ec..a6c45d4da7e5 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/StoredShortcut.swift @@ -47,7 +47,16 @@ public struct StoredShortcut: Sendable, Equatable, Hashable, Codable, SettingCod } public static func decodeFromJSON(_ raw: Any?) -> StoredShortcut? { - guard let raw, !(raw is NSNull) else { return nil } + guard let raw else { return nil } + if raw is NSNull { return .unbound } + if let string = raw as? String { + return parseConfig(string, allowBareFirstStroke: true) + } + if let strings = raw as? [String] { + return strings.isEmpty + ? .unbound + : parseConfig(strokes: strings, allowBareFirstStroke: true) + } guard let data = try? JSONSerialization.data(withJSONObject: raw, options: .fragmentsAllowed) else { return nil } diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutBindingPolicyResultTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutBindingPolicyResultTests.swift new file mode 100644 index 000000000000..807ea74c43f1 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutBindingPolicyResultTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import CmuxSettings + +@Suite struct ShortcutBindingPolicyResultTests { + @Test func showHideRequiresCommandOptionOrControl() { + let shiftOnly = StoredShortcut( + first: ShortcutStroke( + key: "j", + command: false, + shift: true, + option: false, + control: false + ) + ) + + #expect( + ShortcutAction.showHideAllWindows.shortcutBindingPolicyResult( + for: shiftOnly + ) != .accepted + ) + #expect( + ShortcutAction.showHideAllWindows.effectivePersistedShortcut( + shiftOnly + ) == nil + ) + } +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutBindingsSnapshotTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutBindingsSnapshotTests.swift new file mode 100644 index 000000000000..50e57bbf1642 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/ShortcutBindingsSnapshotTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing + +@testable import CmuxSettings + +@Suite struct ShortcutBindingsSnapshotTests { + @Test func decodesBindingsIndependentlyAndRetainsManagedActionIDs() throws { + let raw: [String: Any] = [ + "globalSearch": "cmd+alt+f", + "newSurface": ["ctrl+b", "c"], + "showHideAllWindows": 42, + "openSettings": NSNull(), + ] + + let snapshot = try #require( + ShortcutBindingsSnapshot.decodeFromJSON(raw) + ) + + #expect(snapshot.managedActionIDs == Set(raw.keys)) + #expect(snapshot.bindings["globalSearch"] == StoredShortcut( + first: ShortcutStroke(key: "f", command: true, option: true) + )) + #expect(snapshot.bindings["newSurface"] == StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "c") + )) + #expect(snapshot.bindings["showHideAllWindows"] == nil) + #expect(snapshot.bindings["openSettings"] == .unbound) + } + + @Test func decodesBareBindingBeforeActionSpecificPolicyRuns() throws { + let shortcut = try #require(StoredShortcut.decodeFromJSON("j")) + + #expect(shortcut == StoredShortcut(first: ShortcutStroke(key: "j"))) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutBindingConflict.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutBindingConflict.swift new file mode 100644 index 000000000000..711cb074da53 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutBindingConflict.swift @@ -0,0 +1,50 @@ +import CmuxSettings + +/// A complete runtime-overlap query for two stored shortcut bindings. +struct ShortcutBindingConflict { + let proposed: StoredShortcut + let proposedUsesNumberedDigitMatching: Bool + let configured: StoredShortcut + let configuredUsesNumberedDigitMatching: Bool + + /// Whether the bindings can consume the same keystroke sequence. + var exists: Bool { + guard !proposed.isUnbound, !configured.isUnbound else { return false } + + switch (proposed.second, configured.second) { + case (nil, nil): + return numberedAwareStrokesConflict( + proposed.first, + numbered: proposedUsesNumberedDigitMatching, + configured.first, + numbered: configuredUsesNumberedDigitMatching + ) + case let (proposedSecond?, configuredSecond?): + return numberedAwareStrokesConflict( + proposed.first, + numbered: false, + configured.first, + numbered: false + ) && numberedAwareStrokesConflict( + proposedSecond, + numbered: proposedUsesNumberedDigitMatching, + configuredSecond, + numbered: configuredUsesNumberedDigitMatching + ) + case (_?, nil): + return numberedAwareStrokesConflict( + proposed.first, + numbered: false, + configured.first, + numbered: configuredUsesNumberedDigitMatching + ) + case (nil, _?): + return numberedAwareStrokesConflict( + proposed.first, + numbered: proposedUsesNumberedDigitMatching, + configured.first, + numbered: false + ) + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Persistence.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Persistence.swift new file mode 100644 index 000000000000..f6e2d05a2770 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Persistence.swift @@ -0,0 +1,66 @@ +import CmuxSettings + +extension ShortcutListModel { + /// Persists one binding leaf, or resets the complete map for Reset Defaults. + func write( + _ updated: [String: StoredShortcut], + clearingLegacyFor action: ShortcutAction? = nil, + resetAllLegacy: Bool = false + ) async { + pendingWriteGeneration += 1 + let generation = pendingWriteGeneration + pendingBindings = updated + bindings = updated + if let action { + managedBindingActionIDs.insert(action.rawValue) + } else { + managedBindingActionIDs.removeAll() + } + + do { + if let action { + let key = JSONKey( + id: "\(catalog.shortcuts.bindings.id).\(action.rawValue)", + defaultValue: .unbound + ) + try await jsonStore.set( + updated[action.rawValue] ?? .unbound, + for: key + ) + } else { + try await jsonStore.reset(catalog.shortcuts.bindings) + } + if resetAllLegacy { + await userDefaultsStore?.resetAllLegacyShortcutBindings() + legacyBindings.removeAll() + } else if let action, legacyBindings[action.rawValue] != nil { + await userDefaultsStore?.resetLegacyShortcutBinding(for: action) + legacyBindings.removeValue(forKey: action.rawValue) + } + onShortcutsChanged() + if pendingWriteGeneration == generation { + pendingBindings = nil + } + } catch { + if pendingWriteGeneration == generation { + let committed = await jsonStore.value( + for: catalog.shortcuts.bindingSnapshot + ) + if pendingWriteGeneration == generation { + let changedActionIds = Set(bindings.keys) + .union(committed.bindings.keys) + .filter { bindings[$0] != committed.bindings[$0] } + bindings = committed.bindings + managedBindingActionIDs = committed.managedActionIDs + pendingBindings = nil + pruneRestoreShortcuts() + pruneConflictRejections() + pruneNumberedDigitRejections( + changedActionIds: Set(changedActionIds) + ) + } + } + errorLog.record(error, keyID: catalog.shortcuts.bindings.id) + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Resolution.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Resolution.swift new file mode 100644 index 000000000000..c08110251d2e --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Resolution.swift @@ -0,0 +1,49 @@ +import CmuxSettings + +extension ShortcutListModel { + /// The effective shortcut for `action`, using the runtime's JSON, legacy + /// UserDefaults, then built-in precedence. + func effective(for action: ShortcutAction) -> StoredShortcut? { + let actionID = action.rawValue + let candidate: StoredShortcut? + if !managedBindingActionIDs.contains(actionID) { + candidate = latestBindings[actionID] ?? legacyBindings[actionID] + } else { + candidate = bindings[actionID] + if candidate == nil, action == .showHideAllWindows { return nil } + } + return action.effectivePersistedShortcut( + candidate, + normalizing: { shortcut in + guard action.shortcutBindingPolicyResult(for: shortcut) == .accepted else { + return nil + } + if action == .showHideAllWindows, + !canRegisterSystemWideHotkey(shortcut) { + return nil + } + return shortcut.canonicalized() + }, + conflictsWithReservedShortcut: { shortcut in + guard action == .globalSearch, + let systemWideShortcut = effective(for: .showHideAllWindows) else { + return false + } + return ShortcutBindingConflict( + proposed: shortcut, + proposedUsesNumberedDigitMatching: action.usesNumberedDigitMatching, + configured: systemWideShortcut, + configuredUsesNumberedDigitMatching: false + ).exists + } + ) + } + + /// Whether `action` is currently unbound but has a cached stroke available to + /// restore (drives the X → restore button swap). + func canRestore(for action: ShortcutAction) -> Bool { + let effectiveShortcut = effective(for: action) + let isUnbound = effectiveShortcut?.isUnbound ?? true + return isUnbound && restoreShortcuts[action.rawValue] != nil + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Validation.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Validation.swift new file mode 100644 index 000000000000..52cc8676fedf --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel+Validation.swift @@ -0,0 +1,58 @@ +import CmuxSettings +import Foundation + +extension ShortcutListModel { + /// Whether the recorder should retain its rejected stroke for `action`. + func hasPendingRejection(for action: ShortcutAction) -> Bool { + let actionID = action.rawValue + return bareKeyRejections.contains(actionID) + || primaryModifierRejections.contains(actionID) + || systemReservedRejections.contains(actionID) + || numberedDigitRejections.contains(actionID) + || conflictRejections[actionID] != nil + } + + /// The red validation-banner text for `action`, or `nil` when no rejection is pending. + func validationMessage(for action: ShortcutAction) -> String? { + if primaryModifierRejections.contains(action.rawValue) { + return String( + localized: "shortcut.recorder.error.systemWideHotkeyRequiresModifier", + defaultValue: "System-wide hotkeys must include Command, Option, or Control." + ) + } + if systemReservedRejections.contains(action.rawValue) { + return String( + localized: "shortcut.recorder.error.reservedBySystem", + defaultValue: "This keystroke is reserved by macOS." + ) + } + if numberedDigitRejections.contains(action.rawValue) { + return String( + localized: "shortcut.recorder.error.numberedShortcutRequiresDigit", + defaultValue: "Use a digit from 1 through 9." + ) + } + if bareKeyRejections.contains(action.rawValue) { + return String( + localized: "shortcut.recorder.error.bareKeyNotAllowed", + defaultValue: "Shortcuts must include ⌘ ⌥ ⌃ or ⇧" + ) + } + if let conflict = conflictRejections[action.rawValue] { + let conflictEffective = effective(for: conflict) + let conflictShortcutString = conflictEffective.map { + shortcutDisplayString($0, numbered: conflict.usesNumberedDigitMatching) + } ?? "" + let messageFormat = String( + localized: "shortcut.recorder.error.conflictsWithAction", + defaultValue: "This shortcut conflicts with %@ (%@)." + ) + return String.localizedStringWithFormat( + messageFormat, + conflict.displayName, + conflictShortcutString + ) + } + return nil + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift index af4fb9b559be..dc802333c782 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift @@ -10,29 +10,33 @@ final class ShortcutListModel { // MARK: - Observed state - private(set) var bindings: [String: StoredShortcut] = [:] - private(set) var legacyBindings: [String: StoredShortcut] + var bindings: [String: StoredShortcut] = [:] + var managedBindingActionIDs: Set = [] + var legacyBindings: [String: StoredShortcut] private(set) var whenOverrideClauses: [String: ShortcutWhenClause] = [:] private(set) var whenOverrideRawStrings: [String: String] = [:] private(set) var chordModeActions: Set = [] private(set) var restoreShortcuts: [String: StoredShortcut] = [:] private(set) var bareKeyRejections: Set = [] + private(set) var primaryModifierRejections: Set = [] + private(set) var systemReservedRejections: Set = [] /// Per-action set marking a numbered action rejected for a non-`1…9` key. private(set) var numberedDigitRejections: Set = [] /// Per-action conflict target for the red validation banner. private(set) var conflictRejections: [String: ShortcutAction] = [:] @ObservationIgnored private var rejectedConflictShortcuts: [String: StoredShortcut] = [:] - private var pendingBindings: [String: StoredShortcut]? - @ObservationIgnored private var pendingWriteGeneration = 0 + var pendingBindings: [String: StoredShortcut]? + @ObservationIgnored var pendingWriteGeneration = 0 // MARK: - Observation-ignored internals - @ObservationIgnored private let jsonStore: JSONConfigStore - @ObservationIgnored private let userDefaultsStore: UserDefaultsSettingsStore? - @ObservationIgnored private let catalog: SettingCatalog - @ObservationIgnored private let errorLog: SettingsErrorLog - @ObservationIgnored private let onShortcutsChanged: @MainActor () -> Void - @ObservationIgnored private let bindingsDriver = SettingReadDriver<[String: StoredShortcut]>() + @ObservationIgnored let jsonStore: JSONConfigStore + @ObservationIgnored let userDefaultsStore: UserDefaultsSettingsStore? + @ObservationIgnored let catalog: SettingCatalog + @ObservationIgnored let errorLog: SettingsErrorLog + @ObservationIgnored let onShortcutsChanged: @MainActor () -> Void + @ObservationIgnored let canRegisterSystemWideHotkey: @MainActor (StoredShortcut) -> Bool + @ObservationIgnored private let bindingsDriver = SettingReadDriver() @ObservationIgnored private let legacyBindingsDriver = SettingReadDriver<[String: StoredShortcut]>() @ObservationIgnored private let whenDriver = SettingReadDriver<[String: String]>() @@ -45,6 +49,9 @@ final class ShortcutListModel { userDefaultsStore: UserDefaultsSettingsStore? = nil, catalog: SettingCatalog, errorLog: SettingsErrorLog, + canRegisterSystemWideHotkey: @escaping @MainActor (StoredShortcut) -> Bool = { + ShortcutAction.showHideAllWindows.shortcutBindingPolicyResult(for: $0) == .accepted + }, onShortcutsChanged: @escaping @MainActor () -> Void = {} ) { self.jsonStore = jsonStore @@ -52,6 +59,7 @@ final class ShortcutListModel { self.legacyBindings = userDefaultsStore?.initialLegacyShortcutBindings() ?? [:] self.catalog = catalog self.errorLog = errorLog + self.canRegisterSystemWideHotkey = canRegisterSystemWideHotkey self.onShortcutsChanged = onShortcutsChanged } @@ -60,7 +68,7 @@ final class ShortcutListModel { /// Starts observing the store's shortcut streams. Idempotent: ``SettingReadDriver`` /// ignores subsequent calls after the first activation. func startObserving() { - let bindingsKey = catalog.shortcuts.bindings + let bindingsKey = catalog.shortcuts.bindingSnapshot let whenKey = catalog.shortcuts.when bindingsDriver.activate( { [jsonStore, bindingsKey] in jsonStore.values(for: bindingsKey) }, @@ -83,12 +91,14 @@ final class ShortcutListModel { ) } - private var latestBindings: [String: StoredShortcut] { pendingBindings ?? bindings } + var latestBindings: [String: StoredShortcut] { pendingBindings ?? bindings } - private func ingestBindings(_ dictionary: [String: StoredShortcut]) { + private func ingestBindings(_ snapshot: ShortcutBindingsSnapshot) { + let dictionary = snapshot.bindings let changedActionIds = Set(bindings.keys).union(dictionary.keys) .filter { bindings[$0] != dictionary[$0] } bindings = dictionary + managedBindingActionIDs = snapshot.managedActionIDs pruneRestoreShortcuts() pruneConflictRejections(changedActionIds: Set(changedActionIds)) pruneNumberedDigitRejections(changedActionIds: Set(changedActionIds)) @@ -103,53 +113,7 @@ final class ShortcutListModel { pruneNumberedDigitRejections(changedActionIds: Set(changedActionIds)) } - // MARK: - Display helpers (lifted from actionRow inline computations) - - /// The effective shortcut for `action`, using the runtime's JSON, legacy - /// UserDefaults, then built-in precedence. - func effective(for action: ShortcutAction) -> StoredShortcut? { - latestBindings[action.rawValue] ?? legacyBindings[action.rawValue] ?? action.defaultShortcut - } - - /// Whether `action` is currently unbound but has a cached stroke available to - /// restore (drives the X → restore button swap). - func canRestore(for action: ShortcutAction) -> Bool { - let eff = effective(for: action) - let isUnbound = eff?.isUnbound ?? true - return isUnbound && restoreShortcuts[action.rawValue] != nil - } - - /// The red validation-banner text for `action` (bare-key, numbered-digit, or - /// conflict rejection), or `nil` when the row has no pending rejection. - func validationMessage(for action: ShortcutAction) -> String? { - let numberedDigitRejected = numberedDigitRejections.contains(action.rawValue) - let bareKeyRejected = bareKeyRejections.contains(action.rawValue) - let conflict = conflictRejections[action.rawValue] - if numberedDigitRejected { - return String( - localized: "shortcut.recorder.error.numberedShortcutRequiresDigit", - defaultValue: "Use a digit from 1 through 9." - ) - } - if bareKeyRejected { - return String( - localized: "shortcut.recorder.error.bareKeyNotAllowed", - defaultValue: "Shortcuts must include ⌘ ⌥ ⌃ or ⇧" - ) - } - if let conflict { - let conflictEffective = effective(for: conflict) - let conflictShortcutString = conflictEffective.map { - format($0, numbered: conflict.usesNumberedDigitMatching) - } ?? "" - let messageFormat = String( - localized: "shortcut.recorder.error.conflictsWithAction", - defaultValue: "This shortcut conflicts with %@ (%@)." - ) - return String.localizedStringWithFormat(messageFormat, conflict.displayName, conflictShortcutString) - } - return nil - } + // MARK: - Conflict helpers (moved verbatim from section) /// The effective focus `when` clause for `action`: its `shortcuts.when` /// override, or the built-in ``ShortcutAction/defaultFocusWhenClause``. @@ -238,12 +202,12 @@ final class ShortcutListModel { ) else { continue } let effective = effective(for: other) guard let effective, !effective.isUnbound else { continue } - if numberedAwareStrokesConflict( - stroke.first, - numbered: action.usesNumberedDigitMatching, - effective.first, - numbered: other.usesNumberedDigitMatching - ) { + if ShortcutBindingConflict( + proposed: stroke, + proposedUsesNumberedDigitMatching: action.usesNumberedDigitMatching, + configured: effective, + configuredUsesNumberedDigitMatching: other.usesNumberedDigitMatching + ).exists { return other } } @@ -255,16 +219,26 @@ final class ShortcutListModel { /// Dismisses all rejection banners for the action (the Undo button handler). func clearRejections(for action: ShortcutAction) { bareKeyRejections.remove(action.rawValue) + primaryModifierRejections.remove(action.rawValue) + systemReservedRejections.remove(action.rawValue) numberedDigitRejections.remove(action.rawValue) conflictRejections.removeValue(forKey: action.rawValue) rejectedConflictShortcuts.removeValue(forKey: action.rawValue) } func markBareKeyRejected(_ action: ShortcutAction) { + clearRejections(for: action) bareKeyRejections.insert(action.rawValue) - numberedDigitRejections.remove(action.rawValue) - conflictRejections.removeValue(forKey: action.rawValue) - rejectedConflictShortcuts.removeValue(forKey: action.rawValue) + } + + private func markPrimaryModifierRejected(_ action: ShortcutAction) { + clearRejections(for: action) + primaryModifierRejections.insert(action.rawValue) + } + + private func markSystemReservedRejected(_ action: ShortcutAction) { + clearRejections(for: action) + systemReservedRejections.insert(action.rawValue) } /// The X/restore button handler: clears rejections then either restores a @@ -273,10 +247,7 @@ final class ShortcutListModel { func clearOrRestore(for action: ShortcutAction) async { let eff = effective(for: action) let canRestoreAction = canRestore(for: action) - bareKeyRejections.remove(action.rawValue) - numberedDigitRejections.remove(action.rawValue) - conflictRejections.removeValue(forKey: action.rawValue) - rejectedConflictShortcuts.removeValue(forKey: action.rawValue) + clearRejections(for: action) if canRestoreAction, let restore = restoreShortcuts[action.rawValue] { await restoreBinding(restore, for: action) } else if let eff, !eff.isUnbound { @@ -289,18 +260,26 @@ final class ShortcutListModel { /// a non-digit stroke on a numbered action or a stroke that conflicts with /// another binding; a valid stroke is normalized, persisted, and clears the /// action's rejection/restore state. - func assign(stroke: ShortcutStroke, to action: ShortcutAction) async { + func assign( + stroke: ShortcutStroke, + to action: ShortcutAction, + preservingSupportedLegacyBareSpace: Bool = false + ) async { var stroke = stroke.canonicalized() - guard action.allowsBareFirstStroke || stroke.hasAnyModifier else { + let restoresSupportedLegacyBareSpace = preservingSupportedLegacyBareSpace + && action.shortcutBindingPolicyResult( + for: StoredShortcut(first: stroke) + ) == .accepted + guard action.allowsBareFirstStroke + || stroke.hasAnyModifier + || restoresSupportedLegacyBareSpace else { markBareKeyRejected(action) return } if action.usesNumberedDigitMatching { guard isNumberedDigitKey(stroke.key) else { + clearRejections(for: action) numberedDigitRejections.insert(action.rawValue) - bareKeyRejections.remove(action.rawValue) - conflictRejections.removeValue(forKey: action.rawValue) - rejectedConflictShortcuts.removeValue(forKey: action.rawValue) return } stroke = ShortcutStroke( @@ -313,24 +292,40 @@ final class ShortcutListModel { ) } let proposed = StoredShortcut(first: stroke) + switch action.shortcutBindingPolicyResult(for: proposed) { + case .accepted: + break + case .primaryModifierRequired: + markPrimaryModifierRejected(action) + return + case .bareFirstStrokeNotAllowed: + markBareKeyRejected(action) + return + case .chordNotAllowed, + .systemDefinedMediaKeyNotAllowed, + .systemReservedShortcutNotAllowed: + markSystemReservedRejected(action) + return + } + if action == .showHideAllWindows, + !canRegisterSystemWideHotkey(proposed) { + markSystemReservedRejected(action) + return + } if let conflict = detectConflict(for: action, stroke: proposed) { // Mirror legacy `KeyboardShortcutSettings.Action.normalizedRecordedShortcutResult`: // never write a conflicting binding. Surface the rejection // through `conflictRejections` so the banner + Undo button // can drive the user back to a usable state. + clearRejections(for: action) conflictRejections[action.rawValue] = conflict rejectedConflictShortcuts[action.rawValue] = proposed - bareKeyRejections.remove(action.rawValue) - numberedDigitRejections.remove(action.rawValue) return } var updated = latestBindings updated[action.rawValue] = proposed restoreShortcuts.removeValue(forKey: action.rawValue) - bareKeyRejections.remove(action.rawValue) - numberedDigitRejections.remove(action.rawValue) - conflictRejections.removeValue(forKey: action.rawValue) - rejectedConflictShortcuts.removeValue(forKey: action.rawValue) + clearRejections(for: action) await write(updated, clearingLegacyFor: action) } @@ -349,29 +344,28 @@ final class ShortcutListModel { return } guard let proposed = normalizedNumberedShortcutIfNeeded(chord, for: action) else { + clearRejections(for: action) numberedDigitRejections.insert(action.rawValue) chordModeActions.remove(action.rawValue) - bareKeyRejections.remove(action.rawValue) - conflictRejections.removeValue(forKey: action.rawValue) - rejectedConflictShortcuts.removeValue(forKey: action.rawValue) + return + } + if action.rejectsSystemDefinedMediaKey(proposed) { + markSystemReservedRejected(action) + chordModeActions.remove(action.rawValue) return } if let conflict = detectConflict(for: action, stroke: proposed) { + clearRejections(for: action) conflictRejections[action.rawValue] = conflict rejectedConflictShortcuts[action.rawValue] = proposed chordModeActions.remove(action.rawValue) - bareKeyRejections.remove(action.rawValue) - numberedDigitRejections.remove(action.rawValue) return } var updated = latestBindings updated[action.rawValue] = proposed chordModeActions.remove(action.rawValue) restoreShortcuts.removeValue(forKey: action.rawValue) - bareKeyRejections.remove(action.rawValue) - numberedDigitRejections.remove(action.rawValue) - conflictRejections.removeValue(forKey: action.rawValue) - rejectedConflictShortcuts.removeValue(forKey: action.rawValue) + clearRejections(for: action) await write(updated, clearingLegacyFor: action) } @@ -382,16 +376,17 @@ final class ShortcutListModel { await write(updated, clearingLegacyFor: action) } - /// Persists `shortcut` for `action` and clears its rejection/restore state. + /// Restores `shortcut` through the same validation path as a new recording. func restoreBinding(_ shortcut: StoredShortcut, for action: ShortcutAction) async { - var updated = latestBindings - updated[action.rawValue] = shortcut - restoreShortcuts.removeValue(forKey: action.rawValue) - bareKeyRejections.remove(action.rawValue) - numberedDigitRejections.remove(action.rawValue) - conflictRejections.removeValue(forKey: action.rawValue) - rejectedConflictShortcuts.removeValue(forKey: action.rawValue) - await write(updated, clearingLegacyFor: action) + if shortcut.hasChord { + await assignChord(shortcut, to: action) + } else { + await assign( + stroke: shortcut.first, + to: action, + preservingSupportedLegacyBareSpace: true + ) + } } /// Clears every override and all in-memory rejection/restore state — the @@ -399,58 +394,19 @@ final class ShortcutListModel { func resetAll() async { restoreShortcuts.removeAll() bareKeyRejections.removeAll() + primaryModifierRejections.removeAll() + systemReservedRejections.removeAll() numberedDigitRejections.removeAll() conflictRejections.removeAll() rejectedConflictShortcuts.removeAll() await write([:], resetAllLegacy: true) } - /// Persists `updated` to the bindings store, recording any failure to the - /// error log. - private func write( - _ updated: [String: StoredShortcut], - clearingLegacyFor action: ShortcutAction? = nil, - resetAllLegacy: Bool = false - ) async { - pendingWriteGeneration += 1 - let generation = pendingWriteGeneration - pendingBindings = updated - bindings = updated - do { - try await jsonStore.set(updated, for: catalog.shortcuts.bindings) - if resetAllLegacy { - await userDefaultsStore?.resetAllLegacyShortcutBindings() - legacyBindings.removeAll() - } else if let action, legacyBindings[action.rawValue] != nil { - await userDefaultsStore?.resetLegacyShortcutBinding(for: action) - legacyBindings.removeValue(forKey: action.rawValue) - } - onShortcutsChanged() - if pendingWriteGeneration == generation { - pendingBindings = nil - } - } catch { - if pendingWriteGeneration == generation { - let committed = await jsonStore.value(for: catalog.shortcuts.bindings) - if pendingWriteGeneration == generation { - let changedActionIds = Set(bindings.keys).union(committed.keys) - .filter { bindings[$0] != committed[$0] } - bindings = committed - pendingBindings = nil - pruneRestoreShortcuts() - pruneConflictRejections() - pruneNumberedDigitRejections(changedActionIds: Set(changedActionIds)) - } - } - errorLog.record(error, keyID: catalog.shortcuts.bindings.id) - } - } - // MARK: - Prune helpers (moved verbatim from section) /// Drops the "Use a digit from 1 through 9" banner for an action only when /// *that action's* binding actually changed in the latest stream update. - private func pruneNumberedDigitRejections(changedActionIds: Set) { + func pruneNumberedDigitRejections(changedActionIds: Set) { guard !numberedDigitRejections.isEmpty else { return } for key in Array(numberedDigitRejections) where changedActionIds.contains(key) { numberedDigitRejections.remove(key) @@ -459,7 +415,7 @@ final class ShortcutListModel { /// Drops conflict banners for actions whose binding now resolves cleanly /// (e.g. after an external cmux.json edit removes the colliding binding). - private func pruneConflictRejections(changedActionIds: Set = []) { + func pruneConflictRejections(changedActionIds: Set = []) { guard !conflictRejections.isEmpty else { return } for key in Array(conflictRejections.keys) { guard let action = ShortcutAction(rawValue: key) else { @@ -486,12 +442,12 @@ final class ShortcutListModel { } /// Drops cached restore strokes for actions that are no longer unbound. - private func pruneRestoreShortcuts() { + func pruneRestoreShortcuts() { guard !restoreShortcuts.isEmpty else { return } // Iterate a key snapshot because the loop mutates `restoreShortcuts`. for key in Array(restoreShortcuts.keys) { if let action = ShortcutAction(rawValue: key), - effective(for: action)?.isUnbound == true { continue } + effective(for: action)?.isUnbound ?? true { continue } restoreShortcuts.removeValue(forKey: key) } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index 226fd332dd99..9d87eb9d35bf 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -179,6 +179,15 @@ public protocol SettingsHostActions: AnyObject { /// Invalidates host-owned shortcut caches after Settings persists a shortcut change. func notifyShortcutSettingsDidChange() + /// Whether the host can register `shortcut` as its system-wide hotkey. + /// + /// The macOS host applies Carbon conversion and app-reservation checks that + /// the settings package cannot perform by itself. + /// + /// - Parameter shortcut: The complete Show/Hide shortcut proposed by Settings. + /// - Returns: `true` when the host's runtime registrar can use the shortcut. + func canRegisterSystemWideHotkey(_ shortcut: StoredShortcut) -> Bool + /// Applies the host-side OS `AppleLanguages` override for a changed app /// language selection. func applyLanguageOverride(_ language: AppLanguage) @@ -197,6 +206,13 @@ public extension SettingsHostActions { /// Default no-op for package previews and tests without host layout editing. func customizeWorkspaceLayouts() {} + /// Default package-only validation for previews and non-macOS hosts. + func canRegisterSystemWideHotkey(_ shortcut: StoredShortcut) -> Bool { + ShortcutAction.showHideAllWindows.shortcutBindingPolicyResult( + for: shortcut + ) == .accepted + } + /// Default no-op for package previews and tests without app-language ownership. func applyLanguageOverride(_ language: AppLanguage) {} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift index 298016067487..8169b0625d2c 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Rows/ShortcutListStableLazyView.swift @@ -23,8 +23,7 @@ struct ShortcutListStableLazyView: View { subtitle: model.scopeCaption(for: action), placeholder: model.formatPlaceholder(effective: effective, numbered: action.usesNumberedDigitMatching), chordsEnabled: model.chordModeActions.contains(action.rawValue), - hasPendingRejection: model.bareKeyRejections.contains(action.rawValue) - || model.numberedDigitRejections.contains(action.rawValue), + hasPendingRejection: model.hasPendingRejection(for: action), firstStrokeRequiresModifier: !action.allowsBareFirstStroke, isUnbound: effective?.isUnbound ?? true, canRestore: model.canRestore(for: action), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift index 5376ab7e7f70..c3bd5576fc5e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/GlobalHotkeySection.swift @@ -35,6 +35,9 @@ public struct GlobalHotkeySection: View { userDefaultsStore: defaultsStore, catalog: catalog, errorLog: errorLog, + canRegisterSystemWideHotkey: { + hostActions.canRegisterSystemWideHotkey($0) + }, onShortcutsChanged: { hostActions.notifyShortcutSettingsDidChange() } )) } @@ -88,7 +91,7 @@ public struct GlobalHotkeySection: View { subtitle: nil, placeholder: shortcutModel.formatPlaceholder(effective: effective, numbered: false), chordsEnabled: false, - hasPendingRejection: shortcutModel.bareKeyRejections.contains(hotkeyAction.rawValue), + hasPendingRejection: shortcutModel.hasPendingRejection(for: hotkeyAction), firstStrokeRequiresModifier: true, isUnbound: effective?.isUnbound ?? true, canRestore: shortcutModel.canRestore(for: hotkeyAction), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift index 7c31c793bd8c..033dd79b5c3a 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift @@ -33,6 +33,9 @@ public struct KeyboardShortcutsSection: View { userDefaultsStore: userDefaultsStore, catalog: catalog, errorLog: errorLog, + canRegisterSystemWideHotkey: { + hostActions.canRegisterSystemWideHotkey($0) + }, onShortcutsChanged: { hostActions.notifyShortcutSettingsDidChange() } )) } diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListLegacyOverrideTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListLegacyOverrideTests.swift index d0c04083d1ca..5f9f66a22930 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListLegacyOverrideTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListLegacyOverrideTests.swift @@ -90,6 +90,26 @@ import Testing #expect(model.effective(for: .nextSidebarTab) == legacyShortcut) } + @Test func invalidLegacyShowHideChordDisplaysNoEffectiveHotkey() throws { + let invalidChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "c") + ) + let (defaultsStore, suiteName) = try makeDefaultsStore( + legacyBindings: [.showHideAllWindows: invalidChord] + ) + defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } + let model = ShortcutListModel( + jsonStore: makeJSONStore(), + userDefaultsStore: defaultsStore, + catalog: SettingCatalog(), + errorLog: SettingsErrorLog() + ) + + #expect(model.legacyBindings[ShortcutAction.showHideAllWindows.rawValue] == invalidChord) + #expect(model.effective(for: .showHideAllWindows) == nil) + } + @Test func jsonOverrideTakesPrecedenceOverLegacyOverride() async throws { let action = ShortcutAction.nextSidebarTab let legacyShortcut = StoredShortcut(first: ShortcutStroke(key: "]", command: true, shift: true)) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListManagedBindingTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListManagedBindingTests.swift new file mode 100644 index 000000000000..d49f93e999ae --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListManagedBindingTests.swift @@ -0,0 +1,105 @@ +import CmuxSettings +import Foundation +import Testing + +@testable import CmuxSettingsUI + +@MainActor +@Suite struct ShortcutListManagedBindingTests { + @Test func invalidManagedShowHideDoesNotDisplayBuiltInShortcut() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "shortcut-list-invalid-managed-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + + let fileURL = directory.appendingPathComponent("cmux.json") + try """ + { + "shortcuts": { + "bindings": { + "showHideAllWindows": "j" + } + } + } + """.write(to: fileURL, atomically: true, encoding: .utf8) + + let model = ShortcutListModel( + jsonStore: JSONConfigStore(fileURL: fileURL), + catalog: SettingCatalog(), + errorLog: SettingsErrorLog() + ) + model.startObserving() + + await spin { + model.effective(for: .showHideAllWindows) == nil + } + + #expect(model.effective(for: .showHideAllWindows) == nil) + } + + @Test func unrelatedWritePreservesMalformedManagedSibling() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "shortcut-list-preserves-malformed-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + + let fileURL = directory.appendingPathComponent("cmux.json") + try """ + { + "shortcuts": { + "bindings": { + "showHideAllWindows": 42 + } + } + } + """.write(to: fileURL, atomically: true, encoding: .utf8) + + let model = ShortcutListModel( + jsonStore: JSONConfigStore(fileURL: fileURL), + catalog: SettingCatalog(), + errorLog: SettingsErrorLog() + ) + model.startObserving() + await spin { + model.managedBindingActionIDs.contains( + ShortcutAction.showHideAllWindows.rawValue + ) + } + + await model.clearBinding(for: .globalSearch) + + let root = try #require( + try JSONSerialization.jsonObject(with: Data(contentsOf: fileURL)) + as? [String: Any] + ) + let shortcuts = try #require(root["shortcuts"] as? [String: Any]) + let bindings = try #require(shortcuts["bindings"] as? [String: Any]) + #expect(bindings["showHideAllWindows"] as? Int == 42) + #expect( + StoredShortcut.decodeFromJSON( + bindings["globalSearch"] + )?.isUnbound == true + ) + } + + private func spin(until condition: () -> Bool) async { + var spins = 0 + while !condition(), spins < 100_000 { + await Task.yield() + spins += 1 + } + #expect(condition(), "spin(until:) timed out after 100 000 yields") + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift index 64a597dc5f53..7075ed95d32a 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift @@ -436,6 +436,47 @@ import CmuxSettings ) } + @Test func persistedGlobalSearchCollisionDisplaysRuntimeFallback() async throws { + let (store, catalog, errorLog) = makeStore() + let collision = StoredShortcut(first: ShortcutStroke( + key: "g", command: true, option: true, control: true + )) + try await store.set( + [ + ShortcutAction.showHideAllWindows.rawValue: collision, + ShortcutAction.globalSearch.rawValue: collision, + ], + for: catalog.shortcuts.bindings + ) + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings.count == 2 }) + + #expect(model.effective(for: .showHideAllWindows) == collision) + #expect(model.effective(for: .globalSearch) == ShortcutAction.globalSearch.defaultShortcut) + } + + @Test func invalidPersistedShowHideChordDisplaysNoEffectiveHotkey() async throws { + let (store, catalog, errorLog) = makeStore() + let invalidChord = StoredShortcut( + first: ShortcutStroke(key: "b", control: true), + second: ShortcutStroke(key: "c") + ) + let sentinel = StoredShortcut(first: ShortcutStroke(key: "j", command: true)) + try await store.set( + [ + ShortcutAction.showHideAllWindows.rawValue: invalidChord, + ShortcutAction.openSettings.rawValue: sentinel, + ], + for: catalog.shortcuts.bindings + ) + let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) + model.startObserving() + await spin(until: { model.bindings[ShortcutAction.openSettings.rawValue] == sentinel }) + + #expect(model.effective(for: .showHideAllWindows) == nil) + } + @Test func scopeCaptionDescribesSimulatorFocus() { let (store, catalog, errorLog) = makeStore() let model = ShortcutListModel(jsonStore: store, catalog: catalog, errorLog: errorLog) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListRestoreValidationTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListRestoreValidationTests.swift new file mode 100644 index 000000000000..3fefae476365 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListRestoreValidationTests.swift @@ -0,0 +1,125 @@ +import CmuxSettings +import Foundation +import Testing + +@testable import CmuxSettingsUI + +@MainActor +@Suite struct ShortcutListRestoreValidationTests { + @Test func restorePreservesSupportedLegacyBareSpaceBinding() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "shortcut-list-restore-bare-space-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = JSONConfigStore(fileURL: directory.appendingPathComponent("cmux.json")) + let catalog = SettingCatalog() + let shortcut = StoredShortcut(first: ShortcutStroke(key: "space")) + try await store.set( + [ShortcutAction.openSettings.rawValue: shortcut], + for: catalog.shortcuts.bindings + ) + let model = ShortcutListModel( + jsonStore: store, + catalog: catalog, + errorLog: SettingsErrorLog() + ) + model.startObserving() + await spin { + model.effective(for: .openSettings) == shortcut + } + + await model.clearOrRestore(for: .openSettings) + await spin { + model.bindings[ShortcutAction.openSettings.rawValue]?.isUnbound == true + } + #expect(model.restoreShortcuts[ShortcutAction.openSettings.rawValue] == shortcut) + + await model.clearOrRestore(for: .openSettings) + await spin { + model.bindings[ShortcutAction.openSettings.rawValue] == shortcut + } + + #expect(model.bindings[ShortcutAction.openSettings.rawValue] == shortcut) + #expect(model.effective(for: .openSettings) == shortcut) + #expect(model.restoreShortcuts[ShortcutAction.openSettings.rawValue] == nil) + #expect(!model.bareKeyRejections.contains(ShortcutAction.openSettings.rawValue)) + } + + @Test func restoreRejectsNewShowHideConflictAndKeepsCandidate() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "shortcut-list-restore-validation-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = JSONConfigStore(fileURL: directory.appendingPathComponent("cmux.json")) + let catalog = SettingCatalog() + let shortcut = StoredShortcut(first: ShortcutStroke( + key: "g", + command: true, + option: true, + control: true + )) + try await store.set( + [ShortcutAction.globalSearch.rawValue: shortcut], + for: catalog.shortcuts.bindings + ) + let model = ShortcutListModel( + jsonStore: store, + catalog: catalog, + errorLog: SettingsErrorLog() + ) + model.startObserving() + await spin { + model.effective(for: .globalSearch) == shortcut + } + + await model.clearOrRestore(for: .globalSearch) + await spin { + model.bindings[ShortcutAction.globalSearch.rawValue]?.isUnbound == true + } + #expect(model.restoreShortcuts[ShortcutAction.globalSearch.rawValue] == shortcut) + #expect(model.effective(for: .globalSearch) == nil) + + await model.assign(stroke: shortcut.first, to: .showHideAllWindows) + await spin { + model.bindings[ShortcutAction.showHideAllWindows.rawValue] == shortcut + } + #expect(model.effective(for: .showHideAllWindows) == shortcut) + + await model.clearOrRestore(for: .globalSearch) + + #expect( + model.conflictRejections[ShortcutAction.globalSearch.rawValue] + == .showHideAllWindows + ) + #expect(model.effective(for: .globalSearch) == nil) + #expect(model.restoreShortcuts[ShortcutAction.globalSearch.rawValue] == shortcut) + #expect( + await store.value(for: catalog.shortcuts.bindings)[ + ShortcutAction.globalSearch.rawValue + ]?.isUnbound == true + ) + } + + private func spin(until condition: () -> Bool) async { + var spins = 0 + while !condition(), spins < 100_000 { + await Task.yield() + spins += 1 + } + #expect(condition(), "spin(until:) timed out after 100 000 yields") + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SystemWideHotkeyShortcutListModelTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SystemWideHotkeyShortcutListModelTests.swift new file mode 100644 index 000000000000..9e3d79afa5d0 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SystemWideHotkeyShortcutListModelTests.swift @@ -0,0 +1,119 @@ +import CmuxSettings +import Foundation +import Testing +@testable import CmuxSettingsUI + +@MainActor +@Suite struct SystemWideHotkeyShortcutListModelTests { + @Test func nonRegistrableShowHideBindingDoesNotReserveGlobalSearch() async throws { + let fixture = makeFixture() + let commandPeriod = StoredShortcut( + first: ShortcutStroke(key: ".", command: true) + ) + try await fixture.store.set( + [ + ShortcutAction.showHideAllWindows.rawValue: commandPeriod, + ShortcutAction.globalSearch.rawValue: commandPeriod, + ], + for: fixture.catalog.shortcuts.bindings + ) + let model = ShortcutListModel( + jsonStore: fixture.store, + catalog: fixture.catalog, + errorLog: fixture.errorLog + ) + model.startObserving() + await spin(until: { model.bindings.count == 2 }) + + #expect(model.effective(for: .showHideAllWindows) == nil) + #expect(model.effective(for: .globalSearch) == commandPeriod) + } + + @Test func shiftOnlyShowHideBindingUsesSystemWideModifierRejection() async { + let fixture = makeFixture() + let model = ShortcutListModel( + jsonStore: fixture.store, + catalog: fixture.catalog, + errorLog: fixture.errorLog + ) + + await model.assign( + stroke: ShortcutStroke(key: "f", shift: true), + to: .showHideAllWindows + ) + + let bindings = await fixture.store.value( + for: fixture.catalog.shortcuts.bindings + ) + #expect(bindings[ShortcutAction.showHideAllWindows.rawValue] == nil) + #expect( + model.validationMessage(for: .showHideAllWindows) + == String( + localized: "shortcut.recorder.error.systemWideHotkeyRequiresModifier", + defaultValue: "System-wide hotkeys must include Command, Option, or Control." + ) + ) + } + + @Test func hostRegistrationPolicyControlsGlobalSearchReservation() async throws { + let fixture = makeFixture() + let rejectedByHost = StoredShortcut( + first: ShortcutStroke( + key: "f20", + command: true, + shift: true, + option: true, + control: true, + keyCode: 90 + ) + ) + try await fixture.store.set( + [ + ShortcutAction.showHideAllWindows.rawValue: rejectedByHost, + ShortcutAction.globalSearch.rawValue: rejectedByHost, + ], + for: fixture.catalog.shortcuts.bindings + ) + let model = ShortcutListModel( + jsonStore: fixture.store, + catalog: fixture.catalog, + errorLog: fixture.errorLog, + canRegisterSystemWideHotkey: { $0 != rejectedByHost } + ) + model.startObserving() + await spin(until: { model.bindings.count == 2 }) + + #expect(model.effective(for: .showHideAllWindows) == nil) + #expect(model.effective(for: .globalSearch) == rejectedByHost) + } + + private func makeFixture() -> ( + store: JSONConfigStore, + catalog: SettingCatalog, + errorLog: SettingsErrorLog + ) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "system-wide-hotkey-shortcut-list-model-\(UUID().uuidString)", + isDirectory: true + ) + try? FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + return ( + JSONConfigStore(fileURL: directory.appendingPathComponent("cmux.json")), + SettingCatalog(), + SettingsErrorLog() + ) + } + + private func spin(until condition: () -> Bool) async { + var attempts = 0 + while !condition(), attempts < 100_000 { + await Task.yield() + attempts += 1 + } + #expect(condition(), "spin(until:) timed out after 100 000 yields") + } +} diff --git a/Sources/AllShortcutsPopover.swift b/Sources/AllShortcutsPopover.swift index 829617bd3ad5..3be50357e3a9 100644 --- a/Sources/AllShortcutsPopover.swift +++ b/Sources/AllShortcutsPopover.swift @@ -20,7 +20,7 @@ import SwiftUI /// the boundary) and its `revision` is read once at the top of `body`; no row /// view holds a store reference. struct AllShortcutsPopover: View { - @ObservedObject private var shortcutObserver = KeyboardShortcutSettingsObserver.shared + @State private var shortcutObserver = KeyboardShortcutSettingsObserver.shared var body: some View { // Reading the observer's revision keeps the popover reactive to live diff --git a/Sources/App/AppDelegate+VisibleGlobalSearchShortcutRouting.swift b/Sources/App/AppDelegate+VisibleGlobalSearchShortcutRouting.swift new file mode 100644 index 000000000000..c50e13e4fbc7 --- /dev/null +++ b/Sources/App/AppDelegate+VisibleGlobalSearchShortcutRouting.swift @@ -0,0 +1,118 @@ +import AppKit +import CmuxSettingsUI + +extension AppDelegate { + enum VisibleGlobalSearchShortcutRoute { + case notApplicable + case handled + case queryOwnsEvent + } + + /// Routes the visible Search palette's configured toggle before its local + /// monitor applies generic query-key handling. + func routeVisibleGlobalSearchShortcutFromLocalMonitor( + _ event: NSEvent + ) -> VisibleGlobalSearchShortcutRoute { + guard event.type == .keyDown, + GlobalSearchCoordinator.shared.isPaletteVisible() else { + return .notApplicable + } + guard !KeyboardShortcutRecorderActivity.isAnyRecorderActive, + !RecorderHostButton.isActivelyRecording else { + clearConfiguredShortcutChordState() + return .notApplicable + } + + let shortcut = globalSearchShortcutForRouting() + guard !shortcut.isUnbound else { return .notApplicable } + + let normalizedFlags = ShortcutStroke.normalizedModifierFlags(from: event.modifierFlags) + let hasMatchingChordState = + activeConfiguredShortcutChordPrefixForCurrentEvent == shortcut.firstStroke + || pendingConfiguredShortcutChord?.firstStroke == shortcut.firstStroke + let matchesFirstStroke = + activeConfiguredShortcutChordPrefixForCurrentEvent == nil + && shortcut.firstStroke.modifierFlags == normalizedFlags + && matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + guard hasMatchingChordState || matchesFirstStroke else { + return .notApplicable + } + + let previousPendingChord = pendingConfiguredShortcutChord + let previousActiveChord = activeConfiguredShortcutChordPrefixForCurrentEvent + let eventWindowNumber = configuredShortcutChordWindowNumber(for: event) + if let pendingConfiguredShortcutChord, + pendingConfiguredShortcutChord.windowNumber == eventWindowNumber, + pendingConfiguredShortcutChord.firstStroke == shortcut.firstStroke { + activeConfiguredShortcutChordPrefixForCurrentEvent = + pendingConfiguredShortcutChord.firstStroke + } else if activeConfiguredShortcutChordPrefixForCurrentEvent + != shortcut.firstStroke { + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } + pendingConfiguredShortcutChord = nil + + let route = routeVisibleGlobalSearchShortcut( + event, + normalizedFlags: normalizedFlags + ) + if case .notApplicable = route { + pendingConfiguredShortcutChord = previousPendingChord + activeConfiguredShortcutChordPrefixForCurrentEvent = previousActiveChord + } else { + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } + return route + } + + /// Applies the single visible-palette toggle policy for every local monitor. + func routeVisibleGlobalSearchShortcut( + _ event: NSEvent, + normalizedFlags: NSEvent.ModifierFlags + ) -> VisibleGlobalSearchShortcutRoute { + let shortcut = globalSearchShortcutForRouting() + let matchesShortcut = matchGlobalSearchShortcut( + event: event, + normalizedFlags: normalizedFlags + ) + let matchesUnarmedChordPrefix = matchesUnarmedGlobalSearchChordPrefix( + event, + normalizedFlags: normalizedFlags + ) + guard matchesShortcut || matchesUnarmedChordPrefix else { + return .notApplicable + } + guard GlobalSearchCoordinator.shared.isPaletteVisible() else { + return .notApplicable + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + GlobalSearchKeyEvent(event).queryOwnsEditingShortcut { + return .queryOwnsEvent + } + if matchesShortcut { + toggleGlobalSearchPalette() + return .handled + } + if globalSearchShortcutWhenClauseAllows(event: event), + armConfiguredShortcutChordIfNeeded( + event: event, + actions: [], + shortcuts: [shortcut] + ) { + return .handled + } + return .notApplicable + } + + func matchesUnarmedGlobalSearchChordPrefix( + _ event: NSEvent, + normalizedFlags: NSEvent.ModifierFlags + ) -> Bool { + let shortcut = globalSearchShortcutForRouting() + return shortcut.hasChord + && activeConfiguredShortcutChordPrefixForCurrentEvent == nil + && shortcut.firstStroke.modifierFlags == normalizedFlags + && matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } +} diff --git a/Sources/App/MenuBarExtraController.swift b/Sources/App/MenuBarExtraController.swift index 7c068ef50570..12653b7ca5f4 100644 --- a/Sources/App/MenuBarExtraController.swift +++ b/Sources/App/MenuBarExtraController.swift @@ -270,7 +270,7 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate { } @objc private func globalSearchAction() { - toggleGlobalSearchPalette() + AppDelegate.shared?.toggleGlobalSearchPalette() } @objc private func showMainWindowAction() { diff --git a/Sources/App/ShortcutBareStartRouting.swift b/Sources/App/ShortcutBareStartRouting.swift index bdb0916cbead..0b04b55a8c1d 100644 --- a/Sources/App/ShortcutBareStartRouting.swift +++ b/Sources/App/ShortcutBareStartRouting.swift @@ -15,7 +15,7 @@ enum KeyboardShortcutBareStartCache { let resolvedKeys = Set( KeyboardShortcutSettings.Action.allCases.compactMap { action -> String? in - guard action != .showHideAllWindows else { return nil } + guard !action.isSystemWideHotkey else { return nil } guard !action.isBrowserContentShortcut else { return nil } guard action.participatesInAppWideBareStartRouting else { return nil } return KeyboardShortcutSettings.shortcut(for: action).bareShortcutStartKey @@ -128,6 +128,34 @@ extension AppDelegate { $0.shortcut?.bareShortcutStartKey == bareShortcutKey } } + + func matchGlobalSearchShortcut( + event: NSEvent, + normalizedFlags: NSEvent.ModifierFlags? = nil + ) -> Bool { + let shortcut = globalSearchShortcutForRouting() + guard let stroke = activeGlobalSearchStroke(for: shortcut), + stroke.modifierFlags + == (normalizedFlags ?? ShortcutStroke.normalizedModifierFlags(from: event.modifierFlags)), + matchShortcutStroke(event: event, stroke: stroke) else { + return false + } + return globalSearchShortcutWhenClauseAllows(event: event) + } + + /// Returns the foreground Search binding from the observer's authoritative snapshot. + func globalSearchShortcutForRouting() -> StoredShortcut { + KeyboardShortcutSettingsObserver.shared.globalSearchShortcut + } + + private func activeGlobalSearchStroke(for shortcut: StoredShortcut) -> ShortcutStroke? { + guard !shortcut.isUnbound else { return nil } + if let activePrefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard shortcut.firstStroke == activePrefix else { return nil } + return shortcut.secondStroke + } + return shortcut.hasChord ? nil : shortcut.firstStroke + } } private extension NSEvent { diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 1982236ba2e9..09720e65a319 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -85,7 +85,7 @@ func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { return normalizedFlags == [] || normalizedFlags == [.shift] } -func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool { +func shortcutResponderHasMarkedText(_ responder: NSResponder?) -> Bool { guard let responder else { return false } // During IME composition, Return/Enter belongs to the text system so the diff --git a/Sources/App/TerminalFindEscapeRouting.swift b/Sources/App/TerminalFindEscapeRouting.swift index 47bd3938d9cd..cf0e08876a19 100644 --- a/Sources/App/TerminalFindEscapeRouting.swift +++ b/Sources/App/TerminalFindEscapeRouting.swift @@ -18,7 +18,7 @@ func cmuxCloseFocusedTerminalFindForEscape(event: NSEvent, appDelegate: AppDeleg guard let panel = (targetTabManager ?? appDelegate.tabManager)?.selectedTerminalPanel, panel.searchState != nil, - !browserResponderHasMarkedText(shortcutWindow?.firstResponder), + !shortcutResponderHasMarkedText(shortcutWindow?.firstResponder), terminalFindFieldOwnsResponder || appDelegate.allowsTerminalKeyboardFocus( workspaceId: panel.workspaceId, panelId: panel.id, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index abddf427e1e3..c0e41b90af1d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -757,11 +757,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var splitButtonTooltipRefreshScheduled = false private var didScheduleGhosttyCrashBreadcrumbCheck = false private var ghosttyCrashBreadcrumbTask: Task? - private struct PendingConfiguredShortcutChord { + struct PendingConfiguredShortcutChord { let firstStroke: ShortcutStroke let windowNumber: Int? } - private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? + var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? var shortcutEventFocusContextCache: ShortcutEventFocusContextCache? private var ghosttyConfigObserver: NSObjectProtocol? @@ -9151,7 +9151,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - func toggleGlobalSearchPaletteFromGlobalHotkey() { + func toggleGlobalSearchPalette() { if menuBarExtraController == nil, MenuBarExtraSettings.shouldInstallMenuBarExtra() { setupMenuBarExtra() @@ -12661,13 +12661,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func currentConfiguredShortcutChordActions() -> [KeyboardShortcutSettings.Action] { KeyboardShortcutSettings.Action.allCases.filter { action in - // System-wide hotkeys are dispatched via Carbon RegisterEventHotKey - // and never routed through AppKit's local key handler. If a managed - // cmux.json entry somehow stores one as a chord, arming the prefix - // here would swallow the first stroke and leave the second one - // orphaned, breaking that keystroke for the focused terminal/browser - // input. - guard action != .showHideAllWindows && action != .globalSearch && action.allowsChordShortcut else { return false } + // Carbon owns the opt-in hotkey, while Global Search has a cached + // foreground route before generic chord handling. + guard !action.isSystemWideHotkey, + action != .globalSearch, + action.allowsChordShortcut else { + return false + } guard !action.isBrowserContentShortcut else { return false } return KeyboardShortcutSettings.shortcut(for: action).hasChord } @@ -12942,7 +12942,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shortcutRoutingShouldBypassForPrintableOptionText(event: event) { let shortcutWindow = resolvedShortcutEventWindow(event) ?? shortcutRoutingActiveWindow - if browserResponderHasMarkedText(shortcutWindow?.firstResponder) { + if shortcutResponderHasMarkedText(shortcutWindow?.firstResponder) { clearConfiguredShortcutChordState() return false } @@ -13287,14 +13287,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - if shouldConsumeShortcutWhileCommandPaletteVisible( + // Active marked text owns every non-Command event, regardless of which + // NSTextInputClient has focus. Command shortcuts remain available while + // composing because Command is not part of IME input sequences. + let shortcutWindowForMarkedText = resolvedShortcutEventWindow(event) + ?? event.window + ?? shortcutRoutingActiveWindow + ?? shortcutRoutingKeyWindow + let shortcutResponderForMarkedText = shortcutWindowForMarkedText?.firstResponder + if !normalizedFlags.contains(.command) { + if let ghosttyView = shortcutResponderForMarkedText.cmuxStrictOwningGhosttyView(), + ghosttyView.hasMarkedText() { + return false + } + if shortcutResponderHasMarkedText(shortcutResponderForMarkedText) { + return false + } + } + + let globalSearchShortcut = globalSearchShortcutForRouting() + let matchesGlobalSearchShortcut = matchGlobalSearchShortcut( + event: event, + normalizedFlags: normalizedFlags + ) + let commandPaletteConsumesShortcut = shouldConsumeShortcutWhileCommandPaletteVisible( isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, - normalizedFlags: normalizedFlags, - chars: chars, - keyCode: event.keyCode - ) { + normalizedFlags: normalizedFlags, chars: chars, keyCode: event.keyCode + ) + let commandPaletteCanRouteUnarmedGlobalSearch = commandPaletteEffectiveInTargetWindow && commandPaletteConsumesShortcut + let globalSearchUnarmedChordPrefixMatches = matchesUnarmedGlobalSearchChordPrefix(event, normalizedFlags: normalizedFlags) + switch routeVisibleGlobalSearchShortcut(event, normalizedFlags: normalizedFlags) { + case .handled: return true + case .queryOwnsEvent: + return false + case .notApplicable: + break + } + if matchesGlobalSearchShortcut, + activeConfiguredShortcutChordPrefixForCurrentEvent != nil + || commandPaletteCanRouteUnarmedGlobalSearch { + toggleGlobalSearchPalette() + return true + } + if globalSearchUnarmedChordPrefixMatches, + commandPaletteCanRouteUnarmedGlobalSearch { + if globalSearchShortcutWhenClauseAllows(event: event), + armConfiguredShortcutChordIfNeeded(event: event, actions: [], shortcuts: [globalSearchShortcut]) { return true } } + if commandPaletteConsumesShortcut { return true } + if commandPaletteEffectiveInTargetWindow, + activeConfiguredShortcutChordPrefixForCurrentEvent == nil { return false } if isPlainEscape { let escapeWindow = resolvedShortcutEventWindow(event) ?? shortcutRoutingActiveWindow @@ -13312,25 +13355,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese - // input), don't intercept non-Cmd key events — let them flow through to the - // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still - // work during composition since Cmd is never part of IME input sequences. - if !normalizedFlags.contains(.command), - let ghosttyView = (shortcutRoutingKeyWindow?.firstResponder).cmuxStrictOwningGhosttyView(), - ghosttyView.hasMarkedText() { - return false - } - - let shortcutWindowForMarkedText = resolvedShortcutEventWindow(event) ?? event.window ?? shortcutRoutingActiveWindow - if browserOmnibarShouldBypassShortcutRoutingForMarkedText( - hasFocusedAddressBar: hasFocusedAddressBarInShortcutContext, - firstResponderHasMarkedText: browserResponderHasMarkedText(shortcutWindowForMarkedText?.firstResponder), - flags: event.modifierFlags - ) { - return false - } - // When the notifications popover is open, Escape should dismiss it immediately. if flags.isEmpty, event.keyCode == 53, titlebarAccessoryController.dismissNotificationsPopoverIfShown() { return true @@ -13486,6 +13510,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + globalSearchShortcut.hasChord, + globalSearchShortcutWhenClauseAllows(event: event), + armConfiguredShortcutChordIfNeeded(event: event, actions: [], shortcuts: [globalSearchShortcut]) { + return true + } + + if matchesGlobalSearchShortcut { + toggleGlobalSearchPalette() + return true + } + if matchConfiguredShortcut(event: event, action: .commandPalette) { let targetWindow = commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") @@ -15223,6 +15259,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return matchConfiguredShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: action)) } + /// `shortcuts.when` gates opening Search; visible Search owns its toggle so + /// the auxiliary popover's transient focus context cannot prevent dismissal. + func globalSearchShortcutWhenClauseAllows(event: NSEvent) -> Bool { + GlobalSearchCoordinator.shared.isPaletteVisible() + || shortcutWhenClauseAllows(action: .globalSearch, event: event) + } + /// Whether `action`'s effective `when` clause (its `shortcuts.when` override, /// or its built-in context default) is satisfied by the event's focus state. /// Gates every focus-scoped shortcut, including the numbered workspace/surface @@ -15237,7 +15280,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func rightSidebarModeShortcut(for event: NSEvent) -> RightSidebarMode? { let shortcutWindow = resolvedShortcutEventWindow(event) ?? event.window ?? shortcutRoutingActiveWindow if shortcutRoutingShouldBypassForPrintableOptionText(event: event), - browserResponderHasMarkedText(shortcutWindow?.firstResponder) { + shortcutResponderHasMarkedText(shortcutWindow?.firstResponder) { return nil } return KeyboardShortcutSettingsObserver.shared.rightSidebarModeShortcutMatcher.modeShortcut(for: event) { [self] action in @@ -15314,7 +15357,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { + func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { if let window = mainWindowForShortcutEvent(event) { return window.windowNumber } @@ -17079,7 +17122,7 @@ private extension NSWindow { let firstResponderWebView = self.firstResponder.flatMap { Self.cmuxOwningWebView(for: $0, in: self, event: event) } - let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) + let firstResponderHasMarkedText = shortcutResponderHasMarkedText(self.firstResponder) let firstResponderIsCommandPaletteFieldEditor = Self.cmuxCommandPaletteOwnsFieldEditor( self.firstResponder as? NSTextView, in: self @@ -17361,7 +17404,7 @@ private extension NSWindow { if cmuxForceDispatchKeyDownOnce( event, to: omnibarResponder, - reason: browserResponderHasMarkedText(omnibarResponder) + reason: shortcutResponderHasMarkedText(omnibarResponder) ? "browser arrow restored focused omnibar with marked text" : "browser arrow restored focused omnibar" ) { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0d3397896862..b0d14f950ac5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10495,7 +10495,7 @@ struct VerticalTabsSidebar: View, Equatable { @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore( initialSidebarFontSize: GhosttyConfig.load().sidebarFontSize ) - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State var dragState = SidebarDragState() // Bonsplit tab drags arrive through AppKit pasteboard callbacks, not // `SidebarDragState`, so they need a separate transient collection flag. @@ -14524,7 +14524,7 @@ private struct SidebarHelpMenuButton: View { private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") private let buttonSize: CGFloat = 22 private let iconSize: CGFloat = 11 - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared let onSendFeedback: () -> Void diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index 857772c87216..7041b203422a 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -3,7 +3,7 @@ import Observation import PostHog import os -struct CmuxFeatureFlagDefinition: Identifiable, Equatable { +struct CmuxFeatureFlagDefinition: Identifiable, Equatable, Sendable { var id: String { key } let key: String @@ -51,9 +51,9 @@ final class CmuxFeatureFlags { #endif private static let agentChatUIDefault = false #if DEBUG - private static let mobileWorkspaceChangesDefault = true + private nonisolated static let mobileWorkspaceChangesDefault = true #else - private static let mobileWorkspaceChangesDefault = false + private nonisolated static let mobileWorkspaceChangesDefault = false #endif private static let sidebarWorkspaceAgentSpinnerDefault = false private static let simulatorDefault = true @@ -95,7 +95,7 @@ final class CmuxFeatureFlags { // Mac-side flag turns the whole feature off end to end. Release builds // keep it off until the PostHog flag enables it; DEBUG keeps it on for // dogfood. - static let mobileWorkspaceChangesFlag = CmuxFeatureFlagDefinition( + nonisolated static let mobileWorkspaceChangesFlag = CmuxFeatureFlagDefinition( key: "mobile-workspace-changes-enabled-release", title: String( localized: "featureFlags.mobileWorkspaceChanges.title", diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index a74f1d376073..1db4589bc913 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -102,6 +102,14 @@ final class HostSettingsActions: SettingsHostActions { } } + func canRegisterSystemWideHotkey( + _ shortcut: CmuxSettings.StoredShortcut + ) -> Bool { + SystemWideHotkeySettings.registrationCandidate( + for: StoredShortcut(cmuxSettingsStoredShortcut: shortcut) + ) != nil + } + func applyLanguageOverride(_ language: AppLanguage) { LanguageSettingsStore(defaults: .standard).applyLanguageOverride(language) } diff --git a/Sources/KeyboardShortcutSettings+ActionMetadata.swift b/Sources/KeyboardShortcutSettings+ActionMetadata.swift index df4356bd3d85..df5aa3ed2e33 100644 --- a/Sources/KeyboardShortcutSettings+ActionMetadata.swift +++ b/Sources/KeyboardShortcutSettings+ActionMetadata.swift @@ -1,4 +1,6 @@ extension KeyboardShortcutSettings.Action { + var isSystemWideHotkey: Bool { self == .showHideAllWindows } + var allowsChordShortcut: Bool { self != .fileExplorerOpenSelection && self != .fileExplorerOpenSelectionFinderAlias diff --git a/Sources/KeyboardShortcutSettings+PersistedShortcutPolicy.swift b/Sources/KeyboardShortcutSettings+PersistedShortcutPolicy.swift new file mode 100644 index 000000000000..00cd918ac1e5 --- /dev/null +++ b/Sources/KeyboardShortcutSettings+PersistedShortcutPolicy.swift @@ -0,0 +1,161 @@ +import CmuxSettings + +/// Bridges the app target's legacy shortcut representation into the shared +/// action-owned persistence policy. +extension KeyboardShortcutSettings.Action { + func shortcutBindingPolicyRejection( + for shortcut: StoredShortcut + ) -> KeyboardShortcutSettings.ShortcutRecordingRejection? { + guard let settingsAction = CmuxSettings.ShortcutAction(rawValue: rawValue) else { + return nil + } + + switch settingsAction.shortcutBindingPolicyResult( + for: shortcut.cmuxSettingsStoredShortcut + ) { + case .accepted: + return nil + case .primaryModifierRequired: + return .systemWideHotkeyRequiresModifier + case .bareFirstStrokeNotAllowed: + return self == .showHideAllWindows + ? .systemWideHotkeyRequiresModifier + : .bareKeyNotAllowed + case .chordNotAllowed, + .systemDefinedMediaKeyNotAllowed, + .systemReservedShortcutNotAllowed: + return .reservedBySystem + } + } +} + +extension KeyboardShortcutSettings { + /// Resolves one persistence layer's candidate, falling back to a valid + /// built-in default when the candidate cannot execute. + static func effectivePersistedShortcut( + _ candidate: StoredShortcut?, + for action: Action, + managedBySettingsFile: Bool = false + ) -> StoredShortcut? { + if managedBySettingsFile, + candidate == nil, + action == .showHideAllWindows { + return nil + } + + guard let settingsAction = CmuxSettings.ShortcutAction(rawValue: action.rawValue) else { + return nil + } + + return settingsAction.effectivePersistedShortcut( + candidate?.cmuxSettingsStoredShortcut, + normalizing: { shortcut in + normalizedExecutableShortcut( + StoredShortcut(cmuxSettingsStoredShortcut: shortcut), + for: action + )?.cmuxSettingsStoredShortcut + }, + conflictsWithReservedShortcut: { shortcut in + conflictsWithConfiguredSystemWideHotkey( + StoredShortcut(cmuxSettingsStoredShortcut: shortcut), + action: action + ) + } + ).map(StoredShortcut.init(cmuxSettingsStoredShortcut:)) + } + + private static func normalizedExecutableShortcut( + _ shortcut: StoredShortcut, + for action: Action + ) -> StoredShortcut? { + guard case let .accepted(normalized) = action + .resolvedRecordedShortcutIgnoringConflicts( + shortcut, + checkingSystemWideConflicts: false + ) else { + return nil + } + return normalized + } + + private static func conflictsWithConfiguredSystemWideHotkey( + _ shortcut: StoredShortcut, + action: Action + ) -> Bool { + guard action == .globalSearch, + let proposedPrefix = shortcut.firstStroke.carbonHotKeyRegistration, + let systemWideHotkey = SystemWideHotkeySettings.registrationCandidate( + for: SystemWideHotkeySettings.shortcut() + )?.registration else { + return false + } + return proposedPrefix == systemWideHotkey + } +} + +extension SystemWideHotkeySettings { + /// Resolves a complete Show/Hide binding into the Carbon registration shape + /// shared by conflict arbitration and the actual system-wide registrar. + /// + /// Global Search intentionally yields to this candidate. Every other + /// application and AppKit reservation is validated before it can reserve + /// the foreground shortcut or reach the registrar. + static func registrationCandidate( + for shortcut: StoredShortcut + ) -> (shortcut: StoredShortcut, registration: CarbonHotKeyRegistration)? { + guard case let .accepted(normalizedShortcut) = action + .resolvedRecordedShortcutIgnoringConflicts( + shortcut, + checkingSystemWideConflicts: false + ), + let registration = normalizedShortcut.carbonHotKeyRegistration, + !KeyboardShortcutSettings.systemWideHotkeyConflicts( + with: normalizedShortcut, + excluding: action, + alsoExcluding: [.globalSearch] + ) else { + return nil + } + return (normalizedShortcut, registration) + } +} + +extension StoredShortcut { + init(cmuxSettingsStoredShortcut shortcut: CmuxSettings.StoredShortcut) { + self.init( + first: ShortcutStroke(cmuxSettingsShortcutStroke: shortcut.first), + second: shortcut.second.map(ShortcutStroke.init(cmuxSettingsShortcutStroke:)) + ) + } + + var cmuxSettingsStoredShortcut: CmuxSettings.StoredShortcut { + CmuxSettings.StoredShortcut( + first: firstStroke.cmuxSettingsShortcutStroke, + second: secondStroke?.cmuxSettingsShortcutStroke + ) + } +} + +extension ShortcutStroke { + init(cmuxSettingsShortcutStroke stroke: CmuxSettings.ShortcutStroke) { + self.init( + key: stroke.key, + command: stroke.command, + shift: stroke.shift, + option: stroke.option, + control: stroke.control, + keyCode: stroke.keyCode + ) + } + + var cmuxSettingsShortcutStroke: CmuxSettings.ShortcutStroke { + CmuxSettings.ShortcutStroke( + key: key, + command: command, + shift: shift, + option: option, + control: control, + keyCode: keyCode + ) + } +} diff --git a/Sources/KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift b/Sources/KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift index 5e782f4e968a..444ae982d6c0 100644 --- a/Sources/KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift +++ b/Sources/KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift @@ -2,10 +2,14 @@ import Foundation /// System-wide shortcut conflict helpers extracted from `KeyboardShortcutSettings.swift`, which sits at its file-length budget. extension KeyboardShortcutSettings { - static func reservedSystemWideHotkeyShortcuts(excluding currentAction: Action) -> [StoredShortcut] { + static func reservedSystemWideHotkeyShortcuts( + excluding currentAction: Action, + alsoExcluding ignoredActions: Set = [] + ) -> [StoredShortcut] { var reserved: [StoredShortcut] = [] - for action in Action.allCases where action != currentAction { + for action in Action.allCases + where action != currentAction && !ignoredActions.contains(action) { let shortcut = systemWideConflictShortcut(for: action) guard !shortcut.isUnbound else { continue } if shortcut.hasChord { @@ -34,6 +38,30 @@ extension KeyboardShortcutSettings { return reserved } + static func systemWideHotkeyConflicts( + with shortcut: StoredShortcut, + excluding action: Action, + alsoExcluding ignoredActions: Set = [] + ) -> Bool { + guard let registration = shortcut.carbonHotKeyRegistration else { + return false + } + let keyCode = UInt16(registration.keyCode) + let modifierFlags = shortcut.modifierFlags + let eventCharacter = KeyboardLayout.character(forKeyCode: keyCode) + + return reservedSystemWideHotkeyShortcuts( + excluding: action, + alsoExcluding: ignoredActions + ).contains { reserved in + reserved.matches( + keyCode: keyCode, + modifierFlags: modifierFlags, + eventCharacter: eventCharacter + ) + } + } + static func systemWideConflictShortcut(for action: Action) -> StoredShortcut { switch action { case .showHideAllWindows: @@ -48,9 +76,5 @@ extension KeyboardShortcutSettings { StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true), StoredShortcut(key: "`", command: true, shift: false, option: false, control: false), StoredShortcut(key: "`", command: true, shift: true, option: false, control: false), - // Cmd+. is AppKit's standard cancel keystroke for modal alerts and - // open/save panels. Refuse to register it as the global hotkey so the - // first instinctive "cancel" press never hides the whole app. - StoredShortcut(key: ".", command: true, shift: false, option: false, control: false), ] } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 0f6b49ef053c..9d6d22997005 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -677,18 +677,15 @@ enum KeyboardShortcutSettings { if shortcut.isUnbound { return .accepted(.unbound) } - if shortcut.hasChord && !allowsChordShortcut { - return .rejected(.reservedBySystem) - } - if let conflictingAction = KeyboardShortcutSettings.conflictingAction( - for: shortcut, - excluding: self - ) { + let resolved = resolvedRecordedShortcutIgnoringConflicts(shortcut) + guard case .accepted = resolved else { return resolved } + + if let conflictingAction = KeyboardShortcutSettings.conflictingAction(for: shortcut, excluding: self) { return .rejected(.conflictsWithAction(conflictingAction)) } - return resolvedRecordedShortcutIgnoringConflicts(shortcut) + return resolved } func normalizedSettingsFileShortcut(_ shortcut: StoredShortcut) -> StoredShortcut? { @@ -697,9 +694,6 @@ enum KeyboardShortcutSettings { if shortcut.isUnbound { return .unbound } - if shortcut.hasChord && !allowsChordShortcut { - return nil - } if case let .accepted(normalized) = resolvedRecordedShortcutIgnoringConflicts( shortcut, @@ -720,14 +714,19 @@ enum KeyboardShortcutSettings { if shortcut.isUnbound { return .accepted(.unbound) } + if let rejection = shortcutBindingPolicyRejection(for: shortcut) { + return .rejected(rejection) + } switch self { - case .showHideAllWindows, .globalSearch: + case .showHideAllWindows: return KeyboardShortcutSettings.normalizedSystemWideHotkeyShortcutResult( shortcut, for: self, checkingConflicts: checkingSystemWideConflicts ) + case .globalSearch: + return .accepted(shortcut) case .selectSurfaceByNumber, .selectWorkspaceByNumber: return resolvedNumberedDigitShortcut(shortcut) default: @@ -784,23 +783,6 @@ enum KeyboardShortcutSettings { return normalized } - private static func systemWideHotkeyConflicts(with shortcut: StoredShortcut, excluding action: Action) -> Bool { - guard let registration = shortcut.carbonHotKeyRegistration else { return false } - let keyCode = UInt16(registration.keyCode) - let modifierFlags = shortcut.modifierFlags - // Validate against the keystroke AppKit shortcuts would see for the - // registered Carbon hotkey under the current input source. - let eventCharacter = KeyboardLayout.character(forKeyCode: keyCode) - - return reservedSystemWideHotkeyShortcuts(excluding: action).contains { reserved in - reserved.matches( - keyCode: keyCode, - modifierFlags: modifierFlags, - eventCharacter: eventCharacter - ) - } - } - private static func conflictingAction( for proposedShortcut: StoredShortcut, excluding currentAction: Action @@ -929,7 +911,7 @@ enum KeyboardShortcutSettings { if shortcut.isUnbound { return shortcut } if shortcut.hasChord && !action.allowsChordShortcut { return nil } - switch action.resolvedRecordedShortcutIgnoringConflicts(shortcut) { + switch action.normalizedRecordedShortcutResult(shortcut) { case let .accepted(normalizedShortcut): return normalizedShortcut case .rejected: @@ -1099,10 +1081,7 @@ enum SystemWideHotkeySettings { static func shortcut() -> StoredShortcut { migrateLegacyShortcutIfNeeded() - if let managedShortcut = KeyboardShortcutSettings.settingsFileStore.override(for: action) { - return managedShortcut - } - return storedShortcut() ?? defaultShortcut + return KeyboardShortcutSettings.shortcut(for: action) } static func setShortcut(_ shortcut: StoredShortcut) { @@ -1132,10 +1111,10 @@ enum SystemWideHotkeySettings { private static func migrateLegacyShortcutIfNeeded(defaults: UserDefaults = .standard) { guard defaults.object(forKey: legacyShortcutKey) != nil else { return } - defer { defaults.removeObject(forKey: legacyShortcutKey) } - + let legacyData = defaults.data(forKey: legacyShortcutKey) + defaults.removeObject(forKey: legacyShortcutKey) guard defaults.object(forKey: action.defaultsKey) == nil, - let data = defaults.data(forKey: legacyShortcutKey), + let data = legacyData, let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { return } @@ -1162,16 +1141,10 @@ struct CarbonHotKeyRegistration: Equatable { final class SystemWideHotkeyController { static let shared = SystemWideHotkeyController() private static let hotKeySignature: OSType = 0x434D484B // "CMHK" - private static let hotKeyIDs: [KeyboardShortcutSettings.Action: UInt32] = [ - .showHideAllWindows: 1, - .globalSearch: 2, - ] - private static let systemWideActions: [KeyboardShortcutSettings.Action] = [ - .showHideAllWindows, - .globalSearch, - ] + private static let hotKeyID: UInt32 = 1 + private static let action = SystemWideHotkeySettings.action - private var hotKeyRefs: [KeyboardShortcutSettings.Action: EventHotKeyRef] = [:] + private var hotKeyRef: EventHotKeyRef? private var hotKeyHandler: EventHandlerRef? private var defaultsObserver: NSObjectProtocol? private var shortcutObserver: NSObjectProtocol? @@ -1179,8 +1152,8 @@ final class SystemWideHotkeyController { private var packageRecorderObserver: NSObjectProtocol? private var inputSourceObserver: NSObjectProtocol? private var appHideObserver: NSObjectProtocol? - private var registeredShortcuts: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] - private var registeredHotKeyRegistrations: [KeyboardShortcutSettings.Action: CarbonHotKeyRegistration] = [:] + private var registeredShortcut: StoredShortcut? + private var registeredHotKeyRegistration: CarbonHotKeyRegistration? private init() {} @@ -1243,44 +1216,38 @@ final class SystemWideHotkeyController { } private func refreshRegistration() { + let shortcut = SystemWideHotkeySettings.shortcut() // Stand down while either recorder is armed (legacy app-target recorder or the CmuxSettingsUI - // package recorder) so a system-wide hotkey being rebound in Settings is captured rather than fired. + // package recorder) so the opt-in hotkey is captured rather than fired while being rebound. let isShortcutRecordingActive = KeyboardShortcutRecorderActivity.isAnyRecorderActive || RecorderHostButton.isActivelyRecording - guard !isShortcutRecordingActive else { - unregisterHotKeys() + guard SystemWideHotkeySettings.isEnabled(), + !shortcut.isUnbound, + !isShortcutRecordingActive else { + unregisterHotKey() return } - for action in Self.systemWideActions { - refreshRegistration(for: action) - } - } - - private func refreshRegistration(for action: KeyboardShortcutSettings.Action) { - let configuredShortcut = shortcut(for: action) - guard isSystemWideActionEnabled(action, shortcut: configuredShortcut) else { - unregisterHotKey(for: action) - return - } - - guard let normalizedShortcut = action.normalizedRecordedShortcut(configuredShortcut), - let registration = normalizedShortcut.carbonHotKeyRegistration else { - unregisterHotKey(for: action) + guard let candidate = SystemWideHotkeySettings.registrationCandidate( + for: shortcut + ) else { + unregisterHotKey() return } + let normalizedShortcut = candidate.shortcut + let registration = candidate.registration - if registeredShortcuts[action] == normalizedShortcut, - registeredHotKeyRegistrations[action] == registration, - hotKeyRefs[action] != nil { + if registeredShortcut == normalizedShortcut, + registeredHotKeyRegistration == registration, + hotKeyRef != nil { return } - unregisterHotKey(for: action) + unregisterHotKey() installHotKeyHandlerIfNeeded() - let hotKeyID = EventHotKeyID(signature: Self.hotKeySignature, id: Self.hotKeyID(for: action)) + let hotKeyID = EventHotKeyID(signature: Self.hotKeySignature, id: Self.hotKeyID) var hotKeyRef: EventHotKeyRef? let status = RegisterEventHotKey( registration.keyCode, @@ -1294,50 +1261,25 @@ final class SystemWideHotkeyController { guard status == noErr, let hotKeyRef else { #if DEBUG cmuxDebugLog( - "globalHotkey.register failed action=\(action.rawValue) shortcut=\(normalizedShortcut.displayString) " + + "globalHotkey.register failed action=\(Self.action.rawValue) shortcut=\(normalizedShortcut.displayString) " + "keyCode=\(registration.keyCode) modifiers=\(registration.modifiers) status=\(status)" ) #endif return } - hotKeyRefs[action] = hotKeyRef - registeredShortcuts[action] = normalizedShortcut - registeredHotKeyRegistrations[action] = registration + self.hotKeyRef = hotKeyRef + registeredShortcut = normalizedShortcut + registeredHotKeyRegistration = registration #if DEBUG cmuxDebugLog( - "globalHotkey.register success action=\(action.rawValue) shortcut=\(normalizedShortcut.displayString) " + + "globalHotkey.register success action=\(Self.action.rawValue) shortcut=\(normalizedShortcut.displayString) " + "keyCode=\(registration.keyCode) modifiers=\(registration.modifiers)" ) #endif } - private func shortcut(for action: KeyboardShortcutSettings.Action) -> StoredShortcut { - switch action { - case .showHideAllWindows: - return SystemWideHotkeySettings.shortcut() - default: - return KeyboardShortcutSettings.shortcut(for: action) - } - } - - private func isSystemWideActionEnabled( - _ action: KeyboardShortcutSettings.Action, - shortcut: StoredShortcut - ) -> Bool { - guard !shortcut.isUnbound else { return false } - switch action { - case .showHideAllWindows: - return SystemWideHotkeySettings.isEnabled() - case .globalSearch: - return true - default: - assertionFailure("Unhandled system-wide hotkey action: \(action.rawValue)") - return false - } - } - private func installHotKeyHandlerIfNeeded() { guard hotKeyHandler == nil else { return } @@ -1363,18 +1305,13 @@ final class SystemWideHotkeyController { #endif } - private func unregisterHotKey(for action: KeyboardShortcutSettings.Action) { - if let hotKeyRef = hotKeyRefs.removeValue(forKey: action) { + private func unregisterHotKey() { + if let hotKeyRef { UnregisterEventHotKey(hotKeyRef) + self.hotKeyRef = nil } - registeredShortcuts[action] = nil - registeredHotKeyRegistrations[action] = nil - } - - private func unregisterHotKeys() { - for action in Self.systemWideActions { - unregisterHotKey(for: action) - } + registeredShortcut = nil + registeredHotKeyRegistration = nil } private static let hotKeyEventHandler: EventHandlerUPP = { _, event, userInfo in @@ -1401,32 +1338,27 @@ final class SystemWideHotkeyController { guard status == noErr, hotKeyID.signature == Self.hotKeySignature, - let action = Self.action(forHotKeyID: hotKeyID.id) else { + hotKeyID.id == Self.hotKeyID else { return OSStatus(eventNotHandledErr) } #if DEBUG - let shortcut = registeredShortcuts[action]?.displayString ?? "unknown" - cmuxDebugLog("globalHotkey.fire action=\(action.rawValue) shortcut=\(shortcut) active=\(NSApp.isActive ? 1 : 0)") + let shortcut = registeredShortcut?.displayString ?? "unknown" + cmuxDebugLog( + "globalHotkey.fire action=\(Self.action.rawValue) " + + "shortcut=\(shortcut) active=\(NSApp.isActive ? 1 : 0)" + ) #endif Task { @MainActor [weak self] in - self?.perform(action) + self?.toggleApplicationVisibility() } return OSStatus(noErr) } @MainActor - private func perform(_ action: KeyboardShortcutSettings.Action) { - switch action { - case .showHideAllWindows: - AppDelegate.shared?.toggleApplicationVisibilityFromGlobalHotkey() - case .globalSearch: - AppDelegate.shared?.toggleGlobalSearchPaletteFromGlobalHotkey() - default: - assertionFailure("Unhandled system-wide hotkey action: \(action.rawValue)") - break - } + private func toggleApplicationVisibility() { + AppDelegate.shared?.toggleApplicationVisibilityFromGlobalHotkey() } @MainActor @@ -1434,17 +1366,6 @@ final class SystemWideHotkeyController { AppDelegate.shared?.captureMainWindowVisibilityRestoreTargetsForApplicationHide() } - private static func hotKeyID(for action: KeyboardShortcutSettings.Action) -> UInt32 { - guard let hotKeyID = hotKeyIDs[action] else { - assertionFailure("Unhandled system-wide hotkey action: \(action.rawValue)") - return 0 - } - return hotKeyID - } - - private static func action(forHotKeyID hotKeyID: UInt32) -> KeyboardShortcutSettings.Action? { - systemWideActions.first { Self.hotKeyID(for: $0) == hotKeyID } - } } struct ShortcutStroke: Equatable, Hashable { diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index c5d524f37015..5a97ae0fe4a3 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -6,51 +6,6 @@ import os nonisolated private let cmuxSettingsFileStoreLogger = Logger(subsystem: "com.cmuxterm.app", category: "SettingsStore") -/// Publishes keyboard-shortcut revisions and owns the right-sidebar matcher snapshot. -@MainActor -final class KeyboardShortcutSettingsObserver: ObservableObject { - static let shared = KeyboardShortcutSettingsObserver() - - @Published private(set) var revision: UInt64 = 0 - let rightSidebarModeShortcutMatcher = RightSidebarModeShortcutMatcher() - private var settingsCancellable: AnyCancellable? - private var recorderCancellable: AnyCancellable? - - private init(notificationCenter: NotificationCenter = .default) { - settingsCancellable = notificationCenter.publisher( - for: KeyboardShortcutSettings.didChangeNotification - ).sink { [weak self] _ in - Self.deliverOnMainActor { [weak self] in - self?.revision &+= 1 - self?.rightSidebarModeShortcutMatcher.reload() - } - } - recorderCancellable = notificationCenter.publisher( - for: KeyboardShortcutRecorderActivity.didChangeNotification - ).sink { [weak self] _ in - Self.deliverOnMainActor { [weak self] in - self?.revision &+= 1 - } - } - } - - /// Preserves synchronous delivery for main-thread settings mutations while - /// bridging background file-watcher notifications onto the main actor. - nonisolated private static func deliverOnMainActor( - _ action: @escaping @MainActor @Sendable () -> Void - ) { - if Thread.isMainThread { - MainActor.assumeIsolated { - action() - } - } else { - Task { @MainActor in - action() - } - } - } -} - final class CmuxSettingsFileStore { static let currentSchemaVersion = 1 static let schemaURLString = "https://raw.githubusercontent.com/manaflow-ai/cmux/main/web/data/cmux.schema.json" @@ -98,6 +53,7 @@ final class CmuxSettingsFileStore { private var socketPasswordObserver: NSObjectProtocol? private var shortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + private var managedShortcutActions: Set = [] private var whenClausesByAction: [KeyboardShortcutSettings.Action: ShortcutWhenClause] = [:] private var activeManagedUserDefaults: [String: ManagedSettingsValue] = [:] private var importedManagedDefaults: [String: ManagedSettingsValue] = [:] @@ -185,6 +141,7 @@ final class CmuxSettingsFileStore { let previousState = synchronized { ( shortcuts: shortcutsByAction, + managedShortcutActions: managedShortcutActions, whenClauses: whenClausesByAction, importedManagedDefaults: importedManagedDefaults, sourcePath: activeSourcePath @@ -203,6 +160,7 @@ final class CmuxSettingsFileStore { ) synchronized { shortcutsByAction = resolved.shortcuts + managedShortcutActions = resolved.managedShortcutActions whenClausesByAction = resolved.whenClauses activeManagedUserDefaults = resolved.managedUserDefaults importedManagedDefaults = resolved.managedUserDefaults @@ -213,6 +171,7 @@ final class CmuxSettingsFileStore { saveImportedManagedDefaults(resolved.managedUserDefaults) if previousState.shortcuts != resolved.shortcuts + || previousState.managedShortcutActions != resolved.managedShortcutActions || previousState.whenClauses != resolved.whenClauses || previousState.sourcePath != resolved.path { KeyboardShortcutSettings.notifySettingsFileDidChange(center: notificationCenter) @@ -233,7 +192,7 @@ final class CmuxSettingsFileStore { } func isManagedByFile(_ action: KeyboardShortcutSettings.Action) -> Bool { - synchronized { shortcutsByAction[action] != nil } + synchronized { managedShortcutActions.contains(action) } } func settingsFileURLForEditing() -> URL { @@ -296,6 +255,7 @@ final class CmuxSettingsFileStore { ResolvedSettingsSnapshot( path: activeSourcePath, shortcuts: shortcutsByAction, + managedShortcutActions: managedShortcutActions, whenClauses: whenClausesByAction, managedUserDefaults: activeManagedUserDefaults, legacyDerivedManagedUserDefaultKeys: activeLegacyDerivedManagedUserDefaultKeys, @@ -1004,6 +964,7 @@ final class CmuxSettingsFileStore { cmuxSettingsFileStoreLogger.warning("ignoring unknown shortcut action '\(rawAction, privacy: .private(mask: .hash))' in \(sourcePath, privacy: .private(mask: .hash))") continue } + snapshot.managedShortcutActions.insert(action) guard let shortcut = parseShortcutBindingValue(rawBinding, action: action) else { cmuxSettingsFileStoreLogger.warning("ignoring invalid shortcut binding for '\(rawAction, privacy: .private(mask: .hash))' in \(sourcePath, privacy: .private(mask: .hash))") continue @@ -1061,8 +1022,8 @@ final class CmuxSettingsFileStore { // Object form written by the CmuxSettings package recorder (in-app // Settings UI): { "first": { key, command, ... }, "second": { ... }? }. // A Settings rebinding only reaches this store in that shape; decode it - // so every action resolved here — most visibly the system-wide Carbon - // hotkeys (globalSearch, showHideAllWindows) — honors the rebinding + // so every action resolved here — including foreground Global Search and the + // opt-in system-wide Show/Hide hotkey — honors the rebinding // instead of silently falling back to the built-in default. if let object = rawValue as? [String: Any] { return parseShortcutObjectForm(object, action: action) @@ -1828,6 +1789,7 @@ typealias KeyboardShortcutSettingsFileStore = CmuxSettingsFileStore struct ResolvedSettingsSnapshot { var path: String? var shortcuts: [KeyboardShortcutSettings.Action: StoredShortcut] = [:] + var managedShortcutActions: Set = [] /// Per-action `when`-clause overrides parsed from `shortcuts.when` — gate a /// binding to a focus context (see ``ShortcutWhenClause``). var whenClauses: [KeyboardShortcutSettings.Action: ShortcutWhenClause] = [:] @@ -1836,13 +1798,18 @@ struct ResolvedSettingsSnapshot { var managedCustomSettings = ManagedCustomSettings() mutating func fillMissingSettings(from fallback: ResolvedSettingsSnapshot) { - if path == nil && (!fallback.shortcuts.isEmpty || + if path == nil && (!fallback.managedShortcutActions.isEmpty || !fallback.managedUserDefaults.isEmpty || !fallback.managedCustomSettings.isEmpty) { path = fallback.path } - for (action, shortcut) in fallback.shortcuts where shortcuts[action] == nil { - shortcuts[action] = shortcut + let missingShortcutActions = fallback.managedShortcutActions + .subtracting(managedShortcutActions) + for action in missingShortcutActions { + managedShortcutActions.insert(action) + if let shortcut = fallback.shortcuts[action] { + shortcuts[action] = shortcut + } } for (action, clause) in fallback.whenClauses where whenClauses[action] == nil { whenClauses[action] = clause diff --git a/Sources/KeyboardShortcutSettingsLookup.swift b/Sources/KeyboardShortcutSettingsLookup.swift index 7d63d2a59f87..62dee19dde66 100644 --- a/Sources/KeyboardShortcutSettingsLookup.swift +++ b/Sources/KeyboardShortcutSettingsLookup.swift @@ -8,14 +8,24 @@ extension KeyboardShortcutSettings { shortcutLookupObserver?(action) #endif - if let configuredShortcut = explicitlyConfiguredShortcut(for: action) { - return configuredShortcut.isUnbound ? nil : configuredShortcut - } - - return defaultShortcutResolvingLegacyConflicts( + let managedBySettingsFile = settingsFileStore.isManagedByFile(action) + let configuredShortcut = explicitlyConfiguredShortcut(for: action) + let resolvedShortcut = effectivePersistedShortcut( + configuredShortcut, for: action, - explicitlyConfiguredShortcut: explicitlyConfiguredShortcut(for:) + managedBySettingsFile: managedBySettingsFile ) + + if action == .reopenClosedWorkspace, + resolvedShortcut == action.defaultShortcut, + configuredShortcut != resolvedShortcut { + return defaultShortcutResolvingLegacyConflicts( + for: action, + explicitlyConfiguredShortcut: explicitlyConfiguredShortcut(for:) + ) + } + + return resolvedShortcut } static func defaultShortcutResolvingLegacyConflicts( @@ -40,8 +50,8 @@ extension KeyboardShortcutSettings { } private static func explicitlyConfiguredShortcut(for action: Action) -> StoredShortcut? { - if let managedShortcut = settingsFileStore.override(for: action) { - return managedShortcut + if settingsFileStore.isManagedByFile(action) { + return settingsFileStore.override(for: action) } guard let data = UserDefaults.standard.data(forKey: action.defaultsKey) else { return nil diff --git a/Sources/KeyboardShortcutSettingsObserver.swift b/Sources/KeyboardShortcutSettingsObserver.swift new file mode 100644 index 000000000000..154cb50b1403 --- /dev/null +++ b/Sources/KeyboardShortcutSettingsObserver.swift @@ -0,0 +1,103 @@ +import Carbon +import Foundation +import Observation + +/// Observes keyboard-shortcut revisions and owns hot-path matcher snapshots. +@MainActor +@Observable +final class KeyboardShortcutSettingsObserver { + typealias ShortcutProvider = (KeyboardShortcutSettings.Action) -> StoredShortcut + + static let shared = KeyboardShortcutSettingsObserver() + + private(set) var revision: UInt64 = 0 + private(set) var globalSearchShortcut: StoredShortcut + let rightSidebarModeShortcutMatcher: RightSidebarModeShortcutMatcher + private let notificationCenter: NotificationCenter + private let distributedNotificationCenter: DistributedNotificationCenter + @ObservationIgnored + private let shortcutProvider: ShortcutProvider + @ObservationIgnored + private var settingsObserver: NSObjectProtocol? + @ObservationIgnored + private var recorderObserver: NSObjectProtocol? + @ObservationIgnored + private var inputSourceObserver: NSObjectProtocol? + + init( + notificationCenter: NotificationCenter = .default, + distributedNotificationCenter: DistributedNotificationCenter = .default(), + shortcutProvider: @escaping ShortcutProvider = KeyboardShortcutSettings.shortcut(for:) + ) { + self.notificationCenter = notificationCenter + self.distributedNotificationCenter = distributedNotificationCenter + self.shortcutProvider = shortcutProvider + globalSearchShortcut = shortcutProvider(.globalSearch) + rightSidebarModeShortcutMatcher = RightSidebarModeShortcutMatcher( + shortcutProvider: shortcutProvider + ) + settingsObserver = notificationCenter.addObserver( + forName: KeyboardShortcutSettings.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + Self.deliverOnMainActor { [weak self] in + self?.reloadCachedShortcuts() + } + } + recorderObserver = notificationCenter.addObserver( + forName: KeyboardShortcutRecorderActivity.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + Self.deliverOnMainActor { [weak self] in + self?.revision &+= 1 + } + } + inputSourceObserver = distributedNotificationCenter.addObserver( + forName: Notification.Name( + rawValue: kTISNotifySelectedKeyboardInputSourceChanged as String + ), + object: nil, + queue: nil + ) { [weak self] _ in + Self.deliverOnMainActor { [weak self] in + self?.reloadCachedShortcuts() + } + } + } + + deinit { + if let settingsObserver { + notificationCenter.removeObserver(settingsObserver) + } + if let recorderObserver { + notificationCenter.removeObserver(recorderObserver) + } + if let inputSourceObserver { + distributedNotificationCenter.removeObserver(inputSourceObserver) + } + } + + private func reloadCachedShortcuts() { + globalSearchShortcut = shortcutProvider(.globalSearch) + revision &+= 1 + rightSidebarModeShortcutMatcher.reload() + } + + /// Preserves synchronous delivery for main-thread settings mutations while + /// bridging background notifications onto the main actor. + nonisolated private static func deliverOnMainActor( + _ action: @escaping @MainActor @Sendable () -> Void + ) { + if Thread.isMainThread { + MainActor.assumeIsolated { + action() + } + } else { + Task { @MainActor in + action() + } + } + } +} diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift index a2c8a95f144a..d1729f37a347 100644 --- a/Sources/NotificationsPage.swift +++ b/Sources/NotificationsPage.swift @@ -7,7 +7,7 @@ struct NotificationsPage: View { @EnvironmentObject var tabManager: TabManager @Binding var selection: SidebarSelection @FocusState private var focusedNotificationId: UUID? - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @AppStorage(PhonePushSettings.forwardEnabledKey) private var forwardToPhone = false @AppStorage(PhonePushSettings.hideContentKey) private var hidePhoneNotificationContent = false @AppStorage(PhonePushSettings.forwardModeKey) private var forwardToPhoneMode = PhoneForwardingMode.defaultMode.rawValue diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 31d24a717ff7..8881e22202be 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -279,7 +279,7 @@ struct BrowserPanelView: View { @AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @LiveSetting(\.shortcuts.showModifierHoldHints) private var showModifierHoldHints @State private var omnibarSuggestionRefreshScheduler = OmnibarSuggestionRefreshScheduler() @State private var omnibarSuggestionRefreshConsumerTask: Task? diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 2022ecec4bc0..064c18a8c797 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -125,7 +125,7 @@ struct RightSidebarPanelView: View { @State private var focusShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var closeShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var hasMountedRightSidebarContent = false - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let alwaysShowShortcutHints = ShortcutHintDebugSettings().alwaysShowHints private let closeShortcutHintXOffset = ShortcutHintDebugSettings.defaultRightSidebarCloseHintX private let closeShortcutHintYOffset = ShortcutHintDebugSettings.defaultRightSidebarCloseHintY diff --git a/Sources/Search/GlobalSearchKeyEvent+QueryEditing.swift b/Sources/Search/GlobalSearchKeyEvent+QueryEditing.swift new file mode 100644 index 000000000000..52beecd9d4d0 --- /dev/null +++ b/Sources/Search/GlobalSearchKeyEvent+QueryEditing.swift @@ -0,0 +1,67 @@ +import AppKit +import Foundation + +extension GlobalSearchKeyEvent { + /// Whether the search query's field editor owns this event as native text input. + var queryOwnsEditingShortcut: Bool { + let flags = modifierFlags + let commandControlOption = flags.intersection([.command, .control, .option]) + + if isTextNavigationOrDeletionKey { + return true + } + if commandControlOption == [.command] { + return isCommandEditingCharacter + } + if commandControlOption == [.control] { + return isControlEditingCharacter + } + if commandControlOption == [.option] { + return isPrintableTextInput + } + if commandControlOption.isEmpty { + return isPrintableTextInput + } + return false + } + + private var isCommandEditingCharacter: Bool { + guard let character = charactersIgnoringModifiers?.lowercased() else { return false } + return ["a", "c", "v", "x", "z"].contains(character) + } + + private var isControlEditingCharacter: Bool { + guard let character = charactersIgnoringModifiers?.lowercased() else { return false } + return ["a", "b", "d", "e", "f", "h", "k", "l", "n", "o", "p", "t", "v", "y"] + .contains(character) + } + + private var isTextNavigationOrDeletionKey: Bool { + switch keyCode { + case 51, 115, 116, 117, 119, 121, 123, 124, 125, 126: + return true + default: + return false + } + } + + private var isPrintableTextInput: Bool { + if Self.isPrintableText(characters) { + return true + } + return Self.isPrintableText( + KeyboardLayout.textInputCharacter( + forKeyCode: keyCode, + modifierFlags: modifierFlags + ) + ) + } + + private static func isPrintableText(_ text: String?) -> Bool { + guard let text, !text.isEmpty else { return false } + return text.unicodeScalars.allSatisfy { scalar in + !CharacterSet.controlCharacters.contains(scalar) + && (scalar.value < 0xF700 || scalar.value > 0xF8FF) + } + } +} diff --git a/Sources/Search/MenubarSearchPopover.swift b/Sources/Search/MenubarSearchPopover.swift index c7aed61ecbfb..5e4ca9c9a768 100644 --- a/Sources/Search/MenubarSearchPopover.swift +++ b/Sources/Search/MenubarSearchPopover.swift @@ -219,10 +219,22 @@ private struct GlobalSearchPaletteView: View { guard keyMonitor == nil else { return } keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in let keyEvent = GlobalSearchKeyEvent(event) - let consumed = MainActor.assumeIsolated { - handleKeyEvent(keyEvent) + let route = MainActor.assumeIsolated { + AppDelegate.shared? + .routeVisibleGlobalSearchShortcutFromLocalMonitor(event) + ?? .notApplicable + } + switch route { + case .handled: + return nil + case .queryOwnsEvent: + return event + case .notApplicable: + let consumed = MainActor.assumeIsolated { + handleKeyEvent(keyEvent) + } + return consumed ? nil : event } - return consumed ? nil : event } } @@ -251,10 +263,10 @@ private struct GlobalSearchPaletteView: View { case 53: coordinator.dismissPalette() return true - case 126: + case 126 where flags.isDisjoint(with: [.command, .shift, .option, .control]): selectedIndex = max(0, selectedIndex - 1) return true - case 125: + case 125 where flags.isDisjoint(with: [.command, .shift, .option, .control]): selectedIndex = min(max(results.count - 1, 0), selectedIndex + 1) return true case 36, 76: @@ -264,26 +276,12 @@ private struct GlobalSearchPaletteView: View { if flags.contains(.command), !flags.contains(.option), !flags.contains(.control) { - return !isTextEditingCommand(event) && !isSystemCommand(event) + return !event.queryOwnsEditingShortcut && !isSystemCommand(event) } return false } } - private func isTextEditingCommand(_ event: GlobalSearchKeyEvent) -> Bool { - if let characters = event.charactersIgnoringModifiers?.lowercased(), - ["a", "c", "v", "x", "z"].contains(characters) { - return true - } - - switch event.keyCode { - case 51, 117, 123, 124: - return true - default: - return false - } - } - private func isSystemCommand(_ event: GlobalSearchKeyEvent) -> Bool { guard let characters = event.charactersIgnoringModifiers?.lowercased() else { return false } return ["h", "m", "q", "w", ","].contains(characters) @@ -300,13 +298,15 @@ private struct GlobalSearchPaletteView: View { } } -private struct GlobalSearchKeyEvent: Sendable { +struct GlobalSearchKeyEvent: Sendable { let keyCode: UInt16 + let characters: String? let charactersIgnoringModifiers: String? private let modifierFlagsRawValue: UInt init(_ event: NSEvent) { keyCode = event.keyCode + characters = event.characters charactersIgnoringModifiers = event.charactersIgnoringModifiers modifierFlagsRawValue = event.modifierFlags .intersection(.deviceIndependentFlagsMask) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index db749d751499..2a13419c3c1d 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -2132,7 +2132,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private struct NotificationsPopoverView: View { @ObservedObject var notificationStore: TerminalNotificationStore - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared let onDismiss: () -> Void @AppStorage("cmux.notifications.popover.width") diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 516db4cac174..44213216a05d 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -758,7 +758,7 @@ extension WorkspaceContentView { struct EmptyPanelView: View { @ObservedObject var workspace: Workspace let paneId: PaneID - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private struct ShortcutHint: View { let text: String diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index df470176bc77..f7c545a9c1da 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -56,7 +56,7 @@ struct cmuxApp: App { @StateObject private var notificationStore = TerminalNotificationStore.shared @StateObject var closedItemHistoryStore = ClosedItemHistoryStore.shared @StateObject private var sidebarState = SidebarState() - @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage(TitlebarControlsStyle.storageKey) private var titlebarControlsStyle = TitlebarControlsStyle.defaultRawValue @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 94825bfe67ba..a730cae1dc1e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -169,6 +169,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 6419A0036419A0036419A003 /* AppDelegate+ShortcutRoutingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419A0046419A0046419A004 /* AppDelegate+ShortcutRoutingWindow.swift */; }; C51A740000000000000000A1 /* AppDelegate+SimulatorShortcutRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */; }; A79840020000000000000002 /* AppDelegate+SocketConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79840020000000000000001 /* AppDelegate+SocketConfiguration.swift */; }; + D5AAB5856FF095F610EB565C /* AppDelegate+VisibleGlobalSearchShortcutRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAB5856FF095F610EB565D /* AppDelegate+VisibleGlobalSearchShortcutRouting.swift */; }; DCDC1000000000000000C030 /* AppDelegate+WindowDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDC1000000000000000C031 /* AppDelegate+WindowDock.swift */; }; 4805E1513D104D02B52A9807 /* AppDelegate+WindowFramePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4085E612770476FA272C012 /* AppDelegate+WindowFramePolicy.swift */; }; 895500000000000000000005 /* AppDelegate+WindowFrameRestoration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895500000000000000000006 /* AppDelegate+WindowFrameRestoration.swift */; }; @@ -1007,8 +1008,15 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources FE001106 /* GitStatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE001006 /* GitStatusProvider.swift */; }; 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */; }; 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */; }; + 8561A0018561A0018561A001 /* GlobalSearchInputOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561B0018561B0018561B001 /* GlobalSearchInputOwnershipTests.swift */; }; + 8561A0058561A0058561A005 /* GlobalSearchKeyEvent+QueryEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561B0058561B0058561B005 /* GlobalSearchKeyEvent+QueryEditing.swift */; }; 3865A0083865A0083865A008 /* GlobalSearchPanelCaptureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */; }; + D5AAB5856FF095F610EB565A /* GlobalSearchShortcutBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAB5856FF095F610EB565B /* GlobalSearchShortcutBehaviorTests.swift */; }; + 8561A0068561A0068561A006 /* GlobalSearchShortcutPersistencePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561B0068561B0068561B006 /* GlobalSearchShortcutPersistencePolicyTests.swift */; }; + 8561A0028561A0028561A002 /* GlobalSearchShortcutPriorityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561B0028561B0028561B002 /* GlobalSearchShortcutPriorityTests.swift */; }; + 8561A0048561A0048561A004 /* GlobalSearchShortcutSettingsModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561B0048561B0048561B004 /* GlobalSearchShortcutSettingsModelTests.swift */; }; 3865A0053865A0053865A005 /* GlobalSearchShortcutSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0053865B0053865B005 /* GlobalSearchShortcutSettingsTests.swift */; }; + 8561A0038561A0038561A003 /* GlobalSearchVisiblePopoverShortcutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561B0038561B0038561B003 /* GlobalSearchVisiblePopoverShortcutTests.swift */; }; 5B11E5A100000000000000B1 /* GPUSpinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B11E5A100000000000000B2 /* GPUSpinner.swift */; }; A6AC72010000000000000001 /* GPUSpinnerNSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC72010000000000000002 /* GPUSpinnerNSView.swift */; }; A6AC72020000000000000001 /* GPUSpinnerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC72020000000000000002 /* GPUSpinnerStyle.swift */; }; @@ -1046,6 +1054,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE5A100000000000000001 /* KeyboardShortcutSavedLayoutTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE5A100000000000000002 /* KeyboardShortcutSavedLayoutTemplateTests.swift */; }; 875200000000000000000013 /* KeyboardShortcutSettings+ActionMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875200000000000000000003 /* KeyboardShortcutSettings+ActionMetadata.swift */; }; 80410000000000000000000B /* KeyboardShortcutSettings+ActionVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80410000000000000000000C /* KeyboardShortcutSettings+ActionVisibility.swift */; }; + 8561A0078561A0078561A007 /* KeyboardShortcutSettings+PersistedShortcutPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561B0078561B0078561B007 /* KeyboardShortcutSettings+PersistedShortcutPolicy.swift */; }; F7216002000000000000001 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7216002000000000000002 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */; }; A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; A50012F8 /* KeyboardShortcutSettingsControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F9 /* KeyboardShortcutSettingsControls.swift */; }; @@ -1058,6 +1067,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DEF0B30000000000000001 /* KeyboardShortcutSettingsFileStoreMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0B30000000000000002 /* KeyboardShortcutSettingsFileStoreMigrationTests.swift */; }; E3337001E3337001E3337001 /* KeyboardShortcutSettingsFileStoreStartupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3337002E3337002E3337002 /* KeyboardShortcutSettingsFileStoreStartupTests.swift */; }; C1713004C1713004C1713004 /* KeyboardShortcutSettingsLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1713003C1713003C1713003 /* KeyboardShortcutSettingsLookup.swift */; }; + 8561C0128561C0128561C012 /* KeyboardShortcutSettingsObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8561C0118561C0118561C011 /* KeyboardShortcutSettingsObserver.swift */; }; 468160000000000000000001 /* KeyboardShortcutSettingsObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468160000000000000000002 /* KeyboardShortcutSettingsObserverTests.swift */; }; A17110000000000000000001 /* KeyboardShortcutSpaceKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A17110000000000000000002 /* KeyboardShortcutSpaceKeyTests.swift */; }; C34670040000000000000001 /* KeyboardShortcutTestSettingsIsolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34670040000000000000002 /* KeyboardShortcutTestSettingsIsolation.swift */; }; @@ -2553,6 +2563,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6419A0046419A0046419A004 /* AppDelegate+ShortcutRoutingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+ShortcutRoutingWindow.swift"; sourceTree = ""; }; C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+SimulatorShortcutRouting.swift"; sourceTree = ""; }; A79840020000000000000001 /* AppDelegate+SocketConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+SocketConfiguration.swift"; sourceTree = ""; }; + D5AAB5856FF095F610EB565D /* AppDelegate+VisibleGlobalSearchShortcutRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AppDelegate+VisibleGlobalSearchShortcutRouting.swift"; sourceTree = ""; }; DCDC1000000000000000C031 /* AppDelegate+WindowDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+WindowDock.swift"; sourceTree = ""; }; B4085E612770476FA272C012 /* AppDelegate+WindowFramePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+WindowFramePolicy.swift"; sourceTree = ""; }; 895500000000000000000006 /* AppDelegate+WindowFrameRestoration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+WindowFrameRestoration.swift"; sourceTree = ""; }; @@ -3322,8 +3333,15 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FE001006 /* GitStatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitStatusProvider.swift; sourceTree = ""; }; 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchCoordinator.swift; sourceTree = ""; }; 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchDocuments.swift; sourceTree = ""; }; + 8561B0018561B0018561B001 /* GlobalSearchInputOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchInputOwnershipTests.swift; sourceTree = ""; }; + 8561B0058561B0058561B005 /* GlobalSearchKeyEvent+QueryEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Search/GlobalSearchKeyEvent+QueryEditing.swift"; sourceTree = ""; }; 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/GlobalSearchPanelCaptureManager.swift; sourceTree = ""; }; + D5AAB5856FF095F610EB565B /* GlobalSearchShortcutBehaviorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchShortcutBehaviorTests.swift; sourceTree = ""; }; + 8561B0068561B0068561B006 /* GlobalSearchShortcutPersistencePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchShortcutPersistencePolicyTests.swift; sourceTree = ""; }; + 8561B0028561B0028561B002 /* GlobalSearchShortcutPriorityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchShortcutPriorityTests.swift; sourceTree = ""; }; + 8561B0048561B0048561B004 /* GlobalSearchShortcutSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchShortcutSettingsModelTests.swift; sourceTree = ""; }; 3865B0053865B0053865B005 /* GlobalSearchShortcutSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchShortcutSettingsTests.swift; sourceTree = ""; }; + 8561B0038561B0038561B003 /* GlobalSearchVisiblePopoverShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchVisiblePopoverShortcutTests.swift; sourceTree = ""; }; 5B11E5A100000000000000B2 /* GPUSpinner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/GPUSpinner.swift; sourceTree = ""; }; A6AC72010000000000000002 /* GPUSpinnerNSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/GPUSpinnerNSView.swift; sourceTree = ""; }; A6AC72020000000000000002 /* GPUSpinnerStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/GPUSpinnerStyle.swift; sourceTree = ""; }; @@ -3358,6 +3376,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE5A100000000000000002 /* KeyboardShortcutSavedLayoutTemplateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSavedLayoutTemplateTests.swift; sourceTree = ""; }; 875200000000000000000003 /* KeyboardShortcutSettings+ActionMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcutSettings+ActionMetadata.swift"; sourceTree = ""; }; 80410000000000000000000C /* KeyboardShortcutSettings+ActionVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcutSettings+ActionVisibility.swift"; sourceTree = ""; }; + 8561B0078561B0078561B007 /* KeyboardShortcutSettings+PersistedShortcutPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcutSettings+PersistedShortcutPolicy.swift"; sourceTree = ""; }; F7216002000000000000002 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift"; sourceTree = ""; }; A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = ""; }; A50012F9 /* KeyboardShortcutSettingsControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsControls.swift; sourceTree = ""; }; @@ -3370,6 +3389,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DEF0B30000000000000002 /* KeyboardShortcutSettingsFileStoreMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsFileStoreMigrationTests.swift; sourceTree = ""; }; E3337002E3337002E3337002 /* KeyboardShortcutSettingsFileStoreStartupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsFileStoreStartupTests.swift; sourceTree = ""; }; C1713003C1713003C1713003 /* KeyboardShortcutSettingsLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsLookup.swift; sourceTree = ""; }; + 8561C0118561C0118561C011 /* KeyboardShortcutSettingsObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsObserver.swift; sourceTree = ""; }; 468160000000000000000002 /* KeyboardShortcutSettingsObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettingsObserverTests.swift; sourceTree = ""; }; A17110000000000000000002 /* KeyboardShortcutSpaceKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSpaceKeyTests.swift; sourceTree = ""; }; C34670040000000000000002 /* KeyboardShortcutTestSettingsIsolation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutTestSettingsIsolation.swift; sourceTree = ""; }; @@ -5247,6 +5267,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 604500200000000000000002 /* WindowTitleTemplateContext.swift */, 604500100000000000000002 /* WindowTitleTemplate.swift */, AB7F2E9143904957AAA70726 /* ShortcutBareStartRouting.swift */, + D5AAB5856FF095F610EB565D /* AppDelegate+VisibleGlobalSearchShortcutRouting.swift */, E5C0F1A1E5C0F1A1E5C0F1A1 /* TerminalFindEscapeRouting.swift */, C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */, C0DE70510000000000000001 /* MenuBarProfilingLauncher.swift */, @@ -5276,6 +5297,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 875200000000000000000003 /* KeyboardShortcutSettings+ActionMetadata.swift */, 80410000000000000000000C /* KeyboardShortcutSettings+ActionVisibility.swift */, F7216002000000000000002 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift */, + 8561B0078561B0078561B007 /* KeyboardShortcutSettings+PersistedShortcutPolicy.swift */, C0DE7B330000000000000002 /* TextBoxAgentDetection.swift */, 7898A0020000000000000002 /* TextBoxGitIgnoreProbe.swift */, 5330B0725330B0725330B072 /* TextBoxInlineAttachmentCell.swift */, @@ -5320,6 +5342,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A50012F6 /* KeyboardShortcutRecorder.swift */, A50012F9 /* KeyboardShortcutSettingsControls.swift */, A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */, + 8561C0118561C0118561C011 /* KeyboardShortcutSettingsObserver.swift */, A7984AC10000000000000002 /* CmuxSettingsFileStore+Live.swift */, F7216003000000000000002 /* KeyboardShortcutSettingsFileStore+SectionParsers.swift */, A6AC73030000000000000002 /* KeyboardShortcutSettingsFileStore+SidebarIndicators.swift */, @@ -5816,6 +5839,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 3865B0073865B0073865B007 /* GlobalSearchDocuments.swift */, 3865B0083865B0083865B008 /* GlobalSearchPanelCaptureManager.swift */, 3865B0023865B0023865B002 /* GlobalSearchCoordinator.swift */, + 8561B0058561B0058561B005 /* GlobalSearchKeyEvent+QueryEditing.swift */, 3865B0033865B0033865B003 /* MenubarSearchPopover.swift */, A5001410 /* Panel.swift */, C54860060000000000000002 /* PanelStableSurfaceIdentity.swift */, @@ -6586,6 +6610,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C34670040000000000000002 /* KeyboardShortcutTestSettingsIsolation.swift */, F5996001A1B2C3D4E5F60002 /* AppDelegateFileExplorerShortcutRoutingTests.swift */, 3865B0053865B0053865B005 /* GlobalSearchShortcutSettingsTests.swift */, + 8561B0048561B0048561B004 /* GlobalSearchShortcutSettingsModelTests.swift */, + 8561B0018561B0018561B001 /* GlobalSearchInputOwnershipTests.swift */, + 8561B0028561B0028561B002 /* GlobalSearchShortcutPriorityTests.swift */, + 8561B0068561B0068561B006 /* GlobalSearchShortcutPersistencePolicyTests.swift */, + D5AAB5856FF095F610EB565B /* GlobalSearchShortcutBehaviorTests.swift */, + 8561B0038561B0038561B003 /* GlobalSearchVisiblePopoverShortcutTests.swift */, 65154DD251384EE0A2C8923A /* SystemWideHotkeyShortcutPolicyTests.swift */, E3309A0A /* AppDelegateEqualizeSplitsShortcutTests.swift */, F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */, @@ -7663,6 +7693,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6419A0036419A0036419A003 /* AppDelegate+ShortcutRoutingWindow.swift in Sources */, C51A740000000000000000A1 /* AppDelegate+SimulatorShortcutRouting.swift in Sources */, A79840020000000000000002 /* AppDelegate+SocketConfiguration.swift in Sources */, + D5AAB5856FF095F610EB565C /* AppDelegate+VisibleGlobalSearchShortcutRouting.swift in Sources */, DCDC1000000000000000C030 /* AppDelegate+WindowDock.swift in Sources */, 4805E1513D104D02B52A9807 /* AppDelegate+WindowFramePolicy.swift in Sources */, 895500000000000000000005 /* AppDelegate+WindowFrameRestoration.swift in Sources */, @@ -8086,6 +8117,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FE001106 /* GitStatusProvider.swift in Sources */, 3865A0023865A0023865A002 /* GlobalSearchCoordinator.swift in Sources */, 3865A0073865A0073865A007 /* GlobalSearchDocuments.swift in Sources */, + 8561A0058561A0058561A005 /* GlobalSearchKeyEvent+QueryEditing.swift in Sources */, 3865A0083865A0083865A008 /* GlobalSearchPanelCaptureManager.swift in Sources */, 5B11E5A100000000000000B1 /* GPUSpinner.swift in Sources */, A6AC72010000000000000001 /* GPUSpinnerNSView.swift in Sources */, @@ -8106,6 +8138,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A50012F7 /* KeyboardShortcutRecorder.swift in Sources */, 875200000000000000000013 /* KeyboardShortcutSettings+ActionMetadata.swift in Sources */, 80410000000000000000000B /* KeyboardShortcutSettings+ActionVisibility.swift in Sources */, + 8561A0078561A0078561A007 /* KeyboardShortcutSettings+PersistedShortcutPolicy.swift in Sources */, F7216002000000000000001 /* KeyboardShortcutSettings+SystemWideHotkeyConflicts.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, A50012F8 /* KeyboardShortcutSettingsControls.swift in Sources */, @@ -8115,6 +8148,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D5671301D5671301D5671301 /* KeyboardShortcutSettingsFileStore+TerminalScrollSpeed.swift in Sources */, A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */, C1713004C1713004C1713004 /* KeyboardShortcutSettingsLookup.swift in Sources */, + 8561C0128561C0128561C012 /* KeyboardShortcutSettingsObserver.swift in Sources */, 53750022A0B1C2D3E4F50022 /* MacAuthComposition.swift in Sources */, D1F0A00600000000000000C1 /* MacPairedMacBackupBody.swift in Sources */, D1F0A00700000000000000C1 /* MacPairedMacBackupOpWire.swift in Sources */, @@ -9438,7 +9472,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 816400000000000000000003 /* GhosttyScrollViewTests.swift in Sources */, C13519000000000000000003 /* GhosttyTerminalStartupEnvironmentTests.swift in Sources */, D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */, + 8561A0018561A0018561A001 /* GlobalSearchInputOwnershipTests.swift in Sources */, + D5AAB5856FF095F610EB565A /* GlobalSearchShortcutBehaviorTests.swift in Sources */, + 8561A0068561A0068561A006 /* GlobalSearchShortcutPersistencePolicyTests.swift in Sources */, + 8561A0028561A0028561A002 /* GlobalSearchShortcutPriorityTests.swift in Sources */, + 8561A0048561A0048561A004 /* GlobalSearchShortcutSettingsModelTests.swift in Sources */, 3865A0053865A0053865A005 /* GlobalSearchShortcutSettingsTests.swift in Sources */, + 8561A0038561A0038561A003 /* GlobalSearchVisiblePopoverShortcutTests.swift in Sources */, 4931A11B0000000000000002 /* HiddenRightSidebarContentMountingTests.swift in Sources */, B1F0C0040000000000000001 /* HostedInspectorDockControlScriptTests.swift in Sources */, 809100000000000000000001 /* HostSettingsShortcutNotificationTests.swift in Sources */, diff --git a/cmuxTests/GlobalSearchInputOwnershipTests.swift b/cmuxTests/GlobalSearchInputOwnershipTests.swift new file mode 100644 index 000000000000..4cf188959b42 --- /dev/null +++ b/cmuxTests/GlobalSearchInputOwnershipTests.swift @@ -0,0 +1,468 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension GlobalSearchShortcutBehaviorTests { + @MainActor @Suite final class GlobalSearchInputOwnershipTests { + private let originalSettingsFileStore: KeyboardShortcutSettingsFileStore + + init() { + originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-global-search-input-ownership-\(UUID().uuidString).json") + .path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + KeyboardShortcutSettings.resetAll() + } + + deinit { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + KeyboardShortcutSettings.resetAll() + } + + @Test func browserFocusModeOwnsGlobalSearchShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeBrowserHarness(appDelegate: appDelegate) + defer { closeWindow(harness.window, appDelegate: appDelegate) } + + #expect( + harness.panel.setBrowserFocusModeActive( + true, + reason: "globalSearchInputOwnershipTest", + focusWebView: false + ) + ) + let event = try makeKeyDownEvent( + key: "f", + modifiers: [.command, .option], + keyCode: 3, + windowNumber: harness.window.windowNumber + ) + + let handled = appDelegate.debugHandleCustomShortcut(event: event) + if handled { + appDelegate.toggleGlobalSearchPalette() + } + + #expect(!handled, "Browser focus mode must get the shortcut before app-scoped Global Search") +#else + Issue.record("Global Search input-ownership routing requires a DEBUG build") +#endif + } + + @Test func markedTextOwnsOptionOnlyGlobalSearchShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeBrowserHarness(appDelegate: appDelegate) + defer { closeWindow(harness.window, appDelegate: appDelegate) } + + let field = OmnibarNativeTextField(frame: NSRect(x: 8, y: 8, width: 240, height: 24)) + field.identifier = browserOmnibarTextFieldIdentifier + field.panelId = harness.panel.id + field.stringValue = "かな" + (harness.window.contentView?.superview ?? harness.window.contentView)?.addSubview(field) + BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: harness.panel.id) + defer { + BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: harness.panel.id) + field.removeFromSuperview() + } + + #expect(harness.window.makeFirstResponder(field)) + let fieldEditor = try #require(field.currentEditor() as? NSTextView) + fieldEditor.setMarkedText( + "かな", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + #expect(fieldEditor.hasMarkedText()) + NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: harness.panel.id) + + let shortcut = StoredShortcut( + key: "q", + command: false, + shift: false, + option: true, + control: false + ) + KeyboardShortcutSettings.setShortcut(shortcut, for: .globalSearch) + let event = try makeKeyDownEvent( + key: "q", + characters: "@", + modifiers: [.option], + keyCode: 12, + windowNumber: harness.window.windowNumber + ) + + let handled = appDelegate.debugHandleCustomShortcut(event: event) + if handled { + appDelegate.toggleGlobalSearchPalette() + } + + #expect(!handled, "An Option-only Global Search binding must yield to active IME marked text") +#else + Issue.record("Global Search input-ownership routing requires a DEBUG build") +#endif + } + + @Test func genericMarkedTextResponderOwnsOptionOnlyGlobalSearchShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let windowId = appDelegate.createMainWindow() + let window = try #require(findWindow(withId: windowId)) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + closeWindow(window, appDelegate: appDelegate) + } + + let textView = NSTextView(frame: NSRect(x: 8, y: 8, width: 240, height: 24)) + window.contentView?.addSubview(textView) + window.makeKeyAndOrderFront(nil) + #expect(window.makeFirstResponder(textView)) + textView.setMarkedText( + "かな", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + #expect(textView.hasMarkedText()) + + let shortcut = StoredShortcut( + key: "\r", + command: false, + shift: false, + option: true, + control: false + ) + KeyboardShortcutSettings.setShortcut(shortcut, for: .globalSearch) + let event = try makeKeyDownEvent( + key: "\r", + modifiers: [.option], + keyCode: 36, + windowNumber: window.windowNumber + ) + + #expect(!shortcutRoutingShouldBypassForPrintableOptionText(event: event)) + #expect( + !appDelegate.debugHandleCustomShortcut(event: event), + "Any marked NSTextInputClient must own non-Command input before Global Search routing" + ) +#else + Issue.record("Global Search input-ownership routing requires a DEBUG build") +#endif + } + + @Test func terminalEOFOwnsRemappedGlobalSearchShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let windowId = appDelegate.createMainWindow() + let window = try #require(findWindow(withId: windowId)) + let manager = try #require(appDelegate.tabManagerFor(windowId: windowId)) + let terminalPanel = try #require(manager.selectedTerminalPanel) + let contentView = try #require(window.contentView) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + closeWindow(window, appDelegate: appDelegate) + } + + let hostedView = terminalPanel.hostedView + if hostedView.superview !== contentView { + hostedView.removeFromSuperview() + hostedView.frame = contentView.bounds + contentView.addSubview(hostedView) + } + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + window.makeKeyAndOrderFront(nil) + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + #expect(window.makeFirstResponder(hostedView.surfaceView)) + #expect(window.firstResponder === hostedView.surfaceView) + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "d", + command: false, + shift: false, + option: false, + control: true + ), + for: .globalSearch + ) + let event = try makeKeyDownEvent( + key: "d", + characters: "\u{04}", + modifiers: [.control], + keyCode: 2, + windowNumber: window.windowNumber + ) + + #expect( + !appDelegate.debugHandleCustomShortcut(event: event), + "A Ctrl-D Global Search binding must yield to terminal EOF input" + ) + #expect(!GlobalSearchCoordinator.shared.isPaletteVisible()) +#else + Issue.record("Global Search input-ownership routing requires a DEBUG build") +#endif + } + + @Test func browserEditingOwnsRemappedGlobalSearchShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeBrowserHarness(appDelegate: appDelegate) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + closeWindow(harness.window, appDelegate: appDelegate) + } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "c", + command: true, + shift: false, + option: false, + control: false + ), + for: .globalSearch + ) + let event = try makeKeyDownEvent( + key: "c", + modifiers: [.command], + keyCode: 8, + windowNumber: harness.window.windowNumber + ) + + #expect( + !appDelegate.debugHandleCustomShortcut(event: event), + "A Cmd-C Global Search binding must yield to focused browser editing" + ) + #expect(!GlobalSearchCoordinator.shared.isPaletteVisible()) +#else + Issue.record("Global Search input-ownership routing requires a DEBUG build") +#endif + } + + @Test func browserEditingShortcutCompletesActiveGlobalSearchChord() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeBrowserHarness(appDelegate: appDelegate) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + appDelegate.debugResetShortcutRoutingStateForTesting() + closeWindow(harness.window, appDelegate: appDelegate) + } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "k", + command: true, + shift: false, + option: true, + control: false, + chordKey: "c", + chordCommand: true + ), + for: .globalSearch + ) + let prefixEvent = try makeKeyDownEvent( + key: "k", + modifiers: [.command, .option], + keyCode: 40, + windowNumber: harness.window.windowNumber + ) + let suffixEvent = try makeKeyDownEvent( + key: "c", + modifiers: [.command], + keyCode: 8, + windowNumber: harness.window.windowNumber + ) + + #expect(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + #expect(!GlobalSearchCoordinator.shared.isPaletteVisible()) + #expect( + appDelegate.debugHandleCustomShortcut(event: suffixEvent), + "Cmd-C must complete an already-active Global Search chord" + ) + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) +#else + Issue.record("Global Search input-ownership routing requires a DEBUG build") +#endif + } + + @Test func globalSearchRoutesWhileCommandPaletteIsEffective() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let windowId = appDelegate.createMainWindow() + let window = try #require(findWindow(withId: windowId)) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + appDelegate.setCommandPaletteVisible(false, for: window) + closeWindow(window, appDelegate: appDelegate) + } + + window.makeKeyAndOrderFront(nil) + appDelegate.setCommandPaletteVisible(true, for: window) + GlobalSearchCoordinator.shared.dismissPalette() + #expect(!GlobalSearchCoordinator.shared.isPaletteVisible()) + let event = try makeKeyDownEvent( + key: "f", + modifiers: [.command, .option], + keyCode: 3, + windowNumber: window.windowNumber + ) + + #expect(appDelegate.debugHandleCustomShortcut(event: event)) + #expect( + GlobalSearchCoordinator.shared.isPaletteVisible(), + "The foreground Global Search action must run before command-palette shortcut swallowing" + ) +#else + Issue.record("Global Search command-palette routing requires a DEBUG build") +#endif + } + + @Test func globalSearchChordRoutesWhileCommandPaletteIsEffective() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let windowId = appDelegate.createMainWindow() + let window = try #require(findWindow(withId: windowId)) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + appDelegate.setCommandPaletteVisible(false, for: window) + appDelegate.debugResetShortcutRoutingStateForTesting() + closeWindow(window, appDelegate: appDelegate) + } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "k", + command: true, + shift: false, + option: true, + control: false, + chordKey: "f" + ), + for: .globalSearch + ) + window.makeKeyAndOrderFront(nil) + appDelegate.setCommandPaletteVisible(true, for: window) + GlobalSearchCoordinator.shared.dismissPalette() + let prefixEvent = try makeKeyDownEvent( + key: "k", + modifiers: [.command, .option], + keyCode: 40, + windowNumber: window.windowNumber + ) + let suffixEvent = try makeKeyDownEvent( + key: "f", + modifiers: [], + keyCode: 3, + windowNumber: window.windowNumber + ) + + #expect(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + #expect(!GlobalSearchCoordinator.shared.isPaletteVisible()) + #expect(appDelegate.debugHandleCustomShortcut(event: suffixEvent)) + #expect( + GlobalSearchCoordinator.shared.isPaletteVisible(), + "A configured Global Search chord must arm before command-palette shortcut swallowing" + ) +#else + Issue.record("Global Search command-palette routing requires a DEBUG build") +#endif + } + + private func makeBrowserHarness( + appDelegate: AppDelegate + ) throws -> (window: NSWindow, panel: BrowserPanel, webView: CmuxWebView) { + let windowId = appDelegate.createMainWindow() + guard let window = findWindow(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + let browserURL = URL(string: "data:text/html;base64,PGh0bWw+PGJvZHk+Zm9jdXM8L2JvZHk+PC9odG1sPg=="), + let browserPanelId = manager.openBrowser( + inWorkspace: workspace.id, + url: browserURL, + preferSplitRight: true + ), + let browserPanel = manager.selectedWorkspace?.browserPanel(for: browserPanelId) + ?? workspace.browserPanel(for: browserPanelId), + let webView = browserPanel.webView as? CmuxWebView else { + if let window = findWindow(withId: windowId) { + closeWindow(window, appDelegate: appDelegate) + } + throw TestHarnessError.browserHarnessUnavailable + } + + workspace.focusPanel(browserPanel.id) + if webView.cmuxBrowserViewportAttachmentSuperview == nil, + let contentView = window.contentView { + let presentationView = webView.cmuxBrowserViewportPresentationView + contentView.addSubview(presentationView) + webView.cmuxApplyBrowserViewportLayout(in: contentView.bounds) + } + window.makeKeyAndOrderFront(nil) + guard window.makeFirstResponder(webView) else { + closeWindow(window, appDelegate: appDelegate) + throw TestHarnessError.browserFocusUnavailable + } + return (window, browserPanel, webView) + } + + private func makeKeyDownEvent( + key: String, + characters: String? = nil, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int + ) throws -> NSEvent { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: characters ?? key, + charactersIgnoringModifiers: key, + isARepeat: false, + keyCode: keyCode + ) else { + throw TestHarnessError.eventUnavailable + } + return event + } + + private func findWindow(withId windowId: UUID) -> NSWindow? { + let identifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) + } + + private func closeWindow(_ window: NSWindow, appDelegate: AppDelegate) { +#if DEBUG + let originalConfirmationHandler = appDelegate.debugCloseMainWindowConfirmationHandler + appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } + defer { appDelegate.debugCloseMainWindowConfirmationHandler = originalConfirmationHandler } +#endif + window.animationBehavior = .none + window.orderOut(nil) + window.close() + RunLoop.main.run(until: Date.now.addingTimeInterval(0.05)) + } + + private enum TestHarnessError: Error { + case browserHarnessUnavailable + case browserFocusUnavailable + case eventUnavailable + } + } +} diff --git a/cmuxTests/GlobalSearchShortcutBehaviorTests.swift b/cmuxTests/GlobalSearchShortcutBehaviorTests.swift new file mode 100644 index 000000000000..175764429c4a --- /dev/null +++ b/cmuxTests/GlobalSearchShortcutBehaviorTests.swift @@ -0,0 +1,362 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite(.serialized) +struct GlobalSearchShortcutBehaviorTests {} + +extension GlobalSearchShortcutBehaviorTests { + @MainActor @Suite final class GlobalSearchLocalMonitorChainTests { + private let originalSettingsFileStore: KeyboardShortcutSettingsFileStore + + init() { + originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-global-search-monitor-chain-\(UUID().uuidString).json") + .path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + KeyboardShortcutSettings.resetAll() + } + + deinit { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + KeyboardShortcutSettings.resetAll() + } + + @Test func visibleSearchClosesForRemappedCommandShortcutThroughLocalMonitorChain() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let window = try makeMainWindow(appDelegate: appDelegate) + defer { closeWindow(window, appDelegate: appDelegate) } + + let shortcut = StoredShortcut( + key: "j", + command: true, + shift: false, + option: false, + control: false + ) + KeyboardShortcutSettings.setShortcut(shortcut, for: .globalSearch) + #expect( + KeyboardShortcutSettings.shortcut(for: .globalSearch) == shortcut, + "The monitor-chain fixture must install a valid, unclaimed Command shortcut" + ) + appDelegate.toggleGlobalSearchPalette() + let popoverWindow = try #require( + waitForSearchPopoverWindow(excluding: window), + "The real Search popover and its local key monitor must be active" + ) + + NSApp.sendEvent( + try makeKeyDownEvent( + key: "j", + modifiers: [.command], + keyCode: 38, + windowNumber: popoverWindow.windowNumber + ) + ) + + #expect( + waitUntilGlobalSearchCloses(), + "The popover monitor must route the configured toggle before consuming generic Command keys" + ) +#else + Issue.record("Global Search local-monitor routing requires a DEBUG app-host build") +#endif + } + + @Test func visibleSearchClosesForRemappedChordThroughLocalMonitorChain() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let window = try makeMainWindow(appDelegate: appDelegate) + defer { closeWindow(window, appDelegate: appDelegate) } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "k", + command: true, + shift: false, + option: false, + control: false, + chordKey: "g" + ), + for: .globalSearch + ) + appDelegate.toggleGlobalSearchPalette() + let popoverWindow = try #require( + waitForSearchPopoverWindow(excluding: window), + "The real Search popover and its local key monitor must be active" + ) + + NSApp.sendEvent( + try makeKeyDownEvent( + key: "k", + modifiers: [.command], + keyCode: 40, + windowNumber: popoverWindow.windowNumber + ) + ) + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + NSApp.sendEvent( + try makeKeyDownEvent( + key: "g", + modifiers: [], + keyCode: 5, + windowNumber: popoverWindow.windowNumber + ) + ) + + #expect( + waitUntilGlobalSearchCloses(), + "The popover monitor must let the shared router arm and complete Global Search chords" + ) +#else + Issue.record("Global Search local-monitor routing requires a DEBUG app-host build") +#endif + } + + @Test func visibleSearchChordEditingSuffixCompletesThroughLocalMonitorChain() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let window = try makeMainWindow(appDelegate: appDelegate) + defer { closeWindow(window, appDelegate: appDelegate) } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "k", + command: true, + shift: false, + option: false, + control: false, + chordKey: "c", + chordCommand: true + ), + for: .globalSearch + ) + appDelegate.toggleGlobalSearchPalette() + let popoverWindow = try #require( + waitForSearchPopoverWindow(excluding: window), + "The real Search popover and its local key monitor must be active" + ) + + NSApp.sendEvent( + try makeKeyDownEvent( + key: "k", + modifiers: [.command], + keyCode: 40, + windowNumber: popoverWindow.windowNumber + ) + ) + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + NSApp.sendEvent( + try makeKeyDownEvent( + key: "c", + modifiers: [.command], + keyCode: 8, + windowNumber: popoverWindow.windowNumber + ) + ) + + #expect( + waitUntilGlobalSearchCloses(), + "An editing-key suffix must complete an already-active Global Search chord" + ) +#else + Issue.record("Global Search local-monitor routing requires a DEBUG app-host build") +#endif + } + + @Test func visibleSearchCompletesEditingSuffixFromPromotedChordState() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let window = try makeMainWindow(appDelegate: appDelegate) + defer { closeWindow(window, appDelegate: appDelegate) } + + let shortcut = StoredShortcut( + key: "k", + command: true, + shift: false, + option: false, + control: false, + chordKey: "c", + chordCommand: true + ) + KeyboardShortcutSettings.setShortcut(shortcut, for: .globalSearch) + appDelegate.toggleGlobalSearchPalette() + let popoverWindow = try #require( + waitForSearchPopoverWindow(excluding: window), + "The real Search popover and its local key monitor must be active" + ) + appDelegate.activeConfiguredShortcutChordPrefixForCurrentEvent = + shortcut.firstStroke + + let route = appDelegate.routeVisibleGlobalSearchShortcutFromLocalMonitor( + try makeKeyDownEvent( + key: "c", + modifiers: [.command], + keyCode: 8, + windowNumber: popoverWindow.windowNumber + ) + ) + + guard case .handled = route else { + Issue.record( + "An editing-key suffix must complete a promoted Global Search chord" + ) + return + } + #expect(waitUntilGlobalSearchCloses()) +#else + Issue.record("Global Search local-monitor routing requires a DEBUG app-host build") +#endif + } + + @Test func unrelatedChordSuffixPreservesPendingPrefixForDownstreamMonitor() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let window = try makeMainWindow(appDelegate: appDelegate) + defer { closeWindow(window, appDelegate: appDelegate) } + + let shortcut = StoredShortcut( + key: "k", + command: true, + shift: false, + option: false, + control: false, + chordKey: "g" + ) + KeyboardShortcutSettings.setShortcut(shortcut, for: .globalSearch) + appDelegate.toggleGlobalSearchPalette() + let popoverWindow = try #require( + waitForSearchPopoverWindow(excluding: window), + "The real Search popover and its local key monitor must be active" + ) + let unrelatedSuffixEvent = try makeKeyDownEvent( + key: "s", + modifiers: [], + keyCode: 1, + windowNumber: popoverWindow.windowNumber + ) + let chordWindowNumber = appDelegate.configuredShortcutChordWindowNumber( + for: unrelatedSuffixEvent + ) + appDelegate.pendingConfiguredShortcutChord = AppDelegate.PendingConfiguredShortcutChord( + firstStroke: shortcut.firstStroke, + windowNumber: chordWindowNumber + ) + + let route = appDelegate.routeVisibleGlobalSearchShortcutFromLocalMonitor( + unrelatedSuffixEvent + ) + + guard case .notApplicable = route else { + Issue.record("An unrelated suffix must continue to the downstream shortcut monitor") + return + } + #expect( + appDelegate.pendingConfiguredShortcutChord?.firstStroke == shortcut.firstStroke, + "The Search popover monitor must not destroy another chord sharing the prefix" + ) + #expect( + appDelegate.pendingConfiguredShortcutChord?.windowNumber == chordWindowNumber + ) + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) +#else + Issue.record("Global Search local-monitor routing requires a DEBUG app-host build") +#endif + } + + private func makeMainWindow(appDelegate: AppDelegate) throws -> NSWindow { + let windowId = appDelegate.createMainWindow() + let identifier = "cmux.main.\(windowId.uuidString)" + let window = try #require( + NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) + ) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + return window + } + + private func waitForSearchPopoverWindow( + excluding mainWindow: NSWindow, + timeout: TimeInterval = 2 + ) -> NSWindow? { + let deadline = Date.now.addingTimeInterval(timeout) + repeat { + if let window = NSApp.windows.first(where: { + $0 !== mainWindow + && $0.isVisible + && $0.firstResponder is NSTextView + }) { + return window + } + _ = RunLoop.main.run( + mode: .default, + before: min(deadline, Date.now.addingTimeInterval(0.01)) + ) + } while Date.now < deadline + return nil + } + + private func makeKeyDownEvent( + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int + ) throws -> NSEvent { + try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: false, + keyCode: keyCode + ) + ) + } + + private func waitUntilGlobalSearchCloses(timeout: TimeInterval = 2) -> Bool { + let deadline = Date.now.addingTimeInterval(timeout) + repeat { + if !GlobalSearchCoordinator.shared.isPaletteVisible() { + return true + } + _ = RunLoop.main.run( + mode: .default, + before: min(deadline, Date.now.addingTimeInterval(0.01)) + ) + } while Date.now < deadline + return !GlobalSearchCoordinator.shared.isPaletteVisible() + } + + private func closeWindow(_ window: NSWindow, appDelegate: AppDelegate) { + GlobalSearchCoordinator.shared.dismissPalette() +#if DEBUG + appDelegate.debugResetShortcutRoutingStateForTesting() + let originalConfirmationHandler = appDelegate.debugCloseMainWindowConfirmationHandler + appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } + defer { appDelegate.debugCloseMainWindowConfirmationHandler = originalConfirmationHandler } +#endif + window.animationBehavior = .none + window.orderOut(nil) + window.close() + } + } +} diff --git a/cmuxTests/GlobalSearchShortcutPersistencePolicyTests.swift b/cmuxTests/GlobalSearchShortcutPersistencePolicyTests.swift new file mode 100644 index 000000000000..9306dcc99945 --- /dev/null +++ b/cmuxTests/GlobalSearchShortcutPersistencePolicyTests.swift @@ -0,0 +1,426 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension GlobalSearchShortcutBehaviorTests { + @MainActor @Suite final class GlobalSearchShortcutPersistencePolicyTests { + private let originalSettingsFileStore: KeyboardShortcutSettingsFileStore + private let savedGlobalSearchDefault: Any? + private let savedShowHideDefault: Any? + + init() { + let defaults = UserDefaults.standard + savedGlobalSearchDefault = defaults.object( + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) + savedShowHideDefault = defaults.object( + forKey: KeyboardShortcutSettings.Action.showHideAllWindows.defaultsKey + ) + originalSettingsFileStore = KeyboardShortcutSettings.installIsolatedTestFileStore( + prefix: "cmux-global-search-persistence-policy" + ) + Self.clearShortcutDefaults() + } + + deinit { + Self.clearShortcutDefaults() + Self.restore( + savedGlobalSearchDefault, + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) + Self.restore( + savedShowHideDefault, + forKey: KeyboardShortcutSettings.Action.showHideAllWindows.defaultsKey + ) + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + } + + @Test func directSetterRejectsBareGlobalSearchShortcut() { + let bareSpace = StoredShortcut( + key: "space", + command: false, + shift: false, + option: false, + control: false + ) + + KeyboardShortcutSettings.setShortcut(bareSpace, for: .globalSearch) + + #expect( + UserDefaults.standard.object( + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) == nil + ) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == defaultGlobalSearchShortcut) + } + + @Test func rawUserDefaultsBareGlobalSearchShortcutIsNotEffective() throws { + let bareSpace = StoredShortcut( + key: "space", + command: false, + shift: false, + option: false, + control: false + ) + let data = try JSONEncoder().encode(bareSpace) + UserDefaults.standard.set( + data, + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) + + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == defaultGlobalSearchShortcut) + } + + @Test func settingsFileStringRejectsBareGlobalSearchShortcut() throws { + let fixture = try makeSettingsFileStore( + """ + { + "shortcuts": { + "globalSearch": "space" + } + } + """ + ) + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + + #expect(fixture.store.override(for: .globalSearch) == nil) + } + + @Test func settingsFileObjectRejectsBareGlobalSearchShortcut() throws { + let fixture = try makeSettingsFileStore( + """ + { + "shortcuts": { + "bindings": { + "globalSearch": { + "first": { + "key": "space", + "command": false, + "shift": false, + "option": false, + "control": false + } + } + } + } + } + """ + ) + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + + #expect(fixture.store.override(for: .globalSearch) == nil) + } + + @Test func invalidManagedGlobalSearchShadowsLegacyShortcut() throws { + let legacyShortcut = StoredShortcut( + key: "j", + command: true, + shift: true, + option: false, + control: false + ) + UserDefaults.standard.set( + try JSONEncoder().encode(legacyShortcut), + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) + let fixture = try makeSettingsFileStore( + """ + { + "shortcuts": { + "bindings": { + "globalSearch": { + "first": { + "key": "media.playPause", + "command": true, + "shift": false, + "option": false, + "control": false + } + } + } + } + } + """ + ) + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + KeyboardShortcutSettings.settingsFileStore = fixture.store + + #expect(fixture.store.override(for: .globalSearch) == nil) + #expect(fixture.store.isManagedByFile(.globalSearch)) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == defaultGlobalSearchShortcut) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) != legacyShortcut) + } + + @Test func invalidManagedShowHideFailsClosed() throws { + let fixture = try makeSettingsFileStore( + """ + { + "shortcuts": { + "bindings": { + "showHideAllWindows": "j" + } + } + } + """ + ) + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + KeyboardShortcutSettings.settingsFileStore = fixture.store + + #expect(fixture.store.override(for: .showHideAllWindows) == nil) + #expect(fixture.store.isManagedByFile(.showHideAllWindows)) + #expect(SystemWideHotkeySettings.shortcut() == .unbound) + #expect(KeyboardShortcutSettings.shortcut(for: .showHideAllWindows) == .unbound) + } + + @Test func invalidPrimaryGlobalSearchShadowsFallbackFileShortcut() throws { + let fallbackShortcut = StoredShortcut( + key: "j", + command: true, + shift: true, + option: false, + control: false + ) + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-global-search-primary-fallback-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let primaryURL = directoryURL.appendingPathComponent("cmux.json") + let fallbackURL = directoryURL.appendingPathComponent("settings.json") + try """ + { + "shortcuts": { + "bindings": { + "globalSearch": "media.playPause" + } + } + } + """.write(to: primaryURL, atomically: true, encoding: .utf8) + try """ + { + "shortcuts": { + "bindings": { + "globalSearch": "cmd+shift+j" + } + } + } + """.write(to: fallbackURL, atomically: true, encoding: .utf8) + + let store = KeyboardShortcutSettingsFileStore( + primaryPath: primaryURL.path, + fallbackPath: fallbackURL.path, + startWatching: false + ) + KeyboardShortcutSettings.settingsFileStore = store + + #expect(store.override(for: .globalSearch) == nil) + #expect(store.isManagedByFile(.globalSearch)) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == defaultGlobalSearchShortcut) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) != fallbackShortcut) + } + + @Test func removingInvalidManagedGlobalSearchReactivatesLegacyAndNotifies() async throws { + let legacyShortcut = StoredShortcut( + key: "j", + command: true, + shift: true, + option: false, + control: false + ) + UserDefaults.standard.set( + try JSONEncoder().encode(legacyShortcut), + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-global-search-remove-invalid-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json") + try """ + { + "shortcuts": { + "bindings": { + "globalSearch": "media.playPause" + } + } + } + """.write(to: settingsFileURL, atomically: true, encoding: .utf8) + let notificationCenter = NotificationCenter() + let store = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + notificationCenter: notificationCenter, + startWatching: false + ) + KeyboardShortcutSettings.settingsFileStore = store + + #expect(store.isManagedByFile(.globalSearch)) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == defaultGlobalSearchShortcut) + + try await confirmation("managed shortcut removal notification") { confirm in + let token = notificationCenter.addObserver( + forName: KeyboardShortcutSettings.didChangeNotification, + object: nil, + queue: nil + ) { _ in + confirm() + } + defer { notificationCenter.removeObserver(token) } + try "{}".write(to: settingsFileURL, atomically: true, encoding: .utf8) + #expect(store.reload()) + } + + #expect(!store.isManagedByFile(.globalSearch)) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == legacyShortcut) + } + + @Test func directSetterRejectsShowHideCollision() { + let collision = collisionShortcut + SystemWideHotkeySettings.setShortcut(collision) + + KeyboardShortcutSettings.setShortcut(collision, for: .globalSearch) + + #expect( + UserDefaults.standard.object( + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) == nil + ) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == defaultGlobalSearchShortcut) + } + + @Test func rawUserDefaultsShowHideCollisionIsNotEffective() throws { + let collision = collisionShortcut + SystemWideHotkeySettings.setShortcut(collision) + let data = try JSONEncoder().encode(collision) + UserDefaults.standard.set( + data, + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) + + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == defaultGlobalSearchShortcut) + } + + @Test func settingsFileShowHideCollisionIsNotEffective() throws { + let collision = collisionShortcut + SystemWideHotkeySettings.setShortcut(collision) + let fixture = try makeSettingsFileStore( + """ + { + "shortcuts": { + "globalSearch": "cmd+opt+ctrl+g" + } + } + """ + ) + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + KeyboardShortcutSettings.settingsFileStore = fixture.store + + #expect(fixture.store.override(for: .globalSearch) == collision) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == defaultGlobalSearchShortcut) + } + + @Test func defaultGlobalSearchShortcutIsUnboundWhenRawShowHideCollides() throws { + let defaultShortcut = defaultGlobalSearchShortcut + let data = try JSONEncoder().encode(defaultShortcut) + UserDefaults.standard.set( + data, + forKey: KeyboardShortcutSettings.Action.showHideAllWindows.defaultsKey + ) + + #expect(SystemWideHotkeySettings.shortcut() == defaultShortcut) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == .unbound) + } + + @Test func invalidShowHideChordDoesNotSuppressGlobalSearchPrefix() throws { + let globalSearchShortcut = collisionShortcut + var invalidShowHideChord = globalSearchShortcut + invalidShowHideChord.chordKey = "x" + + let showHideData = try JSONEncoder().encode(invalidShowHideChord) + UserDefaults.standard.set( + showHideData, + forKey: KeyboardShortcutSettings.Action.showHideAllWindows.defaultsKey + ) + KeyboardShortcutSettings.setShortcut(globalSearchShortcut, for: .globalSearch) + + #expect(invalidShowHideChord.carbonHotKeyRegistration == nil) + #expect(SystemWideHotkeySettings.shortcut() == .unbound) + #expect(KeyboardShortcutSettings.shortcut(for: .showHideAllWindows) == .unbound) + #expect(KeyboardShortcutSettings.shortcut(for: .globalSearch) == globalSearchShortcut) + } + + private var defaultGlobalSearchShortcut: StoredShortcut { + KeyboardShortcutSettings.Action.globalSearch.defaultShortcut + } + + private var collisionShortcut: StoredShortcut { + StoredShortcut( + key: "g", + command: true, + shift: false, + option: true, + control: true + ) + } + + private func makeSettingsFileStore( + _ json: String + ) throws -> (store: KeyboardShortcutSettingsFileStore, directoryURL: URL) { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent( + "cmux-global-search-persistence-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json") + try json.write(to: settingsFileURL, atomically: true, encoding: .utf8) + return ( + KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + startWatching: false + ), + directoryURL + ) + } + + private nonisolated static func clearShortcutDefaults() { + let defaults = UserDefaults.standard + defaults.removeObject( + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) + defaults.removeObject( + forKey: KeyboardShortcutSettings.Action.showHideAllWindows.defaultsKey + ) + } + + private nonisolated static func restore(_ value: Any?, forKey key: String) { + if let value { + UserDefaults.standard.set(value, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + } +} diff --git a/cmuxTests/GlobalSearchShortcutPriorityTests.swift b/cmuxTests/GlobalSearchShortcutPriorityTests.swift new file mode 100644 index 000000000000..9c062469cd30 --- /dev/null +++ b/cmuxTests/GlobalSearchShortcutPriorityTests.swift @@ -0,0 +1,479 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension GlobalSearchShortcutBehaviorTests { + @MainActor @Suite final class GlobalSearchShortcutPriorityTests { + private let originalSettingsFileStore: KeyboardShortcutSettingsFileStore + + init() { + originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-global-search-priority-\(UUID().uuidString).json") + .path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + KeyboardShortcutSettings.resetAll() + } + + deinit { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + KeyboardShortcutSettings.resetAll() + } + + @Test func rightSidebarModeOwnsOverlappingGlobalSearchShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let windowId = appDelegate.createMainWindow() + let window = try #require(findWindow(withId: windowId)) + let fileExplorerState = try #require(appDelegate.fileExplorerState) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + appDelegate.debugResetShortcutRoutingStateForTesting() + closeWindow(window, appDelegate: appDelegate) + } + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + fileExplorerState.mode = .sessions + appDelegate.noteRightSidebarKeyboardFocusIntent(mode: .sessions, in: window) + _ = window.makeFirstResponder(nil) + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "1", + command: false, + shift: false, + option: false, + control: true + ), + for: .globalSearch + ) + appDelegate.debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: false) + + let event = try makeKeyEvent( + type: .keyDown, + key: "1", + modifiers: [.control], + keyCode: 18, + windowNumber: window.windowNumber + ) + + #expect(appDelegate.debugHandleCustomShortcut(event: event)) + #expect( + fileExplorerState.mode == .files, + "A focused right-sidebar mode shortcut must run before overlapping app shortcuts" + ) + #expect( + !GlobalSearchCoordinator.shared.isPaletteVisible(), + "Global Search must not steal a stroke owned by the focused right sidebar" + ) +#else + Issue.record("Global Search shortcut-priority routing requires a DEBUG build") +#endif + } + + @Test func omnibarControlNOwnsOverlappingGlobalSearchChordPrefix() throws { + try assertOmnibarOwnsGlobalSearchChordPrefix( + key: "n", + keyCode: 45, + expectedDelta: 1 + ) + } + + @Test func omnibarControlPOwnsOverlappingGlobalSearchChordPrefix() throws { + try assertOmnibarOwnsGlobalSearchChordPrefix( + key: "p", + keyCode: 35, + expectedDelta: -1 + ) + } + + @Test func activeGlobalSearchChordOwnsControlNSuffixOverOmnibar() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeOmnibarHarness(appDelegate: appDelegate) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + appDelegate.debugResetShortcutRoutingStateForTesting() + closeWindow(harness.window, appDelegate: appDelegate) + } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "k", + command: true, + shift: false, + option: true, + control: false, + chordKey: "n", + chordCommand: false, + chordShift: false, + chordOption: false, + chordControl: true + ), + for: .globalSearch + ) + appDelegate.debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: false) + + let prefixEvent = try makeKeyEvent( + type: .keyDown, + key: "k", + modifiers: [.command, .option], + keyCode: 40, + windowNumber: harness.window.windowNumber + ) + let suffixEvent = try makeKeyEvent( + type: .keyDown, + key: "n", + modifiers: [.control], + keyCode: 45, + windowNumber: harness.window.windowNumber + ) + + #expect(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + #expect(!GlobalSearchCoordinator.shared.isPaletteVisible()) + #expect( + appDelegate.debugHandleCustomShortcut(event: suffixEvent), + "Focused-input navigation must not steal the suffix of an already-active chord" + ) + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) +#else + Issue.record("Global Search shortcut-priority routing requires a DEBUG build") +#endif + } + + @Test func browserEditingOwnsUnarmedGlobalSearchChordPrefix() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeOmnibarHarness(appDelegate: appDelegate) + let webView = try #require(harness.panel.webView as? CmuxWebView) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + appDelegate.debugResetShortcutRoutingStateForTesting() + closeWindow(harness.window, appDelegate: appDelegate) + } + + if webView.cmuxBrowserViewportAttachmentSuperview == nil, + let contentView = harness.window.contentView { + let presentationView = webView.cmuxBrowserViewportPresentationView + contentView.addSubview(presentationView) + webView.cmuxApplyBrowserViewportLayout(in: contentView.bounds) + } + BrowserOmnibarNativeFieldRegistry.shared.unregister(harness.field, panelId: harness.panel.id) + harness.field.removeFromSuperview() + webView.allowsFirstResponderAcquisition = true + #expect(harness.window.makeFirstResponder(webView)) + #expect(harness.window.firstResponder === webView) + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "c", + command: true, + shift: false, + option: false, + control: false, + chordKey: "g", + chordCommand: true + ), + for: .globalSearch + ) + appDelegate.debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: false) + + let prefixEvent = try makeKeyEvent( + type: .keyDown, + key: "c", + modifiers: [.command], + keyCode: 8, + windowNumber: harness.window.windowNumber + ) + let suffixEvent = try makeKeyEvent( + type: .keyDown, + key: "g", + modifiers: [.command], + keyCode: 5, + windowNumber: harness.window.windowNumber + ) + + #expect( + !appDelegate.debugHandleCustomShortcut(event: prefixEvent), + "Focused browser editing must own Cmd-C before an unarmed Global Search chord" + ) + _ = appDelegate.debugHandleCustomShortcut(event: suffixEvent) + #expect( + !GlobalSearchCoordinator.shared.isPaletteVisible(), + "A browser-owned editing command must not leave a Global Search chord armed" + ) +#else + Issue.record("Global Search shortcut-priority routing requires a DEBUG build") +#endif + } + + @Test func commandPaletteEditingOwnsOverlappingGlobalSearchShortcut() throws { + try assertCommandPaletteEditingOwnsUnarmedGlobalSearchBinding( + StoredShortcut( + key: "c", + command: true, + shift: false, + option: false, + control: false + ) + ) + } + + @Test func commandPaletteEditingOwnsOverlappingGlobalSearchChordPrefix() throws { + try assertCommandPaletteEditingOwnsUnarmedGlobalSearchBinding( + StoredShortcut( + key: "c", + command: true, + shift: false, + option: false, + control: false, + chordKey: "g", + chordCommand: true + ) + ) + } + + private func assertCommandPaletteEditingOwnsUnarmedGlobalSearchBinding( + _ shortcut: StoredShortcut + ) throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let windowId = appDelegate.createMainWindow() + let window = try #require(findWindow(withId: windowId)) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + appDelegate.setCommandPaletteVisible(false, for: window) + appDelegate.debugResetShortcutRoutingStateForTesting() + closeWindow(window, appDelegate: appDelegate) + } + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + appDelegate.setCommandPaletteVisible(true, for: window) + #expect(appDelegate.isCommandPaletteEffectivelyVisible(for: window)) + + KeyboardShortcutSettings.setShortcut(shortcut, for: .globalSearch) + appDelegate.debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: false) + + let prefixEvent = try makeKeyEvent( + type: .keyDown, + key: "c", + modifiers: [.command], + keyCode: 8, + windowNumber: window.windowNumber + ) + #expect( + !appDelegate.debugHandleCustomShortcut(event: prefixEvent), + "Command-palette editing must own Cmd-C before an unarmed Global Search binding" + ) + + if shortcut.hasChord { + let suffixEvent = try makeKeyEvent( + type: .keyDown, + key: "g", + modifiers: [.command], + keyCode: 5, + windowNumber: window.windowNumber + ) + _ = appDelegate.debugHandleCustomShortcut(event: suffixEvent) + } + #expect( + !GlobalSearchCoordinator.shared.isPaletteVisible(), + "A command-palette editing command must not toggle or arm Global Search" + ) +#else + Issue.record("Global Search shortcut-priority routing requires a DEBUG build") +#endif + } + + private func assertOmnibarOwnsGlobalSearchChordPrefix( + key: String, + keyCode: UInt16, + expectedDelta: Int + ) throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeOmnibarHarness(appDelegate: appDelegate) + defer { + GlobalSearchCoordinator.shared.dismissPalette() + appDelegate.debugResetShortcutRoutingStateForTesting() + closeWindow(harness.window, appDelegate: appDelegate) + } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: key, + command: false, + shift: false, + option: false, + control: true, + chordKey: "g", + chordCommand: true, + chordShift: false, + chordOption: false, + chordControl: false + ), + for: .globalSearch + ) + appDelegate.debugResetShortcutRoutingStateForTesting(clearFocusedWindowOverride: false) + + let panelId = harness.panel.id + var observedDeltas: [Int] = [] + let token = NotificationCenter.default.addObserver( + forName: .browserMoveOmnibarSelection, + object: nil, + queue: nil + ) { notification in + guard notification.object as? UUID == panelId, + let delta = notification.userInfo?["delta"] as? Int else { + return + } + observedDeltas.append(delta) + } + defer { NotificationCenter.default.removeObserver(token) } + + let prefixEvent = try makeKeyEvent( + type: .keyDown, + key: key, + modifiers: [.control], + keyCode: keyCode, + windowNumber: harness.window.windowNumber + ) + let keyUpEvent = try makeKeyEvent( + type: .keyUp, + key: key, + modifiers: [.control], + keyCode: keyCode, + windowNumber: harness.window.windowNumber + ) + let suffixEvent = try makeKeyEvent( + type: .keyDown, + key: "g", + modifiers: [.command], + keyCode: 5, + windowNumber: harness.window.windowNumber + ) + + #expect(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + #expect( + observedDeltas == [expectedDelta], + "Ctrl-\(key.uppercased()) must navigate the focused omnibar instead of arming Global Search" + ) + _ = appDelegate.debugHandleShortcutMonitorEvent(event: keyUpEvent) + _ = appDelegate.debugHandleCustomShortcut(event: suffixEvent) + #expect( + !GlobalSearchCoordinator.shared.isPaletteVisible(), + "An omnibar-owned prefix must not leave a Global Search chord armed" + ) +#else + Issue.record("Global Search shortcut-priority routing requires a DEBUG build") +#endif + } + + private func makeOmnibarHarness( + appDelegate: AppDelegate + ) throws -> (window: NSWindow, panel: BrowserPanel, field: OmnibarNativeTextField) { + let windowId = appDelegate.createMainWindow() + guard let window = findWindow(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + let browserPanelId = manager.openBrowser( + inWorkspace: workspace.id, + url: URL(string: "about:blank"), + preferSplitRight: true + ), + let panel = manager.selectedWorkspace?.browserPanel(for: browserPanelId) + ?? workspace.browserPanel(for: browserPanelId) else { + if let window = findWindow(withId: windowId) { + closeWindow(window, appDelegate: appDelegate) + } + throw TestHarnessError.browserHarnessUnavailable + } + + let field = OmnibarNativeTextField(frame: NSRect(x: 8, y: 8, width: 240, height: 24)) + field.identifier = browserOmnibarTextFieldIdentifier + field.panelId = panel.id + field.stringValue = "example" + window.contentView?.addSubview(field) + BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: panel.id) + + workspace.focusPanel(panel.id) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + guard window.makeFirstResponder(field) else { + BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: panel.id) + field.removeFromSuperview() + closeWindow(window, appDelegate: appDelegate) + throw TestHarnessError.omnibarFocusUnavailable + } + NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: panel.id) + return (window, panel, field) + } + + private func makeKeyEvent( + type: NSEvent.EventType, + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int + ) throws -> NSEvent { + guard let event = NSEvent.keyEvent( + with: type, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: false, + keyCode: keyCode + ) else { + throw TestHarnessError.eventUnavailable + } + return event + } + + private func findWindow(withId windowId: UUID) -> NSWindow? { + let identifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) + } + + private func closeWindow(_ window: NSWindow, appDelegate: AppDelegate) { +#if DEBUG + let originalConfirmationHandler = appDelegate.debugCloseMainWindowConfirmationHandler + appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } + defer { appDelegate.debugCloseMainWindowConfirmationHandler = originalConfirmationHandler } +#endif + window.contentView?.subviews + .compactMap { $0 as? OmnibarNativeTextField } + .forEach { field in + if let panelId = field.panelId { + BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: panelId) + } + field.removeFromSuperview() + } + window.animationBehavior = .none + window.orderOut(nil) + window.close() + RunLoop.main.run(until: Date.now.addingTimeInterval(0.05)) + } + + private enum TestHarnessError: Error { + case browserHarnessUnavailable + case omnibarFocusUnavailable + case eventUnavailable + } + } +} diff --git a/cmuxTests/GlobalSearchShortcutSettingsModelTests.swift b/cmuxTests/GlobalSearchShortcutSettingsModelTests.swift new file mode 100644 index 000000000000..d063f00e6d60 --- /dev/null +++ b/cmuxTests/GlobalSearchShortcutSettingsModelTests.swift @@ -0,0 +1,123 @@ +import CmuxSettings +import Foundation +import Testing +@testable import CmuxSettingsUI + +@MainActor +@Suite(.serialized) +final class GlobalSearchShortcutSettingsModelTests { + @Test func distinctChordSuffixesCanShareAPrefix() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = JSONConfigStore(fileURL: directory.appendingPathComponent("cmux.json")) + let catalog = SettingCatalog() + let model = ShortcutListModel( + jsonStore: store, + catalog: catalog, + errorLog: SettingsErrorLog() + ) + let prefix = ShortcutStroke( + key: "j", + command: true, + shift: true, + option: true, + control: true + ) + let settingsChord = StoredShortcut( + first: prefix, + second: ShortcutStroke(key: "p") + ) + let searchChord = StoredShortcut( + first: prefix, + second: ShortcutStroke(key: "f") + ) + + await model.assignChord(settingsChord, to: .openSettings) + await model.assignChord(searchChord, to: .globalSearch) + + let bindings = await store.value(for: catalog.shortcuts.bindings) + #expect(bindings[ShortcutAction.openSettings.rawValue] == settingsChord) + #expect(bindings[ShortcutAction.globalSearch.rawValue] == searchChord) + #expect(model.conflictRejections[ShortcutAction.globalSearch.rawValue] == nil) + } + + @Test func persistedMediaKeyGlobalSearchBindingIsNotEffective() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = JSONConfigStore(fileURL: directory.appendingPathComponent("cmux.json")) + let catalog = SettingCatalog() + let mediaShortcut = StoredShortcut( + first: ShortcutStroke(key: "media.playPause", command: true) + ) + let observationSentinel = StoredShortcut( + first: ShortcutStroke(key: "j", command: true, shift: true, option: true, control: true) + ) + try await store.set( + [ + ShortcutAction.globalSearch.rawValue: mediaShortcut, + ShortcutAction.openSettings.rawValue: observationSentinel, + ], + for: catalog.shortcuts.bindings + ) + + let model = ShortcutListModel( + jsonStore: store, + catalog: catalog, + errorLog: SettingsErrorLog() + ) + model.startObserving() + await spin { + model.bindings[ShortcutAction.openSettings.rawValue] == observationSentinel + } + + #expect(model.bindings[ShortcutAction.globalSearch.rawValue] == mediaShortcut) + #expect(model.effective(for: .globalSearch) == ShortcutAction.globalSearch.defaultShortcut) + } + + @Test func mediaKeyGlobalSearchBindingCannotBeRestored() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = JSONConfigStore(fileURL: directory.appendingPathComponent("cmux.json")) + let catalog = SettingCatalog() + let model = ShortcutListModel( + jsonStore: store, + catalog: catalog, + errorLog: SettingsErrorLog() + ) + let mediaShortcut = StoredShortcut( + first: ShortcutStroke(key: "j", command: true), + second: ShortcutStroke(key: "media.playPause") + ) + + await model.restoreBinding(mediaShortcut, for: .globalSearch) + + let bindings = await store.value(for: catalog.shortcuts.bindings) + #expect(bindings[ShortcutAction.globalSearch.rawValue] == nil) + #expect( + model.validationMessage(for: .globalSearch) + == String( + localized: "shortcut.recorder.error.reservedBySystem", + defaultValue: "This keystroke is reserved by macOS." + ) + ) + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-global-search-model-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func spin(until condition: () -> Bool) async { + var attempts = 0 + while !condition(), attempts < 100_000 { + await Task.yield() + attempts += 1 + } + #expect(condition(), "spin(until:) timed out after 100,000 yields") + } +} diff --git a/cmuxTests/GlobalSearchShortcutSettingsTests.swift b/cmuxTests/GlobalSearchShortcutSettingsTests.swift index 4e38287fc477..f1a76846cb13 100644 --- a/cmuxTests/GlobalSearchShortcutSettingsTests.swift +++ b/cmuxTests/GlobalSearchShortcutSettingsTests.swift @@ -1,4 +1,8 @@ -import XCTest +import AppKit +import CmuxSettings +import Foundation +import Testing +@testable import CmuxSettingsUI #if canImport(cmux_DEV) @testable import cmux_DEV @@ -6,12 +10,11 @@ import XCTest @testable import cmux #endif -@MainActor -final class GlobalSearchShortcutSettingsTests: XCTestCase { - private var originalSettingsFileStore: KeyboardShortcutSettingsFileStore! +extension GlobalSearchShortcutBehaviorTests { + @MainActor @Suite final class GlobalSearchShortcutSettingsTests { + private let originalSettingsFileStore: KeyboardShortcutSettingsFileStore - override func setUp() { - super.setUp() + init() { originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( primaryPath: FileManager.default.temporaryDirectory @@ -24,50 +27,250 @@ final class GlobalSearchShortcutSettingsTests: XCTestCase { KeyboardShortcutSettings.resetAll() } - override func tearDown() { + deinit { KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore KeyboardShortcutSettings.resetAll() - super.tearDown() } - func testGlobalSearchDefaultShortcutIsRemappableAndSystemWideSafe() { + @Test func globalSearchDefaultShortcutIsRemappableAndForegroundScoped() { let defaultShortcut = KeyboardShortcutSettings.shortcut(for: .globalSearch) - XCTAssertEqual( - defaultShortcut, - StoredShortcut(key: "f", command: true, shift: false, option: true, control: false) + #expect( + defaultShortcut == + StoredShortcut(key: "f", command: true, shift: false, option: true, control: false) ) - XCTAssertTrue(KeyboardShortcutSettings.publicShortcutActions.contains(.globalSearch)) - XCTAssertTrue(KeyboardShortcutSettings.settingsVisibleActions.contains(.globalSearch)) - XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .sendFeedback), .unbound) - XCTAssertEqual( - KeyboardShortcutSettings.Action.globalSearch.normalizedRecordedShortcutResult(defaultShortcut), - .accepted(defaultShortcut) + #expect(KeyboardShortcutSettings.publicShortcutActions.contains(.globalSearch)) + #expect(KeyboardShortcutSettings.settingsVisibleActions.contains(.globalSearch)) + #expect(KeyboardShortcutSettings.shortcut(for: .sendFeedback) == .unbound) + #expect( + KeyboardShortcutSettings.Action.globalSearch.normalizedRecordedShortcutResult(defaultShortcut) == + .accepted(defaultShortcut) ) } - func testGlobalSearchRejectsBareSystemWideShortcut() { - let bareShortcut = StoredShortcut(key: "f", command: false, shift: false, option: false, control: false) + @Test func globalSearchUsesApplicationBareKeyPolicy() { + #expect(!KeyboardShortcutSettings.Action.globalSearch.allowsBareFirstStroke) + } - XCTAssertEqual( - KeyboardShortcutSettings.Action.globalSearch.normalizedRecordedShortcutResult(bareShortcut), - .rejected(.systemWideHotkeyRequiresModifier) + @Test func optionOnlyGlobalSearchRoutesBeforeUnmatchedOptionTextInput() throws { + let shortcut = StoredShortcut( + key: "q", + command: false, + shift: false, + option: true, + control: false + ) + KeyboardShortcutSettings.setShortcut(shortcut, for: .globalSearch) + + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.option], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "@", + charactersIgnoringModifiers: "q", + isARepeat: false, + keyCode: 12 + ) ) + let appDelegate = AppDelegate.shared ?? AppDelegate() + + #expect(shortcutRoutingShouldBypassForPrintableOptionText(event: event)) + #expect(shortcut.matches(event: event)) + #expect(appDelegate.matchGlobalSearchShortcut(event: event)) + } + + @Test func ordinaryTypingDoesNotResolveGlobalSearchBinding() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "a", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + ) + ) + appDelegate.debugResetShortcutRoutingStateForTesting() + var globalSearchLookupCount = 0 + KeyboardShortcutSettings.shortcutLookupObserver = { action in + if action == .globalSearch { + globalSearchLookupCount += 1 + } + } + defer { + KeyboardShortcutSettings.shortcutLookupObserver = nil + appDelegate.debugResetShortcutRoutingStateForTesting() + } + + #expect(!appDelegate.debugHandleCustomShortcut(event: event)) + #expect(globalSearchLookupCount == 0) +#else + Issue.record("Shortcut lookup instrumentation requires a DEBUG build") +#endif } - func testGlobalSearchRejectsConfiguredShowHideHotkeyConflict() { + @Test func optionOnlyGlobalSearchChordPrefixRoutesBeforeUnmatchedOptionTextInput() throws { +#if DEBUG + let shortcut = StoredShortcut( + key: "q", + command: false, + shift: false, + option: true, + control: false, + chordKey: "f" + ) + KeyboardShortcutSettings.setShortcut(shortcut, for: .globalSearch) + + let prefixEvent = try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.option], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "@", + charactersIgnoringModifiers: "q", + isARepeat: false, + keyCode: 12 + ) + ) + let suffixEvent = try #require( + NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: 0, + context: nil, + characters: "f", + charactersIgnoringModifiers: "f", + isARepeat: false, + keyCode: 3 + ) + ) + let appDelegate = try #require(AppDelegate.shared) + var didTogglePalette = false + defer { + if didTogglePalette { + appDelegate.toggleGlobalSearchPalette() + } + appDelegate.debugResetShortcutRoutingStateForTesting() + } + + #expect(shortcutRoutingShouldBypassForPrintableOptionText(event: prefixEvent)) + #expect(appDelegate.debugHandleCustomShortcut(event: prefixEvent)) + didTogglePalette = appDelegate.debugHandleCustomShortcut(event: suffixEvent) + #expect(didTogglePalette) +#else + Issue.record("Option-only Global Search chord routing requires a DEBUG build") +#endif + } + + @Test func globalSearchRejectsConfiguredShowHideHotkeyConflict() { let reservedShortcut = StoredShortcut(key: "g", command: true, shift: false, option: true, control: true) KeyboardShortcutSettings.setShortcut(.unbound, for: .globalSearch) SystemWideHotkeySettings.setShortcut(reservedShortcut) - XCTAssertEqual( - KeyboardShortcutSettings.Action.globalSearch.normalizedRecordedShortcutResult(reservedShortcut), - .rejected(.reservedBySystem) + #expect( + KeyboardShortcutSettings.Action.globalSearch.normalizedRecordedShortcutResult(reservedShortcut) == + .rejected(.conflictsWithAction(.showHideAllWindows)) + ) + } + + @Test func globalSearchRejectsSystemDefinedMediaKeyBindings() { + let singleStroke = StoredShortcut( + key: "media.playPause", + command: true, + shift: false, + option: false, + control: false ) + let mediaPrefix = StoredShortcut( + first: singleStroke.firstStroke, + second: ShortcutStroke( + key: "f", + command: true, + shift: false, + option: false, + control: false + ) + ) + let mediaSuffix = StoredShortcut( + first: ShortcutStroke( + key: "f", + command: true, + shift: false, + option: false, + control: false + ), + second: singleStroke.firstStroke + ) + + for shortcut in [singleStroke, mediaPrefix, mediaSuffix] { + #expect( + KeyboardShortcutSettings.Action.globalSearch.normalizedRecordedShortcutResult(shortcut) == + .rejected(.reservedBySystem) + ) + #expect( + KeyboardShortcutSettings.Action.globalSearch.normalizedSettingsFileShortcut(shortcut) == nil + ) + } } - func testSettingsFileStoreParsesGlobalSearchShortcut() throws { + @Test func settingsModelRejectsMediaKeyChordBeforePersistence() async throws { + // WHY: Global Search is routed through AppKit's foreground key handler, + // which cannot execute system-defined media-key strokes. Settings must + // reject the recording instead of displaying a binding runtime discards. + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-global-search-settings-model-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = JSONConfigStore(fileURL: directory.appendingPathComponent("cmux.json")) + let catalog = SettingCatalog() + let action = CmuxSettings.ShortcutAction.globalSearch + let chord = CmuxSettings.StoredShortcut( + first: CmuxSettings.ShortcutStroke( + key: "j", + command: true, + shift: true, + option: true, + control: true + ), + second: CmuxSettings.ShortcutStroke(key: "media.playPause") + ) + let model = ShortcutListModel( + jsonStore: store, + catalog: catalog, + errorLog: SettingsErrorLog() + ) + + await model.assignChord(chord, to: action) + + let storeBindings = await store.value(for: catalog.shortcuts.bindings) + #expect(storeBindings[action.rawValue] == nil) + #expect( + model.validationMessage(for: action) + == String( + localized: "shortcut.recorder.error.reservedBySystem", + defaultValue: "This keystroke is reserved by macOS." + ) + ) + } + + @Test func settingsFileStoreParsesGlobalSearchShortcut() throws { let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-global-search-settings-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) @@ -88,22 +291,15 @@ final class GlobalSearchShortcutSettingsTests: XCTestCase { startWatching: false ) - XCTAssertEqual( - store.override(for: .globalSearch), - StoredShortcut(key: "g", command: true, shift: false, option: false, control: true) + #expect( + store.override(for: .globalSearch) == + StoredShortcut(key: "g", command: true, shift: false, option: false, control: true) ) } - func testSettingsFileStoreParsesPackageObjectFormGlobalSearchShortcut() throws { - // Regression for https://github.com/manaflow-ai/cmux/issues/5137. - // The in-app Settings UI (CmuxSettings package) persists every - // shortcut rebinding to cmux.json under `shortcuts.bindings.` - // as a nested StoredShortcut object ({"first": {key, command, ...}}), - // not the legacy human-editable "cmd+opt+f" string. The file store - // that feeds KeyboardShortcutSettings — and therefore the system-wide - // Carbon hotkeys (globalSearch, showHideAllWindows) — must understand - // that object form. Otherwise SystemWideHotkeyController never sees the - // rebinding and the default ⌥⌘F keeps opening Global Search. + @Test func settingsFileStoreParsesPackageObjectFormGlobalSearchShortcut() throws { + // Regression for #5137: the Settings package writes a nested StoredShortcut object. + // Both foreground and system-wide routes must read that form rather than fall back. let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-global-search-object-settings-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) @@ -128,16 +324,14 @@ final class GlobalSearchShortcutSettingsTests: XCTestCase { startWatching: false ) - XCTAssertEqual( - store.override(for: .globalSearch), - StoredShortcut(key: "j", command: true, shift: false, option: false, control: true) + #expect( + store.override(for: .globalSearch) == + StoredShortcut(key: "j", command: true, shift: false, option: false, control: true) ) } - func testSettingsFileStoreParsesPackageObjectFormChordShortcut() throws { - // The package object form also encodes two-stroke chords as - // {"first": {...}, "second": {...}}. A non-system-wide action exercises - // the general path so the fix is not narrowed to global search. + @Test func settingsFileStoreParsesPackageObjectFormChordShortcut() throws { + // Package object form also encodes chords as {"first": {...}, "second": {...}}. let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-chord-object-settings-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) @@ -163,27 +357,25 @@ final class GlobalSearchShortcutSettingsTests: XCTestCase { startWatching: false ) - XCTAssertEqual( - store.override(for: .newTab), - StoredShortcut( - key: "b", - command: false, - shift: false, - option: false, - control: true, - chordKey: "n", - chordCommand: false, - chordShift: false, - chordOption: false, - chordControl: false - ) + #expect( + store.override(for: .newTab) == + StoredShortcut( + key: "b", + command: false, + shift: false, + option: false, + control: true, + chordKey: "n", + chordCommand: false, + chordShift: false, + chordOption: false, + chordControl: false + ) ) } - func testSettingsFileStoreParsesPackageObjectFormUnboundShortcut() throws { - // The package marks an explicit "no shortcut" override with an empty - // primary key ({"first": {"key": ""}}). The legacy reader must treat - // that as unbound, not as an invalid binding to be dropped. + @Test func settingsFileStoreParsesPackageObjectFormUnboundShortcut() throws { + // An empty primary key marks an explicit unbound override, not invalid data. let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-unbound-object-settings-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) @@ -206,13 +398,11 @@ final class GlobalSearchShortcutSettingsTests: XCTestCase { startWatching: false ) - XCTAssertEqual(store.override(for: .globalSearch), .unbound) + #expect(store.override(for: .globalSearch) == .unbound) } - func testSettingsFileStoreRejectsObjectFormChordWithMalformedSecondStroke() throws { - // A present-but-malformed `second` stroke must invalidate the whole - // binding rather than silently degrading the chord to a single stroke - // (which could create an unintended single-key shortcut). + @Test func settingsFileStoreRejectsObjectFormChordWithMalformedSecondStroke() throws { + // A malformed second stroke invalidates the chord instead of degrading it. let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-bad-chord-object-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) @@ -238,12 +428,11 @@ final class GlobalSearchShortcutSettingsTests: XCTestCase { startWatching: false ) - XCTAssertNil(store.override(for: .newTab)) + #expect(store.override(for: .newTab) == nil) } - func testSettingsFileStoreRejectsObjectFormBareKeyForModifierRequiringAction() throws { - // Object-form parsing must apply the same bare-first-stroke rule as the - // string parser: an action that requires a modifier rejects a bare key. + @Test func settingsFileStoreRejectsObjectFormBareKeyForModifierRequiringAction() throws { + // Object and string parsing apply the same bare-first-stroke rule. let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-bare-object-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) @@ -266,12 +455,12 @@ final class GlobalSearchShortcutSettingsTests: XCTestCase { startWatching: false ) - XCTAssertNil(store.override(for: .newTab)) + #expect(store.override(for: .newTab) == nil) } - func testSettingsFileStoreRejectsGlobalSearchChordBinding() throws { + @Test func settingsFileStoreParsesGlobalSearchChordBinding() throws { let directoryURL = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-global-search-invalid-settings-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("cmux-global-search-chord-settings-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directoryURL) } @@ -290,6 +479,21 @@ final class GlobalSearchShortcutSettingsTests: XCTestCase { startWatching: false ) - XCTAssertNil(store.override(for: .globalSearch)) + #expect( + store.override(for: .globalSearch) == + StoredShortcut( + key: "k", + command: true, + shift: false, + option: false, + control: false, + chordKey: "f", + chordCommand: false, + chordShift: false, + chordOption: false, + chordControl: false + ) + ) + } } } diff --git a/cmuxTests/GlobalSearchVisiblePopoverShortcutTests.swift b/cmuxTests/GlobalSearchVisiblePopoverShortcutTests.swift new file mode 100644 index 000000000000..bec9bb151d9c --- /dev/null +++ b/cmuxTests/GlobalSearchVisiblePopoverShortcutTests.swift @@ -0,0 +1,483 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension GlobalSearchShortcutBehaviorTests { + @MainActor @Suite final class GlobalSearchVisiblePopoverShortcutTests { + private let originalSettingsFileStore: KeyboardShortcutSettingsFileStore + + init() { + originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-global-search-visible-popover-\(UUID().uuidString).json") + .path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + KeyboardShortcutSettings.resetAll() + } + + deinit { + KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore + KeyboardShortcutSettings.resetAll() + } + + @Test func scopedVisibleGlobalSearchClosesFromAuxiliaryWindowShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeHarness(appDelegate: appDelegate) + defer { closeHarness(harness, appDelegate: appDelegate) } + + try scopeGlobalSearchToBrowserFocus() + appDelegate.toggleGlobalSearchPalette() + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + let event = try makeKeyDownEvent( + key: "f", + modifiers: [.command, .option], + keyCode: 3, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + #expect(!appDelegate.shortcutWhenClauseAllows(action: .globalSearch, event: event)) + + #expect( + appDelegate.debugHandleCustomShortcut(event: event), + "The visible Search popover must own its configured toggle shortcut" + ) + #expect(waitUntilGlobalSearchCloses()) +#else + Issue.record("Global Search visible-popover routing requires a DEBUG build") +#endif + } + + @Test func scopedVisibleGlobalSearchChordCompletesFromAuxiliaryWindow() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeHarness(appDelegate: appDelegate) + defer { closeHarness(harness, appDelegate: appDelegate) } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "k", + command: true, + shift: false, + option: true, + control: false, + chordKey: "f" + ), + for: .globalSearch + ) + try scopeGlobalSearchToBrowserFocus() + appDelegate.toggleGlobalSearchPalette() + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + let prefixEvent = try makeKeyDownEvent( + key: "k", + modifiers: [.command, .option], + keyCode: 40, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + let suffixEvent = try makeKeyDownEvent( + key: "f", + modifiers: [], + keyCode: 3, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + #expect(!appDelegate.shortcutWhenClauseAllows(action: .globalSearch, event: prefixEvent)) + + #expect( + appDelegate.debugHandleCustomShortcut(event: prefixEvent), + "The visible Search popover must arm its configured chord before auxiliary-window rejection" + ) + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + #expect(appDelegate.debugHandleCustomShortcut(event: suffixEvent)) + #expect(waitUntilGlobalSearchCloses()) +#else + Issue.record("Global Search visible-popover routing requires a DEBUG build") +#endif + } + + @Test func visibleGlobalSearchQueryOwnsOverlappingChordPrefix() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeHarness(appDelegate: appDelegate) + defer { closeHarness(harness, appDelegate: appDelegate) } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "c", + command: true, + shift: false, + option: false, + control: false, + chordKey: "g", + chordCommand: true + ), + for: .globalSearch + ) + appDelegate.toggleGlobalSearchPalette() + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + let prefixEvent = try makeKeyDownEvent( + key: "c", + modifiers: [.command], + keyCode: 8, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + #expect( + !appDelegate.debugHandleCustomShortcut(event: prefixEvent), + "The visible Search query must own Cmd-C before an unarmed Global Search chord" + ) + + let suffixEvent = try makeKeyDownEvent( + key: "g", + modifiers: [.command], + keyCode: 5, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + _ = appDelegate.debugHandleCustomShortcut(event: suffixEvent) + #expect( + GlobalSearchCoordinator.shared.isPaletteVisible(), + "A Search query editing command must not leave a Global Search chord armed" + ) +#else + Issue.record("Global Search visible-popover routing requires a DEBUG build") +#endif + } + + @Test func visibleGlobalSearchQueryOwnsSingleStrokeEditingShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeHarness(appDelegate: appDelegate) + defer { closeHarness(harness, appDelegate: appDelegate) } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "c", + command: true, + shift: false, + option: false, + control: false + ), + for: .globalSearch + ) + appDelegate.toggleGlobalSearchPalette() + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + let copyEvent = try makeKeyDownEvent( + key: "c", + modifiers: [.command], + keyCode: 8, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + #expect( + !appDelegate.debugHandleCustomShortcut(event: copyEvent), + "The visible Search query must own a single-stroke Cmd-C binding" + ) + #expect( + GlobalSearchCoordinator.shared.isPaletteVisible(), + "Copying from the Search query must not close Global Search" + ) +#else + Issue.record("Global Search visible-popover routing requires a DEBUG build") +#endif + } + + @Test func visibleGlobalSearchQueryOwnsCocoaControlEditingShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeHarness(appDelegate: appDelegate) + defer { closeHarness(harness, appDelegate: appDelegate) } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "a", + command: false, + shift: false, + option: false, + control: true + ), + for: .globalSearch + ) + appDelegate.toggleGlobalSearchPalette() + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + let event = try makeKeyDownEvent( + key: "a", + characters: "\u{01}", + modifiers: [.control], + keyCode: 0, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + #expect( + !appDelegate.debugHandleCustomShortcut(event: event), + "The visible Search query must own Ctrl-A text navigation" + ) + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) +#else + Issue.record("Global Search visible-popover routing requires a DEBUG build") +#endif + } + + @Test func queryEditingOwnershipIncludesOptionWordNavigation() throws { + let event = try makeKeyDownEvent( + key: "←", + modifiers: [.option], + keyCode: 123, + windowNumber: 0 + ) + + #expect( + GlobalSearchKeyEvent(event).queryOwnsEditingShortcut, + "The Search query must own Option-Left word navigation" + ) + } + + @Test func queryEditingOwnershipIncludesCombinedModifierDeletion() throws { + let event = try makeKeyDownEvent( + key: "\u{08}", + modifiers: [.control, .option], + keyCode: 51, + windowNumber: 0 + ) + + #expect( + GlobalSearchKeyEvent(event).queryOwnsEditingShortcut, + "The Search query must own Control-Option-Delete" + ) + } + + @Test func visibleGlobalSearchQueryOwnsBareSpaceShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeHarness(appDelegate: appDelegate) + defer { closeHarness(harness, appDelegate: appDelegate) } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "space", + command: false, + shift: false, + option: false, + control: false + ), + for: .globalSearch + ) + appDelegate.toggleGlobalSearchPalette() + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + let event = try makeKeyDownEvent( + key: " ", + modifiers: [], + keyCode: 49, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + #expect( + !appDelegate.debugHandleCustomShortcut(event: event), + "The visible Search query must own an unmodified Space" + ) + #expect( + GlobalSearchCoordinator.shared.isPaletteVisible(), + "Typing a space in the Search query must not close Global Search" + ) +#else + Issue.record("Global Search visible-popover routing requires a DEBUG build") +#endif + } + + @Test func visibleGlobalSearchQueryOwnsOptionDeadKeyShortcut() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeHarness(appDelegate: appDelegate) + defer { closeHarness(harness, appDelegate: appDelegate) } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "n", + command: false, + shift: false, + option: true, + control: false + ), + for: .globalSearch + ) + appDelegate.toggleGlobalSearchPalette() + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + let event = try makeKeyDownEvent( + key: "n", + characters: "", + modifiers: [.option], + keyCode: 45, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + #expect( + !appDelegate.debugHandleCustomShortcut(event: event), + "The visible Search query must own the first event in an Option dead-key sequence" + ) + #expect( + GlobalSearchCoordinator.shared.isPaletteVisible(), + "Starting Option dead-key composition must not close Global Search" + ) +#else + Issue.record("Global Search visible-popover routing requires a DEBUG build") +#endif + } + + @Test func markedTextOwnsInputWhileGlobalSearchIsVisible() throws { +#if DEBUG + let appDelegate = try #require(AppDelegate.shared) + let harness = try makeHarness(appDelegate: appDelegate) + defer { closeHarness(harness, appDelegate: appDelegate) } + + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 240, height: 24)) + harness.auxiliaryWindow.contentView?.addSubview(textView) + #expect(harness.auxiliaryWindow.makeFirstResponder(textView)) + textView.setMarkedText( + "かな", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + #expect(textView.hasMarkedText()) + + KeyboardShortcutSettings.setShortcut( + StoredShortcut( + key: "q", + command: false, + shift: false, + option: true, + control: false + ), + for: .globalSearch + ) + appDelegate.toggleGlobalSearchPalette() + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) + + let event = try makeKeyDownEvent( + key: "q", + characters: "@", + modifiers: [.option], + keyCode: 12, + windowNumber: harness.auxiliaryWindow.windowNumber + ) + + #expect( + !appDelegate.debugHandleCustomShortcut(event: event), + "IME marked text must keep ownership before visible Search shortcut routing" + ) + #expect(GlobalSearchCoordinator.shared.isPaletteVisible()) +#else + Issue.record("Global Search visible-popover routing requires a DEBUG build") +#endif + } + + private func scopeGlobalSearchToBrowserFocus() throws { + try """ + { + "shortcuts": { + "when": { + "globalSearch": "browserFocus" + } + } + } + """.write( + to: KeyboardShortcutSettings.settingsFileStore.settingsFileURLForEditing(), + atomically: true, + encoding: .utf8 + ) + KeyboardShortcutSettings.settingsFileStore.reload() + } + + private func makeHarness( + appDelegate: AppDelegate + ) throws -> (mainWindow: NSWindow, auxiliaryWindow: NSPanel) { + let windowId = appDelegate.createMainWindow() + let mainWindow = try #require(findWindow(withId: windowId)) + let auxiliaryWindow = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 120), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + auxiliaryWindow.identifier = NSUserInterfaceItemIdentifier( + "cmux.test.globalSearchAuxiliaryWindow" + ) + auxiliaryWindow.makeKeyAndOrderFront(nil) + return (mainWindow, auxiliaryWindow) + } + + private func makeKeyDownEvent( + key: String, + characters: String? = nil, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int + ) throws -> NSEvent { + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: windowNumber, + context: nil, + characters: characters ?? key, + charactersIgnoringModifiers: key, + isARepeat: false, + keyCode: keyCode + ) else { + throw TestHarnessError.eventUnavailable + } + return event + } + + private func findWindow(withId windowId: UUID) -> NSWindow? { + let identifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) + } + + private func waitUntilGlobalSearchCloses(timeout: TimeInterval = 2) -> Bool { + let deadline = Date.now.addingTimeInterval(timeout) + repeat { + if !GlobalSearchCoordinator.shared.isPaletteVisible() { + return true + } + _ = RunLoop.main.run( + mode: .default, + before: min(deadline, Date.now.addingTimeInterval(0.01)) + ) + } while Date.now < deadline + return !GlobalSearchCoordinator.shared.isPaletteVisible() + } + + private func closeHarness( + _ harness: (mainWindow: NSWindow, auxiliaryWindow: NSPanel), + appDelegate: AppDelegate + ) { + GlobalSearchCoordinator.shared.dismissPalette() + appDelegate.debugResetShortcutRoutingStateForTesting() + harness.auxiliaryWindow.orderOut(nil) + harness.auxiliaryWindow.close() +#if DEBUG + let originalConfirmationHandler = appDelegate.debugCloseMainWindowConfirmationHandler + appDelegate.debugCloseMainWindowConfirmationHandler = { _ in true } + defer { appDelegate.debugCloseMainWindowConfirmationHandler = originalConfirmationHandler } +#endif + harness.mainWindow.animationBehavior = .none + harness.mainWindow.orderOut(nil) + harness.mainWindow.close() + RunLoop.main.run(until: Date.now.addingTimeInterval(0.05)) + } + + private enum TestHarnessError: Error { + case eventUnavailable + } + } +} diff --git a/cmuxTests/KeyboardShortcutSettingsObserverTests.swift b/cmuxTests/KeyboardShortcutSettingsObserverTests.swift index 50f0d4dedd6e..c5197a200471 100644 --- a/cmuxTests/KeyboardShortcutSettingsObserverTests.swift +++ b/cmuxTests/KeyboardShortcutSettingsObserverTests.swift @@ -1,3 +1,4 @@ +import Carbon import Foundation import Testing @@ -7,9 +8,8 @@ import Testing @testable import cmux #endif -@MainActor -@Suite("Keyboard shortcut settings observer", .serialized) -struct KeyboardShortcutSettingsObserverTests { +extension GlobalSearchShortcutBehaviorTests { + @MainActor @Suite("Keyboard shortcut settings observer") struct KeyboardShortcutSettingsObserverTests { @Test func mainThreadSettingsChangeIsAuthoritativeBeforePostReturns() { let observer = KeyboardShortcutSettingsObserver.shared let expectedRevision = observer.revision &+ 1 @@ -21,4 +21,139 @@ struct KeyboardShortcutSettingsObserverTests { #expect(observer.revision == expectedRevision) } + + @Test func globalSearchShortcutUsesSnapshotAndReloadsAfterSettingsChange() { + let notificationCenter = NotificationCenter() + var configuredShortcut = StoredShortcut( + key: "f", + command: true, + shift: false, + option: true, + control: false + ) + var globalSearchLookupCount = 0 + let observer = KeyboardShortcutSettingsObserver( + notificationCenter: notificationCenter, + distributedNotificationCenter: DistributedNotificationCenter(), + shortcutProvider: { action in + guard action == .globalSearch else { return .unbound } + globalSearchLookupCount += 1 + return configuredShortcut + } + ) + + #expect(observer.globalSearchShortcut == configuredShortcut) + let initialLookupCount = globalSearchLookupCount + for _ in 0..<100 { + _ = observer.globalSearchShortcut + } + #expect(globalSearchLookupCount == initialLookupCount) + + configuredShortcut = StoredShortcut( + key: "g", + command: true, + shift: true, + option: false, + control: false, + chordKey: "s" + ) + notificationCenter.post( + name: KeyboardShortcutSettings.didChangeNotification, + object: nil + ) + + #expect(observer.globalSearchShortcut == configuredShortcut) + #expect(globalSearchLookupCount == initialLookupCount + 1) + + configuredShortcut = .unbound + notificationCenter.post( + name: KeyboardShortcutSettings.didChangeNotification, + object: nil + ) + + #expect(observer.globalSearchShortcut == .unbound) + #expect(globalSearchLookupCount == initialLookupCount + 2) + } + + @Test func legacyMediaKeyGlobalSearchBindingFallsBackToDefault() throws { + let action = KeyboardShortcutSettings.Action.globalSearch + let defaults = UserDefaults.standard + let originalValue = defaults.object(forKey: action.defaultsKey) + let originalStore = KeyboardShortcutSettings.settingsFileStore + KeyboardShortcutSettings.settingsFileStore = KeyboardShortcutSettingsFileStore( + primaryPath: FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-global-search-observer-\(UUID().uuidString).json") + .path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + defer { + KeyboardShortcutSettings.settingsFileStore = originalStore + if let originalValue { + defaults.set(originalValue, forKey: action.defaultsKey) + } else { + defaults.removeObject(forKey: action.defaultsKey) + } + } + + let mediaShortcut = StoredShortcut( + key: "media.playPause", + command: true, + shift: false, + option: false, + control: false + ) + defaults.set(try JSONEncoder().encode(mediaShortcut), forKey: action.defaultsKey) + + #expect(KeyboardShortcutSettings.shortcut(for: action) == action.defaultShortcut) + } + + @Test func inputSourceChangeRefreshesGlobalSearchSnapshot() async { + let notificationCenter = NotificationCenter() + let distributedNotificationCenter = DistributedNotificationCenter.default() + var configuredShortcut = StoredShortcut( + key: "f", + command: true, + shift: false, + option: true, + control: false + ) + var globalSearchLookupCount = 0 + let observer = KeyboardShortcutSettingsObserver( + notificationCenter: notificationCenter, + distributedNotificationCenter: distributedNotificationCenter, + shortcutProvider: { action in + guard action == .globalSearch else { return .unbound } + globalSearchLookupCount += 1 + return configuredShortcut + } + ) + let initialLookupCount = globalSearchLookupCount + configuredShortcut = StoredShortcut( + key: "g", + command: true, + shift: true, + option: false, + control: false + ) + + distributedNotificationCenter.postNotificationName( + Notification.Name(rawValue: kTISNotifySelectedKeyboardInputSourceChanged as String), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + var yields = 0 + while globalSearchLookupCount == initialLookupCount, yields < 1_000 { + await Task.yield() + yields += 1 + } + + #expect(observer.globalSearchShortcut == configuredShortcut) + #expect(globalSearchLookupCount == initialLookupCount + 1) + #expect(observer.revision == 1) + } + + } } diff --git a/cmuxTests/SystemWideHotkeyShortcutPolicyTests.swift b/cmuxTests/SystemWideHotkeyShortcutPolicyTests.swift index e66b6b528657..7446a990f505 100644 --- a/cmuxTests/SystemWideHotkeyShortcutPolicyTests.swift +++ b/cmuxTests/SystemWideHotkeyShortcutPolicyTests.swift @@ -1,4 +1,5 @@ import Carbon +import enum CmuxSettings.ShortcutAction import Foundation import Testing @@ -8,9 +9,8 @@ import Testing @testable import cmux #endif -@MainActor -@Suite(.serialized) -final class SystemWideHotkeyShortcutPolicyTests { +extension GlobalSearchShortcutBehaviorTests { + @MainActor @Suite final class SystemWideHotkeyShortcutPolicyTests { private let originalSettingsFileStore: KeyboardShortcutSettingsFileStore private let savedDefaults: [String: Any] @@ -27,6 +27,7 @@ final class SystemWideHotkeyShortcutPolicyTests { Self.clearShortcutDefaults() KeyboardShortcutSettings.settingsFileStore = originalSettingsFileStore Self.restoreDefaults(savedDefaults) + notifyHotkeyControllerOfDefaultsChange() } @Test func showHideAllWindowsAcceptsCommandGravePhysicalHotkeys() { @@ -53,15 +54,155 @@ final class SystemWideHotkeyShortcutPolicyTests { ) } - @Test func globalSearchStillRejectsCommandGraveWindowCyclingHotkey() { + @Test func registrationPolicyContainsOnlyExplicitlySystemWideActions() { + #expect(SystemWideHotkeySettings.action == .showHideAllWindows) + #expect(KeyboardShortcutSettings.Action.allCases.filter(\.isSystemWideHotkey) == [.showHideAllWindows]) + } + + @Test func sharedShortcutCatalogCoversEveryAppActionAndPreservesDefaults() { + let appActionIDs = Set(KeyboardShortcutSettings.Action.allCases.map(\.rawValue)) + let sharedActionIDs = Set(ShortcutAction.allCases.map(\.rawValue)) + + #expect(sharedActionIDs == appActionIDs) + #expect( + KeyboardShortcutSettings.shortcut(for: .toggleBrowserDesignMode) + == StoredShortcut( + key: "d", + command: true, + shift: false, + option: true, + control: true + ) + ) + } + + @Test func foregroundGlobalSearchDoesNotUseSystemWideReservationPolicy() { let shortcut = commandGraveShortcut() + #expect(KeyboardShortcutSettings.Action.globalSearch.normalizedRecordedShortcutResult(shortcut) == .accepted(shortcut)) + } + + @Test func nonRegistrableShowHideDoesNotReserveGlobalSearch() throws { + let commandPeriod = StoredShortcut( + key: ".", + command: true, + shift: false, + option: false, + control: false + ) + let encoded = try JSONEncoder().encode(commandPeriod) + UserDefaults.standard.set( + encoded, + forKey: KeyboardShortcutSettings.Action.showHideAllWindows.defaultsKey + ) + UserDefaults.standard.set( + encoded, + forKey: KeyboardShortcutSettings.Action.globalSearch.defaultsKey + ) + + #expect( + SystemWideHotkeySettings.registrationCandidate( + for: commandPeriod + ) == nil + ) + #expect( + KeyboardShortcutSettings.shortcut(for: .globalSearch) + == commandPeriod + ) + } + + @Test func controllerRegistersOnlyOptInShowHideShortcut() throws { + SystemWideHotkeyController.shared.start() + SystemWideHotkeySettings.setEnabled(false) + notifyHotkeyControllerOfDefaultsChange() + + let syntheticShortcuts = availableSyntheticShortcuts() + let showHideShortcut = try #require(syntheticShortcuts.first) + let globalSearchShortcut = try #require(syntheticShortcuts.dropFirst().first) + SystemWideHotkeySettings.setShortcut(showHideShortcut) + KeyboardShortcutSettings.setShortcut(globalSearchShortcut, for: .globalSearch) + + let showHideRegistration = try #require( + showHideShortcut.carbonHotKeyRegistration + ) + let globalSearchRegistration = try #require( + globalSearchShortcut.carbonHotKeyRegistration + ) + defer { + SystemWideHotkeySettings.setEnabled(false) + notifyHotkeyControllerOfDefaultsChange() + } + + #expect(probeRegistrationStatus(showHideRegistration) == noErr) + #expect(probeRegistrationStatus(globalSearchRegistration) == noErr) + + SystemWideHotkeySettings.setEnabled(true) + notifyHotkeyControllerOfDefaultsChange() + #expect(probeRegistrationStatus(showHideRegistration) == eventHotKeyExistsErr) + #expect(probeRegistrationStatus(globalSearchRegistration) == noErr) + + SystemWideHotkeySettings.setEnabled(false) + notifyHotkeyControllerOfDefaultsChange() + #expect(probeRegistrationStatus(showHideRegistration) == noErr) + } + + @Test func controllerMigratesLegacyShowHideShortcutBeforeRegistration() throws { + SystemWideHotkeyController.shared.start() + SystemWideHotkeySettings.setEnabled(false) + notifyHotkeyControllerOfDefaultsChange() + + let shortcut = try #require(availableSyntheticShortcuts().first) + let registration = try #require(shortcut.carbonHotKeyRegistration) + let encodedShortcut = try JSONEncoder().encode(shortcut) + UserDefaults.standard.removeObject( + forKey: KeyboardShortcutSettings.Action.showHideAllWindows.defaultsKey + ) + UserDefaults.standard.set( + encodedShortcut, + forKey: SystemWideHotkeySettings.legacyShortcutKey + ) + defer { + SystemWideHotkeySettings.setEnabled(false) + notifyHotkeyControllerOfDefaultsChange() + } + + SystemWideHotkeySettings.setEnabled(true) + notifyHotkeyControllerOfDefaultsChange() + #expect(probeRegistrationStatus(registration) == eventHotKeyExistsErr) #expect( - KeyboardShortcutSettings.Action.globalSearch.normalizedRecordedShortcutResult(shortcut) == - .rejected(.reservedBySystem) + UserDefaults.standard.object( + forKey: SystemWideHotkeySettings.legacyShortcutKey + ) == nil ) } + private func availableSyntheticShortcuts() -> [StoredShortcut] { + [ + ("f13", 105), + ("f14", 107), + ("f15", 113), + ("f16", 106), + ("f17", 64), + ("f18", 79), + ("f19", 80), + ("f20", 90), + ].compactMap { key, keyCode in + let shortcut = StoredShortcut( + key: key, + command: true, + shift: true, + option: true, + control: true, + keyCode: UInt16(keyCode) + ) + guard let registration = shortcut.carbonHotKeyRegistration, + probeRegistrationStatus(registration) == noErr else { + return nil + } + return shortcut + } + } + private func commandGraveShortcut(shift: Bool = false) -> StoredShortcut { StoredShortcut( key: "`", @@ -75,10 +216,36 @@ final class SystemWideHotkeyShortcutPolicyTests { private nonisolated static var shortcutDefaultsKeys: [String] { KeyboardShortcutSettings.Action.allCases.map(\.defaultsKey) + [ + SystemWideHotkeySettings.enabledKey, SystemWideHotkeySettings.legacyShortcutKey, ] } + private nonisolated func notifyHotkeyControllerOfDefaultsChange() { + NotificationCenter.default.post( + name: UserDefaults.didChangeNotification, + object: UserDefaults.standard + ) + } + + private func probeRegistrationStatus( + _ registration: CarbonHotKeyRegistration + ) -> OSStatus { + var hotKeyRef: EventHotKeyRef? + let status = RegisterEventHotKey( + registration.keyCode, + registration.modifiers, + EventHotKeyID(signature: 0x54455354, id: 8561), + GetApplicationEventTarget(), + 0, + &hotKeyRef + ) + if let hotKeyRef { + UnregisterEventHotKey(hotKeyRef) + } + return status + } + private nonisolated static func defaultsSnapshot() -> [String: Any] { let defaults = UserDefaults.standard return shortcutDefaultsKeys.reduce(into: [:]) { snapshot, key in @@ -102,4 +269,5 @@ final class SystemWideHotkeyShortcutPolicyTests { defaults.set(value, forKey: key) } } + } } diff --git a/skills/cmux-keyboard-shortcuts/SKILL.md b/skills/cmux-keyboard-shortcuts/SKILL.md index bc388259653a..8a1dbd4cc2b7 100644 --- a/skills/cmux-keyboard-shortcuts/SKILL.md +++ b/skills/cmux-keyboard-shortcuts/SKILL.md @@ -35,11 +35,15 @@ fi ## Shortcut model -- Setting path: `shortcuts.bindings.`. Single stroke `"cmd+b"`; chord `["ctrl+b","c"]` where the first stroke needs a modifier unless the key is Space and the second stroke can be bare. -- Unbind with `null`. `""`, `"none"`, `"clear"`, `"unbound"`, and `"disabled"` are accepted aliases. -- `selectSurfaceByNumber` and `selectWorkspaceByNumber` take a digit 1-9; `cmd+1` means the whole `cmd+1`..`cmd+9` family. -- `showHideAllWindows` and `globalSearch` are system-wide: no chords, modifiers required, and macOS may reject reserved combinations. `showHideAllWindows` also needs Settings > Global Hotkey > Enable System-Wide Hotkey; the binding validates in `cmux.json` while the feature is off, so warn the user before calling it usable. -- `unset` deletes a `cmux.json` override; it does not clear shortcut changes saved through the Settings UI/UserDefaults. For true built-in defaults, clear file overrides then use Settings > Keyboard Shortcuts > Reset Default Shortcuts and verify in the app. That reset intentionally skips the global hotkey, so restore `showHideAllWindows` to `ctrl+opt+cmd+.` from Settings > Global Hotkey. +- Setting path: `shortcuts.bindings.`. +- Single stroke: `"cmd+b"`. +- Chord: `["ctrl+b","c"]`. The first stroke needs a modifier unless the key is Space. The second stroke can be bare. +- Unbind: prefer `null` for explicit unbinds. `""`, `"none"`, `"clear"`, `"unbound"`, and `"disabled"` are accepted aliases, but `null` is the clearest JSON value and matches the templates below. +- `selectSurfaceByNumber` and `selectWorkspaceByNumber` must use a digit from 1 to 9. `cmd+1` means the full `cmd+1` through `cmd+9` family. +- `showHideAllWindows` is the only system-wide shortcut. It cannot be a chord, requires modifiers, and may be rejected by macOS if reserved. +- `globalSearch` is application-scoped and only fires while cmux is active. +- `showHideAllWindows` also requires Settings > Global Hotkey > Enable System-Wide Hotkey. The binding can validate in `cmux.json` while the feature is disabled, so warn the user to enable that setting before reporting the shortcut as usable. +- `unset` deletes a `cmux.json` override. It does not clear shortcut changes saved through the Settings UI/UserDefaults. If the user asks for true built-in defaults, tell them to use Settings > Keyboard Shortcuts > Reset Default Shortcuts after clearing file-managed overrides, then verify in the app. For `showHideAllWindows`, use Settings > Global Hotkey to restore the shortcut to `ctrl+opt+cmd+.` because Keyboard Shortcuts > Reset Default Shortcuts intentionally skips the global hotkey. - Saving `cmux.json` live reloads. Never tell the user to restart cmux. ## Workflow diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 90ca7d720991..ed0a0468523a 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -42,7 +42,7 @@ export const shortcutCategories: ShortcutCategory[] = [ id: "globalSearch", combos: [["⌥", "⌘", "F"]], description: { en: "Global search", ja: "グローバル検索" }, - note: { en: "system-wide hotkey", ja: "システム全体のホットキー" }, + note: { en: "when cmux is active", ja: "cmuxがアクティブな場合" }, }, { id: "commandPalette", combos: [["⌘", "⇧", "P"]], description: { en: "Command palette", ja: "コマンドパレット" } }, {