native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #172 from supabitapp/sbertix/keyboard-shortcuts

authored by

khoi and committed by
GitHub
098acee2 db6d189b

+2026 -261
+6 -6
supacode.xcodeproj/project.pbxproj
··· 431 431 buildSettings = { 432 432 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 433 433 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 434 - AUTOMATION_APPLE_EVENTS = YES; 435 - CODE_SIGN_ENTITLEMENTS = supacode/supacodeDebug.entitlements; 436 - CODE_SIGN_STYLE = Automatic; 434 + AUTOMATION_APPLE_EVENTS = YES; 435 + CODE_SIGN_ENTITLEMENTS = supacode/supacodeDebug.entitlements; 436 + CODE_SIGN_STYLE = Automatic; 437 437 COMBINE_HIDPI_IMAGES = YES; 438 438 COMPILATION_CACHE_ENABLE_CACHING = YES; 439 439 CURRENT_PROJECT_VERSION = 117; ··· 487 487 buildSettings = { 488 488 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 489 489 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 490 - AUTOMATION_APPLE_EVENTS = YES; 491 - CODE_SIGN_ENTITLEMENTS = supacode/supacode.entitlements; 492 - CODE_SIGN_STYLE = Automatic; 490 + AUTOMATION_APPLE_EVENTS = YES; 491 + CODE_SIGN_ENTITLEMENTS = supacode/supacode.entitlements; 492 + CODE_SIGN_STYLE = Automatic; 493 493 COMBINE_HIDPI_IMAGES = YES; 494 494 COMPILATION_CACHE_ENABLE_CACHING = NO; 495 495 CURRENT_PROJECT_VERSION = 117;
+4 -4
supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
··· 69 69 "kind" : "remoteSourceControl", 70 70 "location" : "https://github.com/pointfreeco/swift-composable-architecture", 71 71 "state" : { 72 - "revision" : "5b0890fabfd68a2d375d68502bc3f54a8548c494", 73 - "version" : "1.23.1" 72 + "revision" : "df934d9c5a274a6f6a7bdcec73fbcb330149ff8b", 73 + "version" : "1.23.2" 74 74 } 75 75 }, 76 76 { ··· 114 114 "kind" : "remoteSourceControl", 115 115 "location" : "https://github.com/pointfreeco/swift-navigation", 116 116 "state" : { 117 - "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", 118 - "version" : "2.6.0" 117 + "revision" : "e7441dc4dfec6a4ae929e614e3c1e67c6639d164", 118 + "version" : "2.7.0" 119 119 } 120 120 }, 121 121 {
+295
supacode/App/AppShortcutOverride.swift
··· 1 + import Carbon.HIToolbox 2 + import SwiftUI 3 + 4 + // Persisted override for an app shortcut binding. 5 + nonisolated struct AppShortcutOverride: Codable, Equatable, Hashable, Sendable { 6 + var keyCode: UInt16 7 + var modifiers: ModifierFlags 8 + var isEnabled: Bool 9 + 10 + struct ModifierFlags: OptionSet, Codable, Equatable, Hashable, Sendable { 11 + let rawValue: Int 12 + static let command = Self(rawValue: 1 << 0) 13 + static let option = Self(rawValue: 1 << 1) 14 + static let control = Self(rawValue: 1 << 2) 15 + static let shift = Self(rawValue: 1 << 3) 16 + } 17 + 18 + init(keyCode: UInt16, modifiers: ModifierFlags, isEnabled: Bool = true) { 19 + self.keyCode = keyCode 20 + self.modifiers = modifiers 21 + self.isEnabled = isEnabled 22 + } 23 + 24 + // Sentinel for a disabled shortcut. 25 + static let disabled = AppShortcutOverride(keyCode: 0, modifiers: [], isEnabled: false) 26 + 27 + } 28 + 29 + // MARK: - SwiftUI conversions. 30 + 31 + extension AppShortcutOverride { 32 + init(from eventModifiers: SwiftUI.EventModifiers, keyCode: UInt16) { 33 + self.keyCode = keyCode 34 + var flags: ModifierFlags = [] 35 + if eventModifiers.contains(.command) { flags.insert(.command) } 36 + if eventModifiers.contains(.option) { flags.insert(.option) } 37 + if eventModifiers.contains(.control) { flags.insert(.control) } 38 + if eventModifiers.contains(.shift) { flags.insert(.shift) } 39 + self.modifiers = flags 40 + self.isEnabled = true 41 + } 42 + 43 + var eventModifiers: SwiftUI.EventModifiers { 44 + var result: SwiftUI.EventModifiers = [] 45 + if modifiers.contains(.command) { result.insert(.command) } 46 + if modifiers.contains(.option) { result.insert(.option) } 47 + if modifiers.contains(.control) { result.insert(.control) } 48 + if modifiers.contains(.shift) { result.insert(.shift) } 49 + return result 50 + } 51 + 52 + var keyboardShortcut: KeyboardShortcut { 53 + KeyboardShortcut(keyEquivalent, modifiers: eventModifiers) 54 + } 55 + 56 + var keyEquivalent: KeyEquivalent { 57 + Self.keyEquivalent(for: keyCode) 58 + } 59 + } 60 + 61 + // MARK: - Display. 62 + 63 + extension AppShortcutOverride { 64 + var displayString: String { 65 + displaySymbols.joined() 66 + } 67 + 68 + // Ordered array of individual display symbols: one per modifier, followed by the key. 69 + var displaySymbols: [String] { 70 + displayModifierParts + [Self.displayCharacter(for: keyCode)] 71 + } 72 + 73 + private var displayModifierParts: [String] { 74 + var parts: [String] = [] 75 + if modifiers.contains(.command) { parts.append("⌘") } 76 + if modifiers.contains(.shift) { parts.append("⇧") } 77 + if modifiers.contains(.option) { parts.append("⌥") } 78 + if modifiers.contains(.control) { parts.append("⌃") } 79 + return parts 80 + } 81 + } 82 + 83 + // MARK: - System hotkeys. 84 + 85 + extension AppShortcutOverride { 86 + // Well-known macOS app conventions always reserved by AppKit (not in the symbolic hotkeys plist). 87 + static let appKitReservedDisplayStrings: Set<String> = ["⌘Q", "⌘W", "⌘H", "⌘M"] 88 + 89 + // Reads macOS system symbolic hotkeys at runtime and returns their display strings, 90 + // combined with well-known AppKit reserved shortcuts. 91 + static func allReservedDisplayStrings() -> Set<String> { 92 + systemReservedDisplayStrings().union(appKitReservedDisplayStrings) 93 + } 94 + 95 + // Reads macOS system symbolic hotkeys at runtime and returns their display strings. 96 + static func systemReservedDisplayStrings() -> Set<String> { 97 + guard let defaults = UserDefaults(suiteName: "com.apple.symbolichotkeys"), 98 + let hotkeys = defaults.dictionary(forKey: "AppleSymbolicHotKeys") 99 + else { 100 + shortcutLogger.warning("Could not read system symbolic hotkeys; conflict detection will be incomplete.") 101 + return [] 102 + } 103 + var result: Set<String> = [] 104 + for (_, value) in hotkeys { 105 + guard let entry = value as? [String: Any], 106 + entry["enabled"] as? Bool == true, 107 + let params = (entry["value"] as? [String: Any])?["parameters"] as? [Any], 108 + params.count >= 3, 109 + let keyCode = params[1] as? Int, 110 + let modifierFlags = params[2] as? Int 111 + else { 112 + continue 113 + } 114 + // Carbon modifier flags: cmdKey=0x100, shiftKey=0x200, optionKey=0x800, controlKey=0x1000. 115 + var flags: ModifierFlags = [] 116 + if modifierFlags & 0x100 != 0 { flags.insert(.command) } 117 + if modifierFlags & 0x200 != 0 { flags.insert(.shift) } 118 + if modifierFlags & 0x800 != 0 { flags.insert(.option) } 119 + if modifierFlags & 0x1000 != 0 { flags.insert(.control) } 120 + let override = AppShortcutOverride(keyCode: UInt16(keyCode), modifiers: flags) 121 + result.insert(override.displayString) 122 + } 123 + return result 124 + } 125 + } 126 + 127 + // MARK: - Ghostty keybind. 128 + 129 + extension AppShortcutOverride { 130 + var ghosttyKeybind: String { 131 + let parts = ghosttyModifierParts + [Self.ghosttyKeyName(for: keyCode)] 132 + return parts.joined(separator: "+") 133 + } 134 + 135 + private var ghosttyModifierParts: [String] { 136 + var parts: [String] = [] 137 + if modifiers.contains(.control) { parts.append("ctrl") } 138 + if modifiers.contains(.option) { parts.append("alt") } 139 + if modifiers.contains(.shift) { parts.append("shift") } 140 + if modifiers.contains(.command) { parts.append("super") } 141 + return parts 142 + } 143 + } 144 + 145 + // MARK: - Key code mappings. 146 + 147 + private nonisolated let shortcutLogger = SupaLogger("Shortcuts") 148 + 149 + extension AppShortcutOverride { 150 + // Reverse lookup: given a US QWERTY character, return its key code. 151 + static func keyCode(for character: Character) -> UInt16? { 152 + reverseUSQwerty[character] 153 + } 154 + 155 + private static let reverseUSQwerty: [Character: UInt16] = { 156 + var map: [Character: UInt16] = [:] 157 + for (code, str) in usQwertyFallback { 158 + if let character = str.first { map[character] = code } 159 + } 160 + return map 161 + }() 162 + 163 + // Resolves the character for a key code using the current keyboard layout, 164 + // falling back to US QWERTY when the layout is unavailable (e.g., CI, sandboxed contexts). 165 + static func layoutCharacter(for code: UInt16) -> String? { 166 + if let char = currentLayoutCharacter(for: code) { return char } 167 + shortcutLogger.debug("Using US QWERTY fallback for key code \(code)") 168 + return usQwertyFallback[code] 169 + } 170 + 171 + // The Ghostty key name for a given key code (e.g. "a", "arrow_up", "return"). 172 + static func resolvedGhosttyKeyName(for code: UInt16) -> String { 173 + ghosttyKeyName(for: code) 174 + } 175 + 176 + // Uses UCKeyTranslate to resolve the character from the active input source. 177 + private static func currentLayoutCharacter(for code: UInt16) -> String? { 178 + guard let inputSource = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), 179 + let layoutPtr = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) 180 + else { 181 + return nil 182 + } 183 + let layoutData = unsafeBitCast(layoutPtr, to: CFData.self) 184 + guard CFGetTypeID(layoutData) == CFDataGetTypeID() else { 185 + shortcutLogger.warning("TIS property returned non-CFData type for key code \(code)") 186 + return nil 187 + } 188 + guard let bytePtr = CFDataGetBytePtr(layoutData) else { 189 + shortcutLogger.warning("CFDataGetBytePtr returned nil for key code \(code)") 190 + return nil 191 + } 192 + return bytePtr.withMemoryRebound(to: UCKeyboardLayout.self, capacity: 1) { keyboardLayout in 193 + var deadKeyState: UInt32 = 0 194 + var chars = [UniChar](repeating: 0, count: 4) 195 + var length = 0 196 + let status = UCKeyTranslate( 197 + keyboardLayout, 198 + code, 199 + UInt16(kUCKeyActionDisplay), 200 + 0, 201 + UInt32(LMGetKbdType()), 202 + UInt32(kUCKeyTranslateNoDeadKeysBit), 203 + &deadKeyState, 204 + 4, 205 + &length, 206 + &chars 207 + ) 208 + guard status == noErr, length > 0 else { 209 + if status != noErr { 210 + shortcutLogger.warning("UCKeyTranslate returned status \(status) for key code \(code).") 211 + } 212 + return nil 213 + } 214 + let str = String(utf16CodeUnits: chars, count: length) 215 + // Only return printable, non-whitespace characters. 216 + guard let scalar = str.unicodeScalars.first, scalar.value > 0x20, scalar.value != 0x7F else { 217 + return nil 218 + } 219 + return str 220 + } 221 + } 222 + 223 + // US QWERTY character mapping for environments without a keyboard layout. 224 + private static let usQwertyFallback: [UInt16: String] = { 225 + let entries: [(Int, String)] = [ 226 + (kVK_ANSI_A, "a"), (kVK_ANSI_B, "b"), (kVK_ANSI_C, "c"), (kVK_ANSI_D, "d"), 227 + (kVK_ANSI_E, "e"), (kVK_ANSI_F, "f"), (kVK_ANSI_G, "g"), (kVK_ANSI_H, "h"), 228 + (kVK_ANSI_I, "i"), (kVK_ANSI_J, "j"), (kVK_ANSI_K, "k"), (kVK_ANSI_L, "l"), 229 + (kVK_ANSI_M, "m"), (kVK_ANSI_N, "n"), (kVK_ANSI_O, "o"), (kVK_ANSI_P, "p"), 230 + (kVK_ANSI_Q, "q"), (kVK_ANSI_R, "r"), (kVK_ANSI_S, "s"), (kVK_ANSI_T, "t"), 231 + (kVK_ANSI_U, "u"), (kVK_ANSI_V, "v"), (kVK_ANSI_W, "w"), (kVK_ANSI_X, "x"), 232 + (kVK_ANSI_Y, "y"), (kVK_ANSI_Z, "z"), 233 + (kVK_ANSI_0, "0"), (kVK_ANSI_1, "1"), (kVK_ANSI_2, "2"), (kVK_ANSI_3, "3"), 234 + (kVK_ANSI_4, "4"), (kVK_ANSI_5, "5"), (kVK_ANSI_6, "6"), (kVK_ANSI_7, "7"), 235 + (kVK_ANSI_8, "8"), (kVK_ANSI_9, "9"), 236 + (kVK_ANSI_LeftBracket, "["), (kVK_ANSI_RightBracket, "]"), 237 + (kVK_ANSI_Comma, ","), (kVK_ANSI_Period, "."), (kVK_ANSI_Slash, "/"), 238 + (kVK_ANSI_Semicolon, ";"), (kVK_ANSI_Quote, "'"), (kVK_ANSI_Backslash, "\\"), 239 + (kVK_ANSI_Minus, "-"), (kVK_ANSI_Equal, "="), (kVK_ANSI_Grave, "`"), 240 + ] 241 + var map: [UInt16: String] = [:] 242 + for (code, char) in entries { map[UInt16(code)] = char } 243 + return map 244 + }() 245 + 246 + private static func ghosttyKeyName(for code: UInt16) -> String { 247 + switch Int(code) { 248 + case kVK_LeftArrow: "arrow_left" 249 + case kVK_RightArrow: "arrow_right" 250 + case kVK_UpArrow: "arrow_up" 251 + case kVK_DownArrow: "arrow_down" 252 + case kVK_Return: "return" 253 + case kVK_Escape: "escape" 254 + case kVK_Delete: "backspace" 255 + case kVK_Tab: "tab" 256 + case kVK_Space: "space" 257 + default: layoutCharacter(for: code)?.lowercased() ?? String(format: "0x%02x", code) 258 + } 259 + } 260 + 261 + private static func displayCharacter(for code: UInt16) -> String { 262 + switch Int(code) { 263 + case kVK_LeftArrow: "←" 264 + case kVK_RightArrow: "→" 265 + case kVK_UpArrow: "↑" 266 + case kVK_DownArrow: "↓" 267 + case kVK_Return: "↩" 268 + case kVK_Escape: "⎋" 269 + case kVK_Delete: "⌫" 270 + case kVK_Tab: "⇥" 271 + case kVK_Space: "Space" 272 + default: layoutCharacter(for: code)?.uppercased() ?? String(format: "0x%02x", code) 273 + } 274 + } 275 + 276 + private static func keyEquivalent(for code: UInt16) -> KeyEquivalent { 277 + switch Int(code) { 278 + case kVK_LeftArrow: return .leftArrow 279 + case kVK_RightArrow: return .rightArrow 280 + case kVK_UpArrow: return .upArrow 281 + case kVK_DownArrow: return .downArrow 282 + case kVK_Return: return .return 283 + case kVK_Escape: return .escape 284 + case kVK_Delete: return .delete 285 + case kVK_Tab: return .tab 286 + case kVK_Space: return .space 287 + default: 288 + guard let char = layoutCharacter(for: code)?.first else { 289 + shortcutLogger.warning("Cannot resolve KeyEquivalent for key code \(code), using fallback '?'.") 290 + return KeyEquivalent("?") 291 + } 292 + return KeyEquivalent(char) 293 + } 294 + } 295 + }
+316 -70
supacode/App/AppShortcuts.swift
··· 1 + import Sharing 1 2 import SwiftUI 2 3 3 - struct AppShortcut { 4 + // MARK: - Shortcut identity. 5 + 6 + // Compile-time checkable shortcut identifier. 7 + nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRepresentable { 8 + case commandPalette, openSettings, checkForUpdates 9 + case toggleLeftSidebar 10 + case newWorktree, refreshWorktrees, archivedWorktrees, archiveWorktree 11 + case deleteWorktree, confirmWorktreeAction 12 + case selectNextWorktree, selectPreviousWorktree 13 + case selectWorktree(Int) 14 + case openFinder, openRepository, openPullRequest, copyPath 15 + case runScript, stopRunScript 16 + 17 + // Stable string key for JSON dictionary persistence. 18 + var codingKey: CodingKey { 19 + StringCodingKey(stableKey) 20 + } 21 + 22 + init?<T: CodingKey>(codingKey: T) { 23 + self.init(stableKey: codingKey.stringValue) 24 + } 25 + 26 + private struct StringCodingKey: CodingKey { 27 + var stringValue: String 28 + var intValue: Int? { nil } 29 + init(_ stringValue: String) { self.stringValue = stringValue } 30 + init?(stringValue: String) { self.stringValue = stringValue } 31 + init?(intValue: Int) { nil } 32 + } 33 + 34 + private var stableKey: String { 35 + switch self { 36 + case .commandPalette: "commandPalette" 37 + case .openSettings: "openSettings" 38 + case .checkForUpdates: "checkForUpdates" 39 + case .toggleLeftSidebar: "toggleLeftSidebar" 40 + case .newWorktree: "newWorktree" 41 + case .refreshWorktrees: "refreshWorktrees" 42 + case .archivedWorktrees: "archivedWorktrees" 43 + case .archiveWorktree: "archiveWorktree" 44 + case .deleteWorktree: "deleteWorktree" 45 + case .confirmWorktreeAction: "confirmWorktreeAction" 46 + case .selectNextWorktree: "selectNextWorktree" 47 + case .selectPreviousWorktree: "selectPreviousWorktree" 48 + case .selectWorktree(let index): "selectWorktree\(index)" 49 + case .openFinder: "openFinder" 50 + case .openRepository: "openRepository" 51 + case .openPullRequest: "openPullRequest" 52 + case .copyPath: "copyPath" 53 + case .runScript: "runScript" 54 + case .stopRunScript: "stopRunScript" 55 + } 56 + } 57 + 58 + private static let stableKeyMap: [String: AppShortcutID] = [ 59 + "commandPalette": .commandPalette, 60 + "openSettings": .openSettings, 61 + "checkForUpdates": .checkForUpdates, 62 + "toggleLeftSidebar": .toggleLeftSidebar, 63 + "newWorktree": .newWorktree, 64 + "refreshWorktrees": .refreshWorktrees, 65 + "archivedWorktrees": .archivedWorktrees, 66 + "archiveWorktree": .archiveWorktree, 67 + "deleteWorktree": .deleteWorktree, 68 + "confirmWorktreeAction": .confirmWorktreeAction, 69 + "selectNextWorktree": .selectNextWorktree, 70 + "selectPreviousWorktree": .selectPreviousWorktree, 71 + "openFinder": .openFinder, 72 + "openRepository": .openRepository, 73 + "openPullRequest": .openPullRequest, 74 + "copyPath": .copyPath, 75 + "runScript": .runScript, 76 + "stopRunScript": .stopRunScript, 77 + ] 78 + 79 + private init?(stableKey: String) { 80 + if stableKey.hasPrefix("selectWorktree"), 81 + let index = Int(String(stableKey.dropFirst("selectWorktree".count))) 82 + { 83 + self = .selectWorktree(index) 84 + return 85 + } 86 + guard let id = Self.stableKeyMap[stableKey] else { return nil } 87 + self = id 88 + } 89 + 90 + // Human-readable name for display in settings and tooltips. 91 + var displayName: String { 92 + switch self { 93 + case .commandPalette: "Command Palette" 94 + case .openSettings: "Open Settings" 95 + case .checkForUpdates: "Check For Updates" 96 + case .toggleLeftSidebar: "Toggle Left Sidebar" 97 + case .newWorktree: "New Worktree" 98 + case .refreshWorktrees: "Refresh Worktrees" 99 + case .archivedWorktrees: "Archived Worktrees" 100 + case .archiveWorktree: "Archive Worktree" 101 + case .deleteWorktree: "Delete Worktree" 102 + case .confirmWorktreeAction: "Confirm Worktree Action" 103 + case .selectNextWorktree: "Select Next Worktree" 104 + case .selectPreviousWorktree: "Select Previous Worktree" 105 + case .selectWorktree(let index): "Select Worktree \(index)" 106 + case .openFinder: "Open Finder" 107 + case .openRepository: "Open Repository" 108 + case .openPullRequest: "Open Pull Request" 109 + case .copyPath: "Copy Path" 110 + case .runScript: "Run Script" 111 + case .stopRunScript: "Stop Run Script" 112 + } 113 + } 114 + } 115 + 116 + // MARK: - Shortcut definition. 117 + 118 + struct AppShortcut: Identifiable { 119 + let id: AppShortcutID 4 120 let keyEquivalent: KeyEquivalent 5 121 let modifiers: EventModifiers 122 + private let keyCode: UInt16? 6 123 private let ghosttyKeyName: String 7 124 8 - init(key: Character, modifiers: EventModifiers) { 9 - self.keyEquivalent = KeyEquivalent(key) 125 + init(id: AppShortcutID, key: Character, modifiers: EventModifiers) { 126 + self.id = id 10 127 self.modifiers = modifiers 11 - self.ghosttyKeyName = String(key).lowercased() 128 + // Resolve the physical key code from the US QWERTY character to enable 129 + // layout-aware display and Ghostty keybind generation. 130 + let code = AppShortcutOverride.keyCode(for: key) 131 + self.keyCode = code 132 + if let code, let layoutChar = AppShortcutOverride.layoutCharacter(for: code) { 133 + self.keyEquivalent = KeyEquivalent(Character(layoutChar)) 134 + self.ghosttyKeyName = layoutChar.lowercased() 135 + } else { 136 + self.keyEquivalent = KeyEquivalent(key) 137 + self.ghosttyKeyName = String(key).lowercased() 138 + } 12 139 } 13 140 14 - init(keyEquivalent: KeyEquivalent, ghosttyKeyName: String, modifiers: EventModifiers) { 141 + init(id: AppShortcutID, keyEquivalent: KeyEquivalent, ghosttyKeyName: String, modifiers: EventModifiers) { 142 + self.id = id 15 143 self.keyEquivalent = keyEquivalent 16 144 self.modifiers = modifiers 145 + self.keyCode = nil 17 146 self.ghosttyKeyName = ghosttyKeyName 18 147 } 19 148 20 - var keyboardShortcut: KeyboardShortcut { 21 - KeyboardShortcut(keyEquivalent, modifiers: modifiers) 22 - } 149 + var displayName: String { id.displayName } 23 150 24 151 var ghosttyKeybind: String { 25 152 let parts = ghosttyModifierParts + [ghosttyKeyName] ··· 30 157 "--keybind=\(ghosttyKeybind)=unbind" 31 158 } 32 159 160 + // Layout-aware display string. 33 161 var display: String { 34 - let parts = displayModifierParts + [keyEquivalent.display] 35 - return parts.joined() 162 + displaySymbols.joined() 36 163 } 37 164 38 165 var displaySymbols: [String] { 39 - display.map { String($0) } 166 + displayModifierParts + [keyDisplay] 167 + } 168 + 169 + private var keyDisplay: String { 170 + if let keyCode, let layoutChar = AppShortcutOverride.layoutCharacter(for: keyCode) { 171 + layoutChar.uppercased() 172 + } else { 173 + keyEquivalent.display 174 + } 175 + } 176 + 177 + // Resolves the effective shortcut considering user overrides. 178 + // Returns `nil` when the user has disabled this shortcut. 179 + func effective(from overrides: [AppShortcutID: AppShortcutOverride]) -> AppShortcut? { 180 + guard let override = overrides[id] else { return self } 181 + guard override.isEnabled else { return nil } 182 + return AppShortcut(id: id, override: override) 183 + } 184 + 185 + private init(id: AppShortcutID, override: AppShortcutOverride) { 186 + self.id = id 187 + self.keyEquivalent = override.keyEquivalent 188 + self.modifiers = override.eventModifiers 189 + self.keyCode = override.keyCode 190 + self.ghosttyKeyName = AppShortcutOverride.resolvedGhosttyKeyName(for: override.keyCode) 40 191 } 41 192 42 193 private var ghosttyModifierParts: [String] { ··· 58 209 } 59 210 } 60 211 212 + // MARK: - Category and grouping. 213 + 214 + enum AppShortcutCategory: String, CaseIterable, Sendable { 215 + case general 216 + case sidebar 217 + case worktrees 218 + case worktreeSelection 219 + case actions 220 + 221 + var displayName: String { 222 + switch self { 223 + case .general: "General" 224 + case .sidebar: "Sidebar" 225 + case .worktrees: "Worktrees" 226 + case .worktreeSelection: "Worktree Selection" 227 + case .actions: "Actions" 228 + } 229 + } 230 + } 231 + 232 + struct AppShortcutGroup: Identifiable { 233 + let category: AppShortcutCategory 234 + let shortcuts: [AppShortcut] 235 + 236 + var id: String { category.rawValue } 237 + } 238 + 239 + // MARK: - Registry. 240 + 61 241 enum AppShortcuts { 62 242 private struct TabSelectionBinding { 63 243 let unicode: String ··· 78 258 TabSelectionBinding(unicode: "0", physical: "digit_0", tabIndex: 10), 79 259 ] 80 260 81 - static let newWorktree = AppShortcut(key: "n", modifiers: .command) 82 - static let openSettings = AppShortcut(key: ",", modifiers: .command) 83 - static let openFinder = AppShortcut(key: "o", modifiers: .command) 84 - static let copyPath = AppShortcut(key: "c", modifiers: [.command, .shift]) 85 - static let openRepository = AppShortcut(key: "o", modifiers: [.command, .shift]) 86 - static let openPullRequest = AppShortcut(key: "g", modifiers: [.command, .control]) 87 - static let toggleLeftSidebar = AppShortcut(key: "[", modifiers: .command) 88 - static let refreshWorktrees = AppShortcut(key: "r", modifiers: [.command, .shift]) 89 - static let runScript = AppShortcut(key: "r", modifiers: .command) 90 - static let stopRunScript = AppShortcut(key: ".", modifiers: .command) 91 - static let checkForUpdates = AppShortcut(key: "u", modifiers: .command) 92 - static let archivedWorktrees = AppShortcut(key: "a", modifiers: [.command, .control]) 261 + // MARK: - Shortcut definitions. 262 + 263 + static let commandPalette = AppShortcut(id: .commandPalette, key: "p", modifiers: .command) 264 + static let openSettings = AppShortcut(id: .openSettings, key: ",", modifiers: .command) 265 + static let checkForUpdates = AppShortcut(id: .checkForUpdates, key: "u", modifiers: .command) 266 + 267 + static let toggleLeftSidebar = AppShortcut(id: .toggleLeftSidebar, key: "[", modifiers: .command) 268 + 269 + static let newWorktree = AppShortcut(id: .newWorktree, key: "n", modifiers: .command) 270 + static let refreshWorktrees = AppShortcut(id: .refreshWorktrees, key: "r", modifiers: [.command, .shift]) 271 + static let archivedWorktrees = AppShortcut(id: .archivedWorktrees, key: "a", modifiers: [.command, .control]) 272 + static let archiveWorktree = AppShortcut( 273 + id: .archiveWorktree, 274 + keyEquivalent: .delete, ghosttyKeyName: "backspace", modifiers: .command 275 + ) 276 + static let deleteWorktree = AppShortcut( 277 + id: .deleteWorktree, 278 + keyEquivalent: .delete, ghosttyKeyName: "backspace", modifiers: [.command, .shift] 279 + ) 280 + static let confirmWorktreeAction = AppShortcut( 281 + id: .confirmWorktreeAction, 282 + keyEquivalent: .return, ghosttyKeyName: "return", modifiers: .command 283 + ) 93 284 static let selectNextWorktree = AppShortcut( 285 + id: .selectNextWorktree, 94 286 keyEquivalent: .downArrow, ghosttyKeyName: "arrow_down", modifiers: [.command, .control] 95 287 ) 96 288 static let selectPreviousWorktree = AppShortcut( 289 + id: .selectPreviousWorktree, 97 290 keyEquivalent: .upArrow, ghosttyKeyName: "arrow_up", modifiers: [.command, .control] 98 291 ) 99 - static let selectWorktree1 = AppShortcut(key: "1", modifiers: [.control]) 100 - static let selectWorktree2 = AppShortcut(key: "2", modifiers: [.control]) 101 - static let selectWorktree3 = AppShortcut(key: "3", modifiers: [.control]) 102 - static let selectWorktree4 = AppShortcut(key: "4", modifiers: [.control]) 103 - static let selectWorktree5 = AppShortcut(key: "5", modifiers: [.control]) 104 - static let selectWorktree6 = AppShortcut(key: "6", modifiers: [.control]) 105 - static let selectWorktree7 = AppShortcut(key: "7", modifiers: [.control]) 106 - static let selectWorktree8 = AppShortcut(key: "8", modifiers: [.control]) 107 - static let selectWorktree9 = AppShortcut(key: "9", modifiers: [.control]) 108 - static let selectWorktree0 = AppShortcut(key: "0", modifiers: [.control]) 292 + 293 + static let selectWorktree1 = AppShortcut(id: .selectWorktree(1), key: "1", modifiers: [.control]) 294 + static let selectWorktree2 = AppShortcut(id: .selectWorktree(2), key: "2", modifiers: [.control]) 295 + static let selectWorktree3 = AppShortcut(id: .selectWorktree(3), key: "3", modifiers: [.control]) 296 + static let selectWorktree4 = AppShortcut(id: .selectWorktree(4), key: "4", modifiers: [.control]) 297 + static let selectWorktree5 = AppShortcut(id: .selectWorktree(5), key: "5", modifiers: [.control]) 298 + static let selectWorktree6 = AppShortcut(id: .selectWorktree(6), key: "6", modifiers: [.control]) 299 + static let selectWorktree7 = AppShortcut(id: .selectWorktree(7), key: "7", modifiers: [.control]) 300 + static let selectWorktree8 = AppShortcut(id: .selectWorktree(8), key: "8", modifiers: [.control]) 301 + static let selectWorktree9 = AppShortcut(id: .selectWorktree(9), key: "9", modifiers: [.control]) 302 + static let selectWorktree0 = AppShortcut(id: .selectWorktree(0), key: "0", modifiers: [.control]) 303 + 304 + static let openFinder = AppShortcut(id: .openFinder, key: "o", modifiers: .command) 305 + static let openRepository = AppShortcut(id: .openRepository, key: "o", modifiers: [.command, .shift]) 306 + static let openPullRequest = AppShortcut(id: .openPullRequest, key: "g", modifiers: [.command, .control]) 307 + static let copyPath = AppShortcut(id: .copyPath, key: "c", modifiers: [.command, .shift]) 308 + static let runScript = AppShortcut(id: .runScript, key: "r", modifiers: .command) 309 + static let stopRunScript = AppShortcut(id: .stopRunScript, key: ".", modifiers: .command) 310 + 109 311 static let worktreeSelection: [AppShortcut] = [ 110 - selectWorktree1, 111 - selectWorktree2, 112 - selectWorktree3, 113 - selectWorktree4, 114 - selectWorktree5, 115 - selectWorktree6, 116 - selectWorktree7, 117 - selectWorktree8, 118 - selectWorktree9, 119 - selectWorktree0, 312 + selectWorktree1, selectWorktree2, selectWorktree3, selectWorktree4, selectWorktree5, 313 + selectWorktree6, selectWorktree7, selectWorktree8, selectWorktree9, selectWorktree0, 314 + ] 315 + 316 + // MARK: - Groups. 317 + 318 + static let groups: [AppShortcutGroup] = [ 319 + AppShortcutGroup(category: .general, shortcuts: [commandPalette, openSettings, checkForUpdates]), 320 + AppShortcutGroup(category: .sidebar, shortcuts: [toggleLeftSidebar]), 321 + AppShortcutGroup( 322 + category: .worktrees, 323 + shortcuts: [ 324 + newWorktree, refreshWorktrees, archivedWorktrees, archiveWorktree, 325 + deleteWorktree, confirmWorktreeAction, selectNextWorktree, selectPreviousWorktree, 326 + ] 327 + ), 328 + AppShortcutGroup(category: .worktreeSelection, shortcuts: worktreeSelection), 329 + AppShortcutGroup( 330 + category: .actions, 331 + shortcuts: [openFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript] 332 + ), 120 333 ] 121 334 335 + // MARK: - All shortcuts. 336 + 337 + static let all: [AppShortcut] = groups.flatMap(\.shortcuts) 338 + 339 + // MARK: - Tab selection Ghostty bindings. 340 + 122 341 static let tabSelectionGhosttyKeybindArguments: [String] = tabSelectionBindings.flatMap { binding in 123 342 [ 124 343 "--keybind=ctrl+\(binding.unicode)=goto_tab:\(binding.tabIndex)", ··· 126 345 ] 127 346 } 128 347 348 + // MARK: - Ghostty CLI arguments. 349 + 129 350 static var ghosttyCLIKeybindArguments: [String] { 130 - all.map(\.ghosttyUnbindArgument) + tabSelectionGhosttyKeybindArguments 351 + ghosttyCLIKeybindArguments(from: [:]) 131 352 } 132 353 133 - static let all: [AppShortcut] = [ 134 - newWorktree, 135 - openSettings, 136 - openFinder, 137 - copyPath, 138 - openRepository, 139 - openPullRequest, 140 - toggleLeftSidebar, 141 - refreshWorktrees, 142 - runScript, 143 - stopRunScript, 144 - checkForUpdates, 145 - archivedWorktrees, 146 - selectNextWorktree, 147 - selectPreviousWorktree, 148 - selectWorktree1, 149 - selectWorktree2, 150 - selectWorktree3, 151 - selectWorktree4, 152 - selectWorktree5, 153 - selectWorktree6, 154 - selectWorktree7, 155 - selectWorktree8, 156 - selectWorktree9, 157 - selectWorktree0, 158 - ] 354 + static func ghosttyCLIKeybindArguments(from overrides: [AppShortcutID: AppShortcutOverride]) -> [String] { 355 + let effectiveShortcuts = all.compactMap { $0.effective(from: overrides) } 356 + return effectiveShortcuts.map(\.ghosttyUnbindArgument) + tabSelectionGhosttyKeybindArguments 357 + } 358 + 359 + // MARK: - Conflict detection. 360 + 361 + // Computes conflict warnings for all shortcuts given the current overrides. 362 + static func conflictWarnings( 363 + from overrides: [AppShortcutID: AppShortcutOverride] 364 + ) -> [AppShortcutID: String] { 365 + let reserved = AppShortcutOverride.allReservedDisplayStrings() 366 + var displayToIDs: [String: [AppShortcutID]] = [:] 367 + var warnings: [AppShortcutID: String] = [:] 368 + 369 + for shortcut in all { 370 + guard let effective = shortcut.effective(from: overrides) else { continue } 371 + let display = effective.display 372 + displayToIDs[display, default: []].append(shortcut.id) 373 + 374 + if reserved.contains(display) { 375 + warnings[shortcut.id] = "\(display) is reserved by the system." 376 + } 377 + } 378 + 379 + for (_, ids) in displayToIDs where ids.count > 1 { 380 + for id in ids { 381 + let others = ids.filter { $0 != id } 382 + let otherLabels = others.compactMap { otherID in 383 + all.first { $0.id == otherID }?.displayName 384 + } 385 + let existing = warnings[id].map { $0 + " " } ?? "" 386 + warnings[id] = existing + "Conflicts with \(otherLabels.joined(separator: ", "))." 387 + } 388 + } 389 + 390 + return warnings 391 + } 392 + } 393 + 394 + // MARK: - View modifier. 395 + 396 + extension View { 397 + @ViewBuilder 398 + func appKeyboardShortcut(_ shortcut: AppShortcut?) -> some View { 399 + if let shortcut { 400 + self.keyboardShortcut(shortcut.keyEquivalent, modifiers: shortcut.modifiers) 401 + } else { 402 + self 403 + } 404 + } 159 405 }
+14 -28
supacode/App/KeyboardShortcut+Display.swift
··· 15 15 extension KeyEquivalent { 16 16 var display: String { 17 17 switch self { 18 - case .delete: 19 - return "⌫" 20 - case .return: 21 - return "↩" 22 - case .escape: 23 - return "⎋" 24 - case .tab: 25 - return "⇥" 26 - case .space: 27 - return "␠" 28 - case .upArrow: 29 - return "↑" 30 - case .downArrow: 31 - return "↓" 32 - case .leftArrow: 33 - return "←" 34 - case .rightArrow: 35 - return "→" 36 - case .home: 37 - return "↖" 38 - case .end: 39 - return "↘" 40 - case .pageUp: 41 - return "⇞" 42 - case .pageDown: 43 - return "⇟" 44 - default: 45 - return String(character).uppercased() 18 + case .delete: "⌫" 19 + case .return: "↩" 20 + case .escape: "⎋" 21 + case .tab: "⇥" 22 + case .space: "Space" 23 + case .upArrow: "↑" 24 + case .downArrow: "↓" 25 + case .leftArrow: "←" 26 + case .rightArrow: "→" 27 + case .home: "↖" 28 + case .end: "↘" 29 + case .pageUp: "⇞" 30 + case .pageDown: "⇟" 31 + default: String(character).uppercased() 46 32 } 47 33 } 48 34 }
+9 -8
supacode/App/supacodeApp.swift
··· 16 16 17 17 private enum GhosttyCLI { 18 18 static let argv: [UnsafeMutablePointer<CChar>?] = { 19 + @Shared(.settingsFile) var settingsFile 20 + let overrides = settingsFile.global.shortcutOverrides 19 21 var args: [UnsafeMutablePointer<CChar>?] = [] 20 22 let executable = CommandLine.arguments.first ?? "supacode" 21 23 args.append(strdup(executable)) 22 - for keybindArgument in AppShortcuts.ghosttyCLIKeybindArguments { 24 + for keybindArgument in AppShortcuts.ghosttyCLIKeybindArguments(from: overrides) { 23 25 args.append(strdup(keybindArgument)) 24 26 } 25 27 args.append(nil) ··· 34 36 func applicationDidFinishLaunching(_ notification: Notification) { 35 37 // Disable press-and-hold accent menu so that key repeat works in the terminal. 36 38 UserDefaults.standard.register(defaults: [ 37 - "ApplePressAndHoldEnabled": false, 39 + "ApplePressAndHoldEnabled": false 38 40 ]) 39 41 appStore?.send(.appLaunched) 40 42 } ··· 179 181 TerminalCommands(ghosttyShortcuts: ghosttyShortcuts) 180 182 WindowCommands(ghosttyShortcuts: ghosttyShortcuts) 181 183 CommandGroup(after: .textEditing) { 184 + let cmdPalette = AppShortcuts.commandPalette.effective(from: store.settings.shortcutOverrides) 182 185 Button("Command Palette") { 183 186 store.send(.commandPalette(.togglePresented)) 184 187 } 185 - .keyboardShortcut("p", modifiers: .command) 186 - .help("Command Palette (⌘P)") 188 + .appKeyboardShortcut(cmdPalette) 189 + .help("Command Palette (\(cmdPalette?.display ?? "none"))") 187 190 } 188 191 UpdateCommands(store: store.scope(state: \.updates, action: \.updates)) 189 192 CommandGroup(replacing: .windowArrangement) { ··· 207 210 .help("Zoom (no shortcut)") 208 211 } 209 212 CommandGroup(replacing: .appSettings) { 213 + let settings = AppShortcuts.openSettings.effective(from: store.settings.shortcutOverrides) 210 214 Button("Settings...") { 211 215 SettingsWindowManager.shared.show() 212 216 } 213 - .keyboardShortcut( 214 - AppShortcuts.openSettings.keyEquivalent, 215 - modifiers: AppShortcuts.openSettings.modifiers 216 - ) 217 + .appKeyboardShortcut(settings) 217 218 } 218 219 CommandGroup(replacing: .appTermination) { 219 220 Button("Quit Supacode") {
+5 -4
supacode/Commands/SidebarCommands.swift
··· 1 + import Sharing 1 2 import SwiftUI 2 3 3 4 struct SidebarCommands: Commands { 4 5 @FocusedValue(\.toggleLeftSidebarAction) private var toggleLeftSidebarAction 6 + @Shared(.settingsFile) private var settingsFile 5 7 6 8 var body: some Commands { 9 + let toggleLeftSidebar = AppShortcuts.toggleLeftSidebar.effective(from: settingsFile.global.shortcutOverrides) 7 10 CommandGroup(replacing: .sidebar) { 8 11 Button("Toggle Left Sidebar") { 9 12 toggleLeftSidebarAction?() 10 13 } 11 - .keyboardShortcut( 12 - AppShortcuts.toggleLeftSidebar.keyEquivalent, modifiers: AppShortcuts.toggleLeftSidebar.modifiers 13 - ) 14 - .help("Toggle Left Sidebar (\(AppShortcuts.toggleLeftSidebar.display))") 14 + .appKeyboardShortcut(toggleLeftSidebar) 15 + .help("Toggle Left Sidebar (\(toggleLeftSidebar?.display ?? "none"))") 15 16 .disabled(toggleLeftSidebarAction == nil) 16 17 } 17 18 }
+1 -1
supacode/Commands/TerminalCommands.swift
··· 169 169 get { self[EndSearchActionKey.self] } 170 170 set { self[EndSearchActionKey.self] = newValue } 171 171 } 172 - } 172 + }
+5 -5
supacode/Commands/UpdateCommands.swift
··· 1 1 import ComposableArchitecture 2 + import Sharing 2 3 import SwiftUI 3 4 4 5 struct UpdateCommands: Commands { 5 6 let store: StoreOf<UpdatesFeature> 7 + @Shared(.settingsFile) private var settingsFile 6 8 7 9 var body: some Commands { 10 + let checkForUpdates = AppShortcuts.checkForUpdates.effective(from: settingsFile.global.shortcutOverrides) 8 11 CommandGroup(after: .appInfo) { 9 12 Button("Check for Updates...") { 10 13 store.send(.checkForUpdates) 11 14 } 12 - .keyboardShortcut( 13 - AppShortcuts.checkForUpdates.keyEquivalent, 14 - modifiers: AppShortcuts.checkForUpdates.modifiers 15 - ) 16 - .help("Check for Updates (\(AppShortcuts.checkForUpdates.display))") 15 + .appKeyboardShortcut(checkForUpdates) 16 + .help("Check for Updates (\(checkForUpdates?.display ?? "none"))") 17 17 } 18 18 } 19 19 }
+72 -77
supacode/Commands/WorktreeCommands.swift
··· 1 1 import AppKit 2 2 import ComposableArchitecture 3 + import Sharing 3 4 import SwiftUI 4 5 5 6 struct WorktreeCommands: Commands { ··· 17 18 } 18 19 19 20 var body: some Commands { 21 + let overrides = store.settings.shortcutOverrides 20 22 let repositories = store.repositories 21 23 let orderedRows = visibleHotkeyWorktreeRows ?? repositories.orderedWorktreeRows() 22 24 let pullRequestURL = selectedPullRequestURL 23 25 let githubIntegrationEnabled = store.settings.githubIntegrationEnabled 24 - let archiveShortcut = KeyboardShortcut(.delete, modifiers: .command).display 25 - let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display 26 + let selectNext = AppShortcuts.selectNextWorktree.effective(from: overrides) 27 + let selectPrevious = AppShortcuts.selectPreviousWorktree.effective(from: overrides) 28 + let archive = AppShortcuts.archiveWorktree.effective(from: overrides) 29 + let deleteWt = AppShortcuts.deleteWorktree.effective(from: overrides) 30 + let confirm = AppShortcuts.confirmWorktreeAction.effective(from: overrides) 31 + let openRepo = AppShortcuts.openRepository.effective(from: overrides) 32 + let openWorktree = AppShortcuts.openFinder.effective(from: overrides) 33 + let openPR = AppShortcuts.openPullRequest.effective(from: overrides) 34 + let newWt = AppShortcuts.newWorktree.effective(from: overrides) 35 + let archived = AppShortcuts.archivedWorktrees.effective(from: overrides) 36 + let refresh = AppShortcuts.refreshWorktrees.effective(from: overrides) 37 + let run = AppShortcuts.runScript.effective(from: overrides) 38 + let stop = AppShortcuts.stopRunScript.effective(from: overrides) 26 39 CommandMenu("Worktrees") { 27 40 Button("Select Next Worktree") { 28 41 store.send(.repositories(.selectNextWorktree)) 29 42 } 30 - .keyboardShortcut( 31 - AppShortcuts.selectNextWorktree.keyEquivalent, 32 - modifiers: AppShortcuts.selectNextWorktree.modifiers 33 - ) 34 - .help("Select Next Worktree (\(AppShortcuts.selectNextWorktree.display))") 43 + .appKeyboardShortcut(selectNext) 44 + .help("Select Next Worktree (\(selectNext?.display ?? "none"))") 35 45 .disabled(orderedRows.isEmpty) 36 46 Button("Select Previous Worktree") { 37 47 store.send(.repositories(.selectPreviousWorktree)) 38 48 } 39 - .keyboardShortcut( 40 - AppShortcuts.selectPreviousWorktree.keyEquivalent, 41 - modifiers: AppShortcuts.selectPreviousWorktree.modifiers 42 - ) 43 - .help("Select Previous Worktree (\(AppShortcuts.selectPreviousWorktree.display))") 49 + .appKeyboardShortcut(selectPrevious) 50 + .help("Select Previous Worktree (\(selectPrevious?.display ?? "none"))") 44 51 .disabled(orderedRows.isEmpty) 45 52 Divider() 46 - ForEach(worktreeShortcuts.indices, id: \.self) { index in 47 - let shortcut = worktreeShortcuts[index] 48 - worktreeShortcutButton(index: index, shortcut: shortcut, orderedRows: orderedRows) 53 + let worktreeShortcutsList = worktreeShortcuts(from: overrides) 54 + ForEach(worktreeShortcutsList.indices, id: \.self) { index in 55 + WorktreeShortcutButton( 56 + index: index, 57 + shortcut: worktreeShortcutsList[index], 58 + orderedRows: orderedRows, 59 + store: store 60 + ) 49 61 } 50 62 } 51 63 CommandGroup(replacing: .newItem) { 52 64 Button("Open Repository...", systemImage: "folder") { 53 65 store.send(.repositories(.setOpenPanelPresented(true))) 54 66 } 55 - .keyboardShortcut( 56 - AppShortcuts.openRepository.keyEquivalent, 57 - modifiers: AppShortcuts.openRepository.modifiers 58 - ) 59 - .help("Open Repository (\(AppShortcuts.openRepository.display))") 67 + .appKeyboardShortcut(openRepo) 68 + .help("Open Repository (\(openRepo?.display ?? "none"))") 60 69 Button("Open Worktree") { 61 70 openSelectedWorktreeAction?() 62 71 } 63 - .keyboardShortcut( 64 - AppShortcuts.openFinder.keyEquivalent, 65 - modifiers: AppShortcuts.openFinder.modifiers 66 - ) 67 - .help("Open Worktree (\(AppShortcuts.openFinder.display))") 72 + .appKeyboardShortcut(openWorktree) 73 + .help("Open Worktree (\(openWorktree?.display ?? "none"))") 68 74 .disabled(openSelectedWorktreeAction == nil) 69 75 Button("Open Pull Request on GitHub") { 70 76 if let pullRequestURL { 71 77 NSWorkspace.shared.open(pullRequestURL) 72 78 } 73 79 } 74 - .keyboardShortcut( 75 - AppShortcuts.openPullRequest.keyEquivalent, 76 - modifiers: AppShortcuts.openPullRequest.modifiers 77 - ) 78 - .help("Open Pull Request on GitHub (\(AppShortcuts.openPullRequest.display))") 80 + .appKeyboardShortcut(openPR) 81 + .help("Open Pull Request on GitHub (\(openPR?.display ?? "none"))") 79 82 .disabled(pullRequestURL == nil || !githubIntegrationEnabled) 80 83 Button("New Worktree", systemImage: "plus") { 81 84 store.send(.repositories(.createRandomWorktree)) 82 85 } 83 - .keyboardShortcut( 84 - AppShortcuts.newWorktree.keyEquivalent, modifiers: AppShortcuts.newWorktree.modifiers 85 - ) 86 - .help("New Worktree (\(AppShortcuts.newWorktree.display))") 86 + .appKeyboardShortcut(newWt) 87 + .help("New Worktree (\(newWt?.display ?? "none"))") 87 88 .disabled(!repositories.canCreateWorktree) 88 89 Button("Archived Worktrees") { 89 90 store.send(.repositories(.selectArchivedWorktrees)) 90 91 } 91 - .keyboardShortcut( 92 - AppShortcuts.archivedWorktrees.keyEquivalent, 93 - modifiers: AppShortcuts.archivedWorktrees.modifiers 94 - ) 95 - .help("Archived Worktrees (\(AppShortcuts.archivedWorktrees.display))") 92 + .appKeyboardShortcut(archived) 93 + .help("Archived Worktrees (\(archived?.display ?? "none"))") 96 94 Button("Archive Worktree") { 97 95 archiveWorktreeAction?() 98 96 } 99 - .keyboardShortcut(.delete, modifiers: .command) 100 - .help("Archive Worktree (\(archiveShortcut))") 97 + .appKeyboardShortcut(archive) 98 + .help("Archive Worktree (\(archive?.display ?? "none"))") 101 99 .disabled(archiveWorktreeAction == nil) 102 100 Button("Delete Worktree") { 103 101 deleteWorktreeAction?() 104 102 } 105 - .keyboardShortcut(.delete, modifiers: [.command, .shift]) 106 - .help("Delete Worktree (\(deleteShortcut))") 103 + .appKeyboardShortcut(deleteWt) 104 + .help("Delete Worktree (\(deleteWt?.display ?? "none"))") 107 105 .disabled(deleteWorktreeAction == nil) 108 106 Button("Confirm Worktree Action") { 109 107 confirmWorktreeAction?() 110 108 } 111 - .keyboardShortcut(.return, modifiers: .command) 112 - .help("Confirm Worktree Action (⌘↩)") 109 + .appKeyboardShortcut(confirm) 110 + .help("Confirm Worktree Action (\(confirm?.display ?? "none"))") 113 111 .disabled(confirmWorktreeAction == nil) 114 112 Button("Refresh Worktrees") { 115 113 store.send(.repositories(.refreshWorktrees)) 116 114 } 117 - .keyboardShortcut( 118 - AppShortcuts.refreshWorktrees.keyEquivalent, 119 - modifiers: AppShortcuts.refreshWorktrees.modifiers 120 - ) 121 - .help("Refresh Worktrees (\(AppShortcuts.refreshWorktrees.display))") 115 + .appKeyboardShortcut(refresh) 116 + .help("Refresh Worktrees (\(refresh?.display ?? "none"))") 122 117 Divider() 123 118 Button("Run Script") { 124 119 runScriptAction?() 125 120 } 126 - .keyboardShortcut( 127 - AppShortcuts.runScript.keyEquivalent, 128 - modifiers: AppShortcuts.runScript.modifiers 129 - ) 130 - .help("Run Script (\(AppShortcuts.runScript.display))") 121 + .appKeyboardShortcut(run) 122 + .help("Run Script (\(run?.display ?? "none"))") 131 123 .disabled(runScriptAction == nil) 132 124 Button("Stop Script") { 133 125 stopRunScriptAction?() 134 126 } 135 - .keyboardShortcut( 136 - AppShortcuts.stopRunScript.keyEquivalent, 137 - modifiers: AppShortcuts.stopRunScript.modifiers 138 - ) 139 - .help("Stop Script (\(AppShortcuts.stopRunScript.display))") 127 + .appKeyboardShortcut(stop) 128 + .help("Stop Script (\(stop?.display ?? "none"))") 140 129 .disabled(stopRunScriptAction == nil) 141 130 } 142 131 } 143 132 144 - private var worktreeShortcuts: [AppShortcut] { 145 - AppShortcuts.worktreeSelection 133 + private func worktreeShortcuts(from overrides: [AppShortcutID: AppShortcutOverride]) -> [AppShortcut?] { 134 + AppShortcuts.worktreeSelection.map { $0.effective(from: overrides) } 146 135 } 147 136 148 137 private var selectedPullRequestURL: URL? { ··· 152 141 return pullRequest.flatMap { URL(string: $0.url) } 153 142 } 154 143 155 - private func worktreeShortcutButton( 156 - index: Int, 157 - shortcut: AppShortcut, 158 - orderedRows: [WorktreeRowModel] 159 - ) -> some View { 160 - let row = orderedRows.indices.contains(index) ? orderedRows[index] : nil 161 - let title = worktreeShortcutTitle(index: index, row: row) 162 - return Button(title) { 163 - guard let row else { return } 164 - store.send(.repositories(.selectWorktree(row.id))) 165 - } 166 - .keyboardShortcut(shortcut.keyEquivalent, modifiers: shortcut.modifiers) 167 - .help("Switch to \(title) (\(shortcut.display))") 168 - .disabled(row == nil) 144 + } 145 + 146 + private struct WorktreeShortcutButton: View { 147 + let index: Int 148 + let shortcut: AppShortcut? 149 + let orderedRows: [WorktreeRowModel] 150 + let store: StoreOf<AppFeature> 151 + 152 + private var row: WorktreeRowModel? { 153 + orderedRows.indices.contains(index) ? orderedRows[index] : nil 169 154 } 170 155 171 - private func worktreeShortcutTitle(index: Int, row: WorktreeRowModel?) -> String { 156 + private var title: String { 172 157 guard let row else { return "Worktree \(index + 1)" } 173 158 let repositoryName = store.repositories.repositoryName(for: row.repositoryID) ?? "Repository" 174 159 return "\(repositoryName) — \(row.name)" 160 + } 161 + 162 + var body: some View { 163 + Button(title) { 164 + guard let row else { return } 165 + store.send(.repositories(.selectWorktree(row.id))) 166 + } 167 + .appKeyboardShortcut(shortcut) 168 + .help("Switch to \(title) (\(shortcut?.display ?? "none"))") 169 + .disabled(row == nil) 175 170 } 176 171 } 177 172
+1 -1
supacode/Features/App/Reducer/AppFeature.swift
··· 240 240 rootURL: repository.rootURL, 241 241 settings: repositorySettings 242 242 ) 243 - case .general, .notifications, .worktree, .updates, .advanced, .github: 243 + case .general, .notifications, .worktree, .shortcuts, .updates, .advanced, .github: 244 244 state.settings.repositorySettings = nil 245 245 } 246 246 return .none
+28 -27
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 1 + import Sharing 2 + 1 3 struct CommandPaletteItem: Identifiable, Equatable { 2 4 static let defaultPriorityTier = 100 3 5 ··· 47 49 var isGlobal: Bool { 48 50 switch kind { 49 51 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 50 - return true 52 + true 51 53 case .ghosttyCommand: 52 - return false 54 + false 53 55 case .openPullRequest, 54 56 .markPullRequestReady, 55 57 .mergePullRequest, ··· 58 60 .copyCiFailureLogs, 59 61 .rerunFailedJobs, 60 62 .openFailingCheckDetails: 61 - return true 63 + true 62 64 case .worktreeSelect, .removeWorktree, .archiveWorktree: 63 - return false 65 + false 64 66 #if DEBUG 65 67 case .debugTestToast: 66 - return true 68 + true 67 69 #endif 68 70 } 69 71 } ··· 71 73 var isRootAction: Bool { 72 74 switch kind { 73 75 case .checkForUpdates, .openRepository, .openSettings, .newWorktree, .refreshWorktrees: 74 - return true 76 + true 75 77 case .ghosttyCommand: 76 - return false 78 + false 77 79 case .openPullRequest, 78 80 .markPullRequestReady, 79 81 .mergePullRequest, ··· 85 87 .worktreeSelect, 86 88 .removeWorktree, 87 89 .archiveWorktree: 88 - return false 90 + false 89 91 #if DEBUG 90 92 case .debugTestToast: 91 - return false 93 + false 92 94 #endif 93 95 } 94 96 } 95 97 96 98 var appShortcut: AppShortcut? { 97 99 switch kind { 98 - case .checkForUpdates: 99 - return AppShortcuts.checkForUpdates 100 - case .openRepository: 101 - return AppShortcuts.openRepository 102 - case .openSettings: 103 - return AppShortcuts.openSettings 104 - case .newWorktree: 105 - return AppShortcuts.newWorktree 106 - case .refreshWorktrees: 107 - return AppShortcuts.refreshWorktrees 108 - case .ghosttyCommand: 109 - return nil 110 - case .openPullRequest: 111 - return AppShortcuts.openPullRequest 100 + case .checkForUpdates: AppShortcuts.checkForUpdates 101 + case .openRepository: AppShortcuts.openRepository 102 + case .openSettings: AppShortcuts.openSettings 103 + case .newWorktree: AppShortcuts.newWorktree 104 + case .refreshWorktrees: AppShortcuts.refreshWorktrees 105 + case .ghosttyCommand: nil 106 + case .openPullRequest: AppShortcuts.openPullRequest 112 107 case .markPullRequestReady, 113 108 .mergePullRequest, 114 109 .closePullRequest, ··· 119 114 .worktreeSelect, 120 115 .removeWorktree, 121 116 .archiveWorktree: 122 - return nil 117 + nil 123 118 #if DEBUG 124 119 case .debugTestToast: 125 - return nil 120 + nil 126 121 #endif 127 122 } 128 123 } 129 124 130 125 var appShortcutLabel: String? { 131 - appShortcut?.display 126 + effectiveAppShortcut?.display 132 127 } 133 128 134 129 var appShortcutSymbols: [String]? { 135 - appShortcut?.displaySymbols 130 + effectiveAppShortcut?.displaySymbols 131 + } 132 + 133 + private var effectiveAppShortcut: AppShortcut? { 134 + guard let shortcut = appShortcut else { return nil } 135 + @Shared(.settingsFile) var settingsFile 136 + return shortcut.effective(from: settingsFile.global.shortcutOverrides) 136 137 } 137 138 }
+6 -6
supacode/Features/Repositories/Views/EmptyStateView.swift
··· 1 1 import ComposableArchitecture 2 + import Sharing 2 3 import SwiftUI 3 4 4 5 struct EmptyStateView: View { 5 6 let store: StoreOf<RepositoriesFeature> 7 + @Shared(.settingsFile) private var settingsFile 6 8 7 9 var body: some View { 10 + let openRepo = AppShortcuts.openRepository.effective(from: settingsFile.global.shortcutOverrides) 8 11 VStack { 9 12 Image(systemName: "tray") 10 13 .font(.title2) ··· 12 15 Text("Open a git repository") 13 16 .font(.headline) 14 17 Text( 15 - "Press \(AppShortcuts.openRepository.display) " 18 + "Press \(openRepo?.display ?? AppShortcuts.openRepository.display) " 16 19 + "or click Open Repository to choose a repository." 17 20 ) 18 21 .font(.subheadline) ··· 20 23 Button("Open Repository...") { 21 24 store.send(.setOpenPanelPresented(true)) 22 25 } 23 - .keyboardShortcut( 24 - AppShortcuts.openRepository.keyEquivalent, 25 - modifiers: AppShortcuts.openRepository.modifiers 26 - ) 27 - .help("Open Repository (\(AppShortcuts.openRepository.display))") 26 + .appKeyboardShortcut(openRepo) 27 + .help("Open Repository (\(openRepo?.display ?? "none"))") 28 28 } 29 29 .frame(maxWidth: .infinity, maxHeight: .infinity) 30 30 .background(Color(nsColor: .windowBackgroundColor))
+9 -1
supacode/Features/Repositories/Views/PullRequestChecksPopoverButton.swift
··· 1 + import Sharing 1 2 import SwiftUI 2 3 3 4 struct PullRequestChecksPopoverButton<Label: View>: View { ··· 8 9 @State private var isHoveringPopover = false 9 10 @State private var closeTask: Task<Void, Never>? 10 11 @Environment(\.openURL) private var openURL 12 + @Shared(.settingsFile) private var settingsFile 11 13 12 14 var body: some View { 13 15 let pullRequestURL = URL(string: pullRequest.url) ··· 21 23 } 22 24 .buttonStyle(.plain) 23 25 .contentShape(.rect) 24 - .help("Open pull request on GitHub (\(AppShortcuts.openPullRequest.display)). Hover to show checks.") 26 + .help( 27 + { 28 + let overrides = settingsFile.global.shortcutOverrides 29 + let display = AppShortcuts.openPullRequest.effective(from: overrides)?.display ?? "none" 30 + return "Open pull request on GitHub (\(display)). Hover to show checks." 31 + }() 32 + ) 25 33 .accessibilityLabel("Open pull request on GitHub") 26 34 .onHover { hovering in 27 35 isHoveringButton = hovering
+8 -2
supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift
··· 1 + import Sharing 1 2 import SwiftUI 2 3 3 4 struct PullRequestChecksPopoverView: View { ··· 7 8 private let sortedChecks: [GithubPullRequestStatusCheck] 8 9 @Environment(\.analyticsClient) private var analyticsClient 9 10 @Environment(\.openURL) private var openURL 11 + @Shared(.settingsFile) private var settingsFile 10 12 11 13 init( 12 14 pullRequest: GithubPullRequest, ··· 23 25 } 24 26 return left < right 25 27 } 28 + } 29 + 30 + private var effectiveOpenPR: AppShortcut? { 31 + AppShortcuts.openPullRequest.effective(from: settingsFile.global.shortcutOverrides) 26 32 } 27 33 28 34 var body: some View { ··· 58 64 } 59 65 .buttonStyle(.plain) 60 66 .focusable(false) 61 - .help("Open pull request on GitHub (\(AppShortcuts.openPullRequest.display))") 62 - .keyboardShortcut(AppShortcuts.openPullRequest.keyboardShortcut) 67 + .help("Open pull request on GitHub (\(effectiveOpenPR?.display ?? "none"))") 68 + .appKeyboardShortcut(effectiveOpenPR) 63 69 .font(.headline) 64 70 } else { 65 71 titleLine
+9 -2
supacode/Features/Repositories/Views/PullRequestStatusButton.swift
··· 1 + import Sharing 1 2 import SwiftUI 2 3 3 4 struct PullRequestStatusButton: View { 4 5 let model: PullRequestStatusModel 5 6 @Environment(CommandKeyObserver.self) private var commandKeyObserver 7 + @Shared(.settingsFile) private var settingsFile 8 + 9 + private var openPRDisplay: String { 10 + let effective = AppShortcuts.openPullRequest.effective(from: settingsFile.global.shortcutOverrides) 11 + return effective?.display ?? "" 12 + } 6 13 7 14 var body: some View { 8 15 PullRequestChecksPopoverButton(pullRequest: model.pullRequest) { ··· 20 27 if let detailText = model.detailText { 21 28 Text( 22 29 commandKeyObserver.isPressed 23 - ? "Open on GitHub \(AppShortcuts.openPullRequest.display)" : detailText 30 + ? "Open on GitHub \(openPRDisplay)" : detailText 24 31 ) 25 32 .lineLimit(1) 26 33 } else if commandKeyObserver.isPressed { 27 - Text("Open on GitHub \(AppShortcuts.openPullRequest.display)") 34 + Text("Open on GitHub \(openPRDisplay)") 28 35 .lineLimit(1) 29 36 } 30 37 if model.detailText == nil, !commandKeyObserver.isPressed {
+8 -1
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 1 1 import ComposableArchitecture 2 + import Sharing 2 3 import SwiftUI 3 4 4 5 struct RepositorySectionView: View { ··· 12 13 let terminalManager: WorktreeTerminalManager 13 14 @Environment(\.colorScheme) private var colorScheme 14 15 @State private var isHovering = false 16 + @Shared(.settingsFile) private var settingsFile 15 17 16 18 var body: some View { 17 19 let state = store.state ··· 74 76 } 75 77 .buttonStyle(.plain) 76 78 .foregroundStyle(.secondary) 77 - .help("New Worktree (\(AppShortcuts.newWorktree.display))") 79 + .help( 80 + { 81 + let display = AppShortcuts.newWorktree.effective(from: settingsFile.global.shortcutOverrides)?.display 82 + return "New Worktree (\(display ?? "none"))" 83 + }() 84 + ) 78 85 .disabled(isRemovingRepository) 79 86 Button { 80 87 toggleExpanded()
+12 -5
supacode/Features/Repositories/Views/SidebarFooterView.swift
··· 1 1 import ComposableArchitecture 2 + import Sharing 2 3 import SwiftUI 3 4 4 5 struct SidebarFooterView: View { ··· 6 7 @Environment(\.surfaceBottomChromeBackgroundOpacity) private var surfaceBottomChromeBackgroundOpacity 7 8 @Environment(\.openURL) private var openURL 8 9 @Environment(CommandKeyObserver.self) private var commandKeyObserver 10 + @Shared(.settingsFile) private var settingsFile 9 11 10 12 var body: some View { 13 + let overrides = settingsFile.global.shortcutOverrides 14 + let openRepo = AppShortcuts.openRepository.effective(from: overrides) 15 + let refresh = AppShortcuts.refreshWorktrees.effective(from: overrides) 16 + let archived = AppShortcuts.archivedWorktrees.effective(from: overrides) 17 + let settings = AppShortcuts.openSettings.effective(from: overrides) 11 18 HStack { 12 19 Button { 13 20 store.send(.setOpenPanelPresented(true)) ··· 16 23 Label("Add Repository", systemImage: "folder.badge.plus") 17 24 .font(.callout) 18 25 if commandKeyObserver.isPressed { 19 - ShortcutHintView(text: AppShortcuts.openRepository.display, color: .secondary) 26 + ShortcutHintView(text: openRepo?.display ?? "", color: .secondary) 20 27 } 21 28 } 22 29 } 23 - .help("Add Repository (\(AppShortcuts.openRepository.display))") 30 + .help("Add Repository (\(openRepo?.display ?? "none"))") 24 31 Spacer() 25 32 Menu { 26 33 Button("Submit GitHub issue", systemImage: "exclamationmark.bubble") { ··· 42 49 .symbolEffect(.rotate, options: .repeating, isActive: store.state.isRefreshingWorktrees) 43 50 .accessibilityLabel("Refresh Worktrees") 44 51 } 45 - .help("Refresh Worktrees (\(AppShortcuts.refreshWorktrees.display))") 52 + .help("Refresh Worktrees (\(refresh?.display ?? "none"))") 46 53 .disabled(store.state.repositoryRoots.isEmpty && !store.state.isRefreshingWorktrees) 47 54 Button { 48 55 store.send(.selectArchivedWorktrees) ··· 50 57 Image(systemName: "archivebox") 51 58 .accessibilityLabel("Archived Worktrees") 52 59 } 53 - .help("Archived Worktrees (\(AppShortcuts.archivedWorktrees.display))") 60 + .help("Archived Worktrees (\(archived?.display ?? "none"))") 54 61 Button("Settings", systemImage: "gearshape") { 55 62 SettingsWindowManager.shared.show() 56 63 } 57 64 .labelStyle(.iconOnly) 58 - .help("Settings (\(AppShortcuts.openSettings.display))") 65 + .help("Settings (\(settings?.display ?? "none"))") 59 66 } 60 67 .buttonStyle(.plain) 61 68 .font(.callout)
+17 -8
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 1 1 import AppKit 2 2 import ComposableArchitecture 3 + import Sharing 3 4 import SwiftUI 4 5 5 6 struct WorktreeDetailView: View { ··· 249 250 let runScriptIsRunning: Bool 250 251 251 252 var runScriptHelpText: String { 252 - "Run Script (\(AppShortcuts.runScript.display))" 253 + @Shared(.settingsFile) var settingsFile 254 + let display = AppShortcuts.runScript.effective(from: settingsFile.global.shortcutOverrides)?.display ?? "none" 255 + return "Run Script (\(display))" 253 256 } 254 257 255 258 var stopRunScriptHelpText: String { 256 - "Stop Script (\(AppShortcuts.stopRunScript.display))" 259 + @Shared(.settingsFile) var settingsFile 260 + let display = AppShortcuts.stopRunScript.effective(from: settingsFile.global.shortcutOverrides)?.display ?? "none" 261 + return "Stop Script (\(display))" 257 262 } 258 263 } 259 264 ··· 315 320 isEnabled: toolbarState.runScriptEnabled, 316 321 runHelpText: toolbarState.runScriptHelpText, 317 322 stopHelpText: toolbarState.stopRunScriptHelpText, 318 - runShortcut: AppShortcuts.runScript.display, 319 - stopShortcut: AppShortcuts.stopRunScript.display, 323 + runShortcut: shortcutDisplay(for: AppShortcuts.runScript, fallback: ""), 324 + stopShortcut: shortcutDisplay(for: AppShortcuts.stopRunScript, fallback: ""), 320 325 runAction: onRunScript, 321 326 stopAction: onStopRunScript 322 327 ) ··· 334 339 } label: { 335 340 OpenWorktreeActionMenuLabelView( 336 341 action: resolvedOpenActionSelection, 337 - shortcutHint: showExtras ? AppShortcuts.openFinder.display : nil 342 + shortcutHint: showExtras ? shortcutDisplay(for: AppShortcuts.openFinder, fallback: "") : nil 338 343 ) 339 344 } 340 345 .help(openActionHelpText(for: resolvedOpenActionSelection, isDefault: true)) ··· 368 373 369 374 } 370 375 376 + private func shortcutDisplay(for shortcut: AppShortcut, fallback: String = "none") -> String { 377 + @Shared(.settingsFile) var settingsFile 378 + return shortcut.effective(from: settingsFile.global.shortcutOverrides)?.display ?? fallback 379 + } 380 + 371 381 private func openActionHelpText(for action: OpenWorktreeAction, isDefault: Bool) -> String { 372 - isDefault 373 - ? "\(action.title) (\(AppShortcuts.openFinder.display))" 374 - : action.title 382 + guard isDefault else { return action.title } 383 + return "\(action.title) (\(shortcutDisplay(for: AppShortcuts.openFinder)))" 375 384 } 376 385 } 377 386
+4 -1
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 1 1 import AppKit 2 2 import ComposableArchitecture 3 + import Sharing 3 4 import SwiftUI 4 5 5 6 struct WorktreeRowsView: View { ··· 278 279 279 280 private func worktreeShortcutHint(for index: Int?) -> String? { 280 281 guard let index, AppShortcuts.worktreeSelection.indices.contains(index) else { return nil } 281 - return AppShortcuts.worktreeSelection[index].display 282 + @Shared(.settingsFile) var settingsFile 283 + let overrides = settingsFile.global.shortcutOverrides 284 + return AppShortcuts.worktreeSelection[index].effective(from: overrides)?.display 282 285 } 283 286 284 287 private func togglePin(for worktreeID: Worktree.ID, isPinned: Bool) {
+9 -2
supacode/Features/Settings/Models/GlobalSettings.swift
··· 16 16 var automaticallyArchiveMergedWorktrees: Bool 17 17 var promptForWorktreeCreation: Bool 18 18 var defaultWorktreeBaseDirectoryPath: String? 19 + var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 19 20 20 21 static let `default` = GlobalSettings( 21 22 appearanceMode: .dark, ··· 34 35 deleteBranchOnDeleteWorktree: true, 35 36 automaticallyArchiveMergedWorktrees: false, 36 37 promptForWorktreeCreation: true, 37 - defaultWorktreeBaseDirectoryPath: nil 38 + defaultWorktreeBaseDirectoryPath: nil, 39 + shortcutOverrides: [:] 38 40 ) 39 41 40 42 init( ··· 54 56 deleteBranchOnDeleteWorktree: Bool, 55 57 automaticallyArchiveMergedWorktrees: Bool, 56 58 promptForWorktreeCreation: Bool, 57 - defaultWorktreeBaseDirectoryPath: String? = nil 59 + defaultWorktreeBaseDirectoryPath: String? = nil, 60 + shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:] 58 61 ) { 59 62 self.appearanceMode = appearanceMode 60 63 self.defaultEditorID = defaultEditorID ··· 73 76 self.automaticallyArchiveMergedWorktrees = automaticallyArchiveMergedWorktrees 74 77 self.promptForWorktreeCreation = promptForWorktreeCreation 75 78 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 79 + self.shortcutOverrides = shortcutOverrides 76 80 } 77 81 78 82 init(from decoder: any Decoder) throws { ··· 122 126 defaultWorktreeBaseDirectoryPath = 123 127 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 124 128 ?? Self.default.defaultWorktreeBaseDirectoryPath 129 + shortcutOverrides = 130 + try container.decodeIfPresent([AppShortcutID: AppShortcutOverride].self, forKey: .shortcutOverrides) 131 + ?? Self.default.shortcutOverrides 125 132 } 126 133 }
+42 -1
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 22 22 var automaticallyArchiveMergedWorktrees: Bool 23 23 var promptForWorktreeCreation: Bool 24 24 var defaultWorktreeBaseDirectoryPath: String 25 + var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 25 26 var selection: SettingsSection? = .general 26 27 var repositorySettings: RepositorySettingsFeature.State? 27 28 @Presents var alert: AlertState<Alert>? ··· 44 45 deleteBranchOnDeleteWorktree = settings.deleteBranchOnDeleteWorktree 45 46 automaticallyArchiveMergedWorktrees = settings.automaticallyArchiveMergedWorktrees 46 47 promptForWorktreeCreation = settings.promptForWorktreeCreation 48 + shortcutOverrides = settings.shortcutOverrides 47 49 defaultWorktreeBaseDirectoryPath = 48 50 SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" 49 51 } ··· 68 70 promptForWorktreeCreation: promptForWorktreeCreation, 69 71 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 70 72 defaultWorktreeBaseDirectoryPath 71 - ) 73 + ), 74 + shortcutOverrides: shortcutOverrides 72 75 ) 73 76 } 74 77 } ··· 79 82 case setSelection(SettingsSection?) 80 83 case setSystemNotificationsEnabled(Bool) 81 84 case showNotificationPermissionAlert(errorMessage: String?) 85 + case updateShortcut(id: AppShortcutID, override: AppShortcutOverride?) 86 + case toggleShortcutEnabled(id: AppShortcutID, enabled: Bool) 87 + case resetAllShortcuts 82 88 case repositorySettings(RepositorySettingsFeature.Action) 83 89 case alert(PresentationAction<Alert>) 84 90 case delegate(Delegate) ··· 139 145 state.deleteBranchOnDeleteWorktree = normalizedSettings.deleteBranchOnDeleteWorktree 140 146 state.automaticallyArchiveMergedWorktrees = normalizedSettings.automaticallyArchiveMergedWorktrees 141 147 state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 148 + state.shortcutOverrides = normalizedSettings.shortcutOverrides 142 149 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 143 150 state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 144 151 normalizedSettings.defaultWorktreeBaseDirectoryPath ··· 179 186 TextState(message) 180 187 } 181 188 return .none 189 + 190 + case .updateShortcut(let id, let override): 191 + if let override { 192 + state.shortcutOverrides[id] = override 193 + } else { 194 + state.shortcutOverrides.removeValue(forKey: id) 195 + } 196 + return persist(state) 197 + 198 + case .toggleShortcutEnabled(let id, let enabled): 199 + if enabled { 200 + // Re-enable: if override exists with a real binding, just flip the flag. 201 + // If it was a disabled sentinel, remove the override entirely (restore default). 202 + if var existing = state.shortcutOverrides[id] { 203 + existing.isEnabled = true 204 + if existing.keyCode == 0, existing.modifiers.isEmpty { 205 + state.shortcutOverrides.removeValue(forKey: id) 206 + } else { 207 + state.shortcutOverrides[id] = existing 208 + } 209 + } 210 + } else { 211 + if var existing = state.shortcutOverrides[id] { 212 + existing.isEnabled = false 213 + state.shortcutOverrides[id] = existing 214 + } else { 215 + state.shortcutOverrides[id] = .disabled 216 + } 217 + } 218 + return persist(state) 219 + 220 + case .resetAllShortcuts: 221 + state.shortcutOverrides = [:] 222 + return persist(state) 182 223 183 224 case .setSelection(let selection): 184 225 state.selection = selection ?? .general
+234
supacode/Features/Settings/Views/HotkeyRecorderView.swift
··· 1 + import AppKit 2 + import Carbon.HIToolbox 3 + import SwiftUI 4 + 5 + // Keycap-styled label for a single key symbol. 6 + struct Keycap: View { 7 + let symbol: String 8 + 9 + var body: some View { 10 + Text(symbol) 11 + .font(.body.weight(.medium).monospaced()) 12 + .padding(.horizontal, 6) 13 + .frame(minWidth: 28, minHeight: 28) 14 + .background(.quaternary, in: .rect(cornerRadius: 6)) 15 + } 16 + } 17 + 18 + // Popover content for recording a hotkey, Raycast-style. 19 + struct HotkeyRecorderPopover: View { 20 + let onRecorded: (AppShortcutOverride) -> Void 21 + let onCancelled: () -> Void 22 + // Returns the display name of the conflicting shortcut, or nil if no conflict. 23 + let conflictChecker: (AppShortcutOverride) -> String? 24 + 25 + private enum Result { 26 + case recorded(AppShortcutOverride) 27 + case conflict(override: AppShortcutOverride, name: String) 28 + } 29 + 30 + @State private var activeModifiers: AppShortcutOverride.ModifierFlags = [] 31 + @State private var result: Result? 32 + @State private var shakeOffset: CGFloat = 0 33 + @State private var dismissTask: Task<Void, Never>? 34 + 35 + var body: some View { 36 + VStack(spacing: 8) { 37 + switch result { 38 + case .recorded(let override): 39 + KeycapsView(override: override) 40 + HStack(spacing: 4) { 41 + Text("Recorded!") 42 + Image(systemName: "checkmark.circle.fill") 43 + .accessibilityHidden(true) 44 + } 45 + .font(.caption) 46 + .foregroundStyle(.green) 47 + 48 + case .conflict(let override, let name): 49 + KeycapsView(override: override) 50 + Text("Already used by \(name).") 51 + .font(.caption) 52 + .foregroundStyle(.red) 53 + .fixedSize(horizontal: true, vertical: false) 54 + 55 + case nil: 56 + HStack(spacing: 6) { 57 + if activeModifiers.contains(.control) { Keycap(symbol: "⌃") } 58 + if activeModifiers.contains(.option) { Keycap(symbol: "⌥") } 59 + if activeModifiers.contains(.shift) { Keycap(symbol: "⇧") } 60 + if activeModifiers.contains(.command) { Keycap(symbol: "⌘") } 61 + if activeModifiers.isEmpty { 62 + HStack(spacing: 4) { 63 + Text("e.g.,") 64 + .foregroundStyle(.tertiary) 65 + Keycap(symbol: "⇧") 66 + Keycap(symbol: "⌘") 67 + Keycap(symbol: "Space") 68 + } 69 + .opacity(0.4) 70 + } 71 + } 72 + .frame(minHeight: 28) 73 + Text("Recording…") 74 + .font(.caption) 75 + .foregroundStyle(.secondary) 76 + } 77 + } 78 + .fixedSize() 79 + .padding(.horizontal, 32) 80 + .padding(.vertical, 16) 81 + .offset(x: shakeOffset) 82 + .overlay(alignment: .topTrailing) { 83 + if case .recorded = result { 84 + } else { 85 + Button { 86 + onCancelled() 87 + } label: { 88 + Image(systemName: "xmark") 89 + .font(.caption2) 90 + .foregroundStyle(.secondary) 91 + .accessibilityLabel("Cancel") 92 + } 93 + .buttonStyle(.plain) 94 + .padding(8) 95 + } 96 + } 97 + .background { 98 + if result == nil { 99 + HotkeyRecorderRepresentable( 100 + onRecorded: handleRecorded, 101 + onCancelled: onCancelled, 102 + onModifiersChanged: { activeModifiers = $0 } 103 + ) 104 + .frame(width: 0, height: 0) 105 + } 106 + } 107 + } 108 + 109 + private func handleRecorded(_ override: AppShortcutOverride) { 110 + dismissTask?.cancel() 111 + if let name = conflictChecker(override) { 112 + result = .conflict(override: override, name: name) 113 + shake() 114 + dismissTask = Task { @MainActor in 115 + try? await Task.sleep(for: .milliseconds(1500)) 116 + guard !Task.isCancelled else { return } 117 + result = nil 118 + } 119 + } else { 120 + result = .recorded(override) 121 + onRecorded(override) 122 + dismissTask = Task { @MainActor in 123 + try? await Task.sleep(for: .milliseconds(1000)) 124 + guard !Task.isCancelled else { return } 125 + onCancelled() 126 + } 127 + } 128 + } 129 + 130 + private func shake() { 131 + withAnimation(.linear(duration: 0.06)) { shakeOffset = -8 } 132 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { 133 + withAnimation(.linear(duration: 0.06)) { shakeOffset = 8 } 134 + } 135 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { 136 + withAnimation(.linear(duration: 0.06)) { shakeOffset = -4 } 137 + } 138 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { 139 + withAnimation(.linear(duration: 0.06)) { shakeOffset = 0 } 140 + } 141 + } 142 + } 143 + 144 + // MARK: - Keycaps display. 145 + 146 + private struct KeycapsView: View { 147 + let override: AppShortcutOverride 148 + 149 + var body: some View { 150 + HStack(spacing: 3) { 151 + ForEach(Array(override.displaySymbols.enumerated()), id: \.offset) { _, symbol in 152 + Keycap(symbol: symbol) 153 + } 154 + } 155 + .frame(minHeight: 28) 156 + } 157 + } 158 + 159 + // MARK: - NSViewRepresentable for key capture. 160 + 161 + private struct HotkeyRecorderRepresentable: NSViewRepresentable { 162 + var onRecorded: (AppShortcutOverride) -> Void 163 + var onCancelled: () -> Void 164 + var onModifiersChanged: (AppShortcutOverride.ModifierFlags) -> Void 165 + 166 + func makeNSView(context: Context) -> HotkeyRecorderNSView { 167 + let view = HotkeyRecorderNSView() 168 + view.onRecorded = onRecorded 169 + view.onCancelled = onCancelled 170 + view.onModifiersChanged = onModifiersChanged 171 + return view 172 + } 173 + 174 + func updateNSView(_ nsView: HotkeyRecorderNSView, context: Context) { 175 + nsView.onRecorded = onRecorded 176 + nsView.onCancelled = onCancelled 177 + nsView.onModifiersChanged = onModifiersChanged 178 + } 179 + } 180 + 181 + // MARK: - NSView for key capture. 182 + 183 + final class HotkeyRecorderNSView: NSView { 184 + var onRecorded: ((AppShortcutOverride) -> Void)? 185 + var onCancelled: (() -> Void)? 186 + var onModifiersChanged: ((AppShortcutOverride.ModifierFlags) -> Void)? 187 + 188 + override var acceptsFirstResponder: Bool { true } 189 + 190 + override func viewDidMoveToWindow() { 191 + super.viewDidMoveToWindow() 192 + window?.makeFirstResponder(self) 193 + } 194 + 195 + // Intercept all key equivalents to prevent menu shortcuts from firing while recording. 196 + override func performKeyEquivalent(with event: NSEvent) -> Bool { 197 + keyDown(with: event) 198 + return true 199 + } 200 + 201 + override func keyDown(with event: NSEvent) { 202 + let keyCode = event.keyCode 203 + 204 + // Escape cancels recording. 205 + if keyCode == UInt16(kVK_Escape) { 206 + onCancelled?() 207 + return 208 + } 209 + 210 + // Require at least one modifier key. 211 + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) 212 + let modifierOnly = flags.subtracting([.capsLock, .numericPad, .function]) 213 + guard !modifierOnly.isEmpty else { return } 214 + 215 + var overrideFlags: AppShortcutOverride.ModifierFlags = [] 216 + if flags.contains(.command) { overrideFlags.insert(.command) } 217 + if flags.contains(.option) { overrideFlags.insert(.option) } 218 + if flags.contains(.control) { overrideFlags.insert(.control) } 219 + if flags.contains(.shift) { overrideFlags.insert(.shift) } 220 + 221 + let recorded = AppShortcutOverride(keyCode: keyCode, modifiers: overrideFlags) 222 + onRecorded?(recorded) 223 + } 224 + 225 + override func flagsChanged(with event: NSEvent) { 226 + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) 227 + var current: AppShortcutOverride.ModifierFlags = [] 228 + if flags.contains(.command) { current.insert(.command) } 229 + if flags.contains(.option) { current.insert(.option) } 230 + if flags.contains(.control) { current.insert(.control) } 231 + if flags.contains(.shift) { current.insert(.shift) } 232 + onModifiersChanged?(current) 233 + } 234 + }
+290
supacode/Features/Settings/Views/KeyboardShortcutsSettingsView.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + // Row model for the outline table. 5 + struct ShortcutTableItem: Identifiable { 6 + enum Kind { 7 + case group(AppShortcutCategory) 8 + case shortcut(AppShortcut) 9 + } 10 + 11 + let id: String 12 + let kind: Kind 13 + let children: [ShortcutTableItem]? 14 + } 15 + 16 + struct KeyboardShortcutsSettingsView: View { 17 + @Bindable var store: StoreOf<SettingsFeature> 18 + @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts 19 + 20 + @State private var searchText = "" 21 + @State private var showRestoreConfirmation = false 22 + @State private var expandedGroups: Set<String> = Set(AppShortcuts.groups.map(\.id)) 23 + 24 + private var filteredGroups: [AppShortcutGroup] { 25 + guard !searchText.isEmpty else { return AppShortcuts.groups } 26 + let query = searchText.lowercased() 27 + return AppShortcuts.groups.compactMap { group in 28 + let filtered = group.shortcuts.filter { shortcut in 29 + shortcut.displayName.lowercased().contains(query) 30 + || shortcut.display.lowercased().contains(query) 31 + } 32 + guard !filtered.isEmpty else { return nil } 33 + return AppShortcutGroup(category: group.category, shortcuts: filtered) 34 + } 35 + } 36 + 37 + private var tableItems: [ShortcutTableItem] { 38 + filteredGroups.map { group in 39 + ShortcutTableItem( 40 + id: group.id, 41 + kind: .group(group.category), 42 + children: group.shortcuts.map { shortcut in 43 + ShortcutTableItem( 44 + id: shortcut.displayName, 45 + kind: .shortcut(shortcut), 46 + children: nil 47 + ) 48 + } 49 + ) 50 + } 51 + } 52 + 53 + private var hasAnyOverrides: Bool { 54 + !store.shortcutOverrides.isEmpty 55 + } 56 + 57 + private var warningsByID: [AppShortcutID: String] { 58 + var warnings = AppShortcuts.conflictWarnings(from: store.shortcutOverrides) 59 + let terminalDisplays = ghosttyShortcuts.reservedDisplayStrings 60 + guard !terminalDisplays.isEmpty else { return warnings } 61 + for shortcut in AppShortcuts.all { 62 + guard let effective = shortcut.effective(from: store.shortcutOverrides) else { continue } 63 + guard terminalDisplays.contains(effective.display) else { continue } 64 + let existing = warnings[shortcut.id].map { $0 + " " } ?? "" 65 + warnings[shortcut.id] = existing + "Conflicts with Terminal." 66 + } 67 + return warnings 68 + } 69 + 70 + var body: some View { 71 + let warnings = warningsByID 72 + let terminalDisplays = ghosttyShortcuts.reservedDisplayStrings 73 + Table(of: ShortcutTableItem.self) { 74 + TableColumn("Name") { item in 75 + NameCell(item: item, overrides: store.shortcutOverrides) 76 + } 77 + TableColumn("Hotkey") { item in 78 + HotkeyCell(item: item, store: store, warning: warnings, terminalReservedDisplays: terminalDisplays) 79 + } 80 + .width(min: 90, ideal: 120, max: 200) 81 + TableColumn("Enabled") { item in 82 + EnabledCell(item: item, store: store) 83 + } 84 + .width(min: 60, max: 90) 85 + } rows: { 86 + ForEach(tableItems) { group in 87 + DisclosureTableRow( 88 + group, 89 + isExpanded: Binding( 90 + get: { expandedGroups.contains(group.id) }, 91 + set: { expanded in 92 + if expanded { 93 + expandedGroups.insert(group.id) 94 + } else { 95 + expandedGroups.remove(group.id) 96 + } 97 + } 98 + ) 99 + ) { 100 + if let children = group.children { 101 + ForEach(children) { child in 102 + TableRow(child) 103 + } 104 + } 105 + } 106 + } 107 + } 108 + .alternatingRowBackgrounds() 109 + .searchable(text: $searchText, placement: .toolbar, prompt: "Search...") 110 + .toolbar { 111 + ToolbarItem(placement: .primaryAction) { 112 + Button { 113 + showRestoreConfirmation = true 114 + } label: { 115 + Image(systemName: "arrow.counterclockwise") 116 + .accessibilityLabel("Restore Defaults") 117 + } 118 + .help("Restore all shortcuts to their default values.") 119 + .disabled(!hasAnyOverrides) 120 + .confirmationDialog( 121 + "Restore all keyboard shortcuts to their defaults?", 122 + isPresented: $showRestoreConfirmation, 123 + titleVisibility: .visible 124 + ) { 125 + Button("Restore Defaults", role: .destructive) { 126 + store.send(.resetAllShortcuts) 127 + } 128 + } 129 + } 130 + } 131 + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) 132 + // Align window toolbar and first column text. 133 + .padding(.leading, -6) 134 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 135 + .overlay(alignment: .top) { 136 + // Liquid Glass removes the toolbar separator in windowed mode and `NSTitlebarSeparatorStyle` 137 + // has no effect, so we draw a manual divider when not in fullscreen. 138 + ToolbarSeparatorOverlay() 139 + } 140 + } 141 + } 142 + 143 + // MARK: - Toolbar separator. 144 + 145 + private struct ToolbarSeparatorOverlay: NSViewRepresentable { 146 + func makeNSView(context: Context) -> ToolbarSeparatorView { ToolbarSeparatorView() } 147 + func updateNSView(_ nsView: ToolbarSeparatorView, context: Context) {} 148 + } 149 + 150 + // Observes fullscreen transitions and hides the separator when the system already provides one. 151 + private final class ToolbarSeparatorView: NSView { 152 + private let separator = NSBox() 153 + 154 + override var acceptsFirstResponder: Bool { false } 155 + override func hitTest(_ point: NSPoint) -> NSView? { nil } 156 + 157 + override init(frame: NSRect) { 158 + super.init(frame: frame) 159 + separator.boxType = .separator 160 + addSubview(separator) 161 + } 162 + 163 + @available(*, unavailable) 164 + required init?(coder: NSCoder) { fatalError() } 165 + 166 + override func layout() { 167 + super.layout() 168 + separator.frame = CGRect(x: 0, y: bounds.maxY, width: bounds.width, height: 1) 169 + } 170 + 171 + override func viewDidMoveToWindow() { 172 + super.viewDidMoveToWindow() 173 + updateVisibility() 174 + NotificationCenter.default.addObserver( 175 + self, selector: #selector(updateVisibility), name: NSWindow.didEnterFullScreenNotification, object: window) 176 + NotificationCenter.default.addObserver( 177 + self, selector: #selector(updateVisibility), name: NSWindow.didExitFullScreenNotification, object: window) 178 + } 179 + 180 + deinit { 181 + NotificationCenter.default.removeObserver(self) 182 + } 183 + 184 + @objc private func updateVisibility() { 185 + separator.isHidden = window?.styleMask.contains(.fullScreen) == true 186 + } 187 + } 188 + 189 + // MARK: - Cell views. 190 + 191 + private struct NameCell: View { 192 + let item: ShortcutTableItem 193 + let overrides: [AppShortcutID: AppShortcutOverride] 194 + 195 + var body: some View { 196 + switch item.kind { 197 + case .group(let category): 198 + Text(category.displayName) 199 + .padding(.vertical, 4) 200 + case .shortcut(let shortcut): 201 + Text(shortcut.displayName) 202 + .foregroundStyle(overrides[shortcut.id]?.isEnabled ?? true ? .primary : .secondary) 203 + .padding(.vertical, 4) 204 + } 205 + } 206 + } 207 + 208 + private struct HotkeyCell: View { 209 + let item: ShortcutTableItem 210 + let store: StoreOf<SettingsFeature> 211 + let warning: [AppShortcutID: String] 212 + let terminalReservedDisplays: Set<String> 213 + 214 + var body: some View { 215 + switch item.kind { 216 + case .group: 217 + EmptyView() 218 + case .shortcut(let shortcut): 219 + HotkeyCellView( 220 + shortcut: shortcut, 221 + override: store.shortcutOverrides[shortcut.id], 222 + isEnabled: store.shortcutOverrides[shortcut.id]?.isEnabled ?? true, 223 + warning: warning[shortcut.id], 224 + onRecorded: { newOverride in 225 + store.send(.updateShortcut(id: shortcut.id, override: newOverride)) 226 + }, 227 + onReset: { 228 + store.send(.updateShortcut(id: shortcut.id, override: nil)) 229 + }, 230 + conflictChecker: { proposed in 231 + let proposedDisplay = proposed.displayString 232 + // Check system-reserved shortcuts. 233 + guard !AppShortcutOverride.allReservedDisplayStrings().contains(proposedDisplay) else { 234 + return "System" 235 + } 236 + // Check terminal shortcuts. 237 + guard !terminalReservedDisplays.contains(proposedDisplay) else { return "Terminal" } 238 + // Check other app shortcuts. 239 + let overrides = store.shortcutOverrides 240 + for other in AppShortcuts.all where other.id != shortcut.id { 241 + guard let effective = other.effective(from: overrides) else { continue } 242 + guard effective.display == proposedDisplay else { continue } 243 + return other.displayName 244 + } 245 + return nil 246 + } 247 + ) 248 + } 249 + } 250 + } 251 + 252 + private struct EnabledCell: View { 253 + let item: ShortcutTableItem 254 + let store: StoreOf<SettingsFeature> 255 + 256 + var body: some View { 257 + switch item.kind { 258 + case .group(let category): 259 + if let group = AppShortcuts.groups.first(where: { $0.category == category }) { 260 + MixedStateCheckbox( 261 + state: groupCheckboxState(for: group), 262 + onToggle: { enabled in 263 + for shortcut in group.shortcuts { 264 + store.send(.toggleShortcutEnabled(id: shortcut.id, enabled: enabled)) 265 + } 266 + } 267 + ).frame(maxWidth: .infinity, alignment: .center) 268 + } 269 + case .shortcut(let shortcut): 270 + Toggle( 271 + "", 272 + isOn: Binding( 273 + get: { store.shortcutOverrides[shortcut.id]?.isEnabled ?? true }, 274 + set: { store.send(.toggleShortcutEnabled(id: shortcut.id, enabled: $0)) } 275 + ) 276 + ) 277 + .frame(maxWidth: .infinity, alignment: .center) 278 + .toggleStyle(.checkbox) 279 + .labelsHidden() 280 + } 281 + } 282 + 283 + private func groupCheckboxState(for group: AppShortcutGroup) -> CheckboxState { 284 + let overrides = store.shortcutOverrides 285 + let enabledCount = group.shortcuts.filter { overrides[$0.id]?.isEnabled ?? true }.count 286 + if enabledCount == group.shortcuts.count { return .checked } 287 + if enabledCount == 0 { return .unchecked } 288 + return .mixed 289 + } 290 + }
+55
supacode/Features/Settings/Views/MixedStateCheckbox.swift
··· 1 + import AppKit 2 + import SwiftUI 3 + 4 + // Three-state checkbox backed by NSButton for mixed-state support. 5 + enum CheckboxState: Equatable { 6 + case checked 7 + case unchecked 8 + case mixed 9 + } 10 + 11 + struct MixedStateCheckbox: NSViewRepresentable { 12 + let state: CheckboxState 13 + let onToggle: (Bool) -> Void 14 + 15 + func makeNSView(context: Context) -> NSButton { 16 + let button = NSButton( 17 + checkboxWithTitle: "", target: context.coordinator, action: #selector(Coordinator.toggled(_:))) 18 + button.allowsMixedState = true 19 + button.setContentHuggingPriority(.required, for: .horizontal) 20 + button.setContentHuggingPriority(.required, for: .vertical) 21 + applyState(to: button) 22 + return button 23 + } 24 + 25 + func updateNSView(_ button: NSButton, context: Context) { 26 + applyState(to: button) 27 + context.coordinator.onToggle = onToggle 28 + } 29 + 30 + func makeCoordinator() -> Coordinator { 31 + Coordinator(onToggle: onToggle) 32 + } 33 + 34 + private func applyState(to button: NSButton) { 35 + switch state { 36 + case .checked: button.state = .on 37 + case .unchecked: button.state = .off 38 + case .mixed: button.state = .mixed 39 + } 40 + } 41 + 42 + final class Coordinator: NSObject { 43 + var onToggle: (Bool) -> Void 44 + 45 + init(onToggle: @escaping (Bool) -> Void) { 46 + self.onToggle = onToggle 47 + } 48 + 49 + @objc func toggled(_ sender: NSButton) { 50 + // Clicking mixed or off → on; clicking on → off. 51 + let newEnabled = sender.state != .off 52 + onToggle(newEnabled) 53 + } 54 + } 55 + }
+1
supacode/Features/Settings/Views/SettingsSection.swift
··· 4 4 case general 5 5 case notifications 6 6 case worktree 7 + case shortcuts 7 8 case updates 8 9 case advanced 9 10 case github
+6
supacode/Features/Settings/Views/SettingsView.swift
··· 35 35 .tag(SettingsSection.notifications) 36 36 Label("Worktree", systemImage: "archivebox") 37 37 .tag(SettingsSection.worktree) 38 + Label("Shortcuts", systemImage: "command") 39 + .tag(SettingsSection.shortcuts) 38 40 Label("Updates", systemImage: "arrow.down.circle") 39 41 .tag(SettingsSection.updates) 40 42 Label("Advanced", systemImage: "gearshape.2") ··· 74 76 .navigationTitle("Worktree") 75 77 .navigationSubtitle("Archive behavior") 76 78 } 79 + case .shortcuts: 80 + KeyboardShortcutsSettingsView(store: settingsStore) 81 + .navigationTitle("Keyboard Shortcuts") 82 + .navigationSubtitle("Customize key bindings") 77 83 case .updates: 78 84 SettingsDetailView { 79 85 UpdatesSettingsView(settingsStore: settingsStore, updatesStore: updatesStore)
+66
supacode/Features/Settings/Views/ShortcutRowView.swift
··· 1 + import SwiftUI 2 + 3 + // Self-contained hotkey cell with local recording state for Table compatibility. 4 + struct HotkeyCellView: View { 5 + let shortcut: AppShortcut 6 + let override: AppShortcutOverride? 7 + let isEnabled: Bool 8 + let warning: String? 9 + let onRecorded: (AppShortcutOverride) -> Void 10 + let onReset: () -> Void 11 + // Returns the display name of the conflicting shortcut, or nil. 12 + let conflictChecker: (AppShortcutOverride) -> String? 13 + 14 + @State private var isRecording = false 15 + 16 + private var display: String { 17 + override.map(\.displayString) ?? shortcut.display 18 + } 19 + 20 + var body: some View { 21 + if isEnabled { 22 + let isModified = override != nil 23 + Button { 24 + isRecording = true 25 + } label: { 26 + HStack(spacing: 4) { 27 + Text(display) 28 + .foregroundStyle(isModified ? .primary : .secondary) 29 + if let warning { 30 + Image(systemName: "exclamationmark.triangle.fill") 31 + .font(.caption2) 32 + .foregroundStyle(.yellow) 33 + .accessibilityLabel("Warning") 34 + .help(warning) 35 + } 36 + Spacer() 37 + } 38 + .frame(maxWidth: .infinity) 39 + .contentShape(Rectangle()) 40 + } 41 + .buttonStyle(.borderless) 42 + .popover(isPresented: $isRecording) { 43 + HotkeyRecorderPopover( 44 + onRecorded: { newOverride in 45 + onRecorded(newOverride) 46 + }, 47 + onCancelled: { isRecording = false }, 48 + conflictChecker: conflictChecker 49 + ) 50 + } 51 + .contextMenu { 52 + Button("Change Shortcut…") { 53 + isRecording = true 54 + } 55 + Divider() 56 + Button("Reset to Default") { 57 + onReset() 58 + } 59 + .disabled(!isModified) 60 + } 61 + } else { 62 + Text("--") 63 + .foregroundStyle(.tertiary) 64 + } 65 + } 66 + }
+5 -1
supacode/Features/Settings/Views/UpdatesSettingsView.swift
··· 32 32 Button("Check for Updates Now") { 33 33 updatesStore.send(.checkForUpdates) 34 34 } 35 - .help("Check for Updates (\(AppShortcuts.checkForUpdates.display))") 35 + .help( 36 + { 37 + let display = AppShortcuts.checkForUpdates.effective(from: settingsStore.shortcutOverrides)?.display 38 + return "Check for Updates (\(display ?? "none"))" 39 + }()) 36 40 Spacer() 37 41 } 38 42 .padding(.top)
+11
supacode/Infrastructure/Ghostty/GhosttyShortcutManager.swift
··· 33 33 guard let shortcut = keyboardShortcut(for: action) else { return nil } 34 34 return shortcut.display 35 35 } 36 + 37 + // Display strings for terminal actions that have app-level menu bindings. 38 + var reservedDisplayStrings: Set<String> { 39 + _ = generation 40 + return Set(Self.terminalActions.compactMap { display(for: $0) }) 41 + } 42 + 43 + private static let terminalActions = [ 44 + "new_tab", "close_surface", "close_tab", 45 + "start_search", "search:next", "search:previous", "end_search", "search_selection", 46 + ] 36 47 }
+215
supacodeTests/AppShortcutOverrideTests.swift
··· 1 + import Carbon.HIToolbox 2 + import Foundation 3 + import SwiftUI 4 + import Testing 5 + 6 + @testable import supacode 7 + 8 + @MainActor 9 + struct AppShortcutOverrideTests { 10 + @Test func encodeDecode() throws { 11 + let override = AppShortcutOverride( 12 + keyCode: UInt16(kVK_ANSI_LeftBracket), 13 + modifiers: [.command, .shift] 14 + ) 15 + let data = try JSONEncoder().encode(override) 16 + let decoded = try JSONDecoder().decode(AppShortcutOverride.self, from: data) 17 + #expect(decoded == override) 18 + } 19 + 20 + @Test func ghosttyKeybindUsesLayoutCharacter() { 21 + let code = UInt16(kVK_ANSI_LeftBracket) 22 + let override = AppShortcutOverride(keyCode: code, modifiers: [.command]) 23 + let char = AppShortcutOverride.layoutCharacter(for: code)!.lowercased() 24 + #expect(override.ghosttyKeybind == "super+\(char)") 25 + } 26 + 27 + @Test func ghosttyKeybindLetterWithMultipleModifiers() { 28 + let override = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_A), modifiers: [.command, .shift]) 29 + let char = AppShortcutOverride.layoutCharacter(for: UInt16(kVK_ANSI_A))!.lowercased() 30 + #expect(override.ghosttyKeybind == "shift+super+\(char)") 31 + } 32 + 33 + @Test func ghosttyKeybindArrowKey() { 34 + let override = AppShortcutOverride(keyCode: UInt16(kVK_UpArrow), modifiers: [.command, .control]) 35 + #expect(override.ghosttyKeybind == "ctrl+super+arrow_up") 36 + } 37 + 38 + @Test func displayStringUsesLayoutCharacter() { 39 + let code = UInt16(kVK_ANSI_LeftBracket) 40 + let override = AppShortcutOverride(keyCode: code, modifiers: [.command]) 41 + let char = AppShortcutOverride.layoutCharacter(for: code)!.uppercased() 42 + #expect(override.displayString == "⌘\(char)") 43 + } 44 + 45 + @Test func displayStringLetterWithCommandShift() { 46 + let code = UInt16(kVK_ANSI_A) 47 + let override = AppShortcutOverride(keyCode: code, modifiers: [.command, .shift]) 48 + let char = AppShortcutOverride.layoutCharacter(for: code)!.uppercased() 49 + #expect(override.displayString == "⌘⇧\(char)") 50 + } 51 + 52 + @Test func displayStringArrowKey() { 53 + let override = AppShortcutOverride(keyCode: UInt16(kVK_UpArrow), modifiers: [.command, .control]) 54 + #expect(override.displayString == "⌘⌃↑") 55 + } 56 + 57 + @Test func keyboardShortcutConversion() { 58 + let code = UInt16(kVK_ANSI_LeftBracket) 59 + let override = AppShortcutOverride(keyCode: code, modifiers: [.command]) 60 + let shortcut = override.keyboardShortcut 61 + let char = AppShortcutOverride.layoutCharacter(for: code)! 62 + #expect(shortcut.key == KeyEquivalent(Character(char))) 63 + #expect(shortcut.modifiers == .command) 64 + } 65 + 66 + @Test func modifierFlagsCombining() { 67 + let flags: AppShortcutOverride.ModifierFlags = [.command, .shift] 68 + #expect(flags.contains(.command)) 69 + #expect(flags.contains(.shift)) 70 + #expect(!flags.contains(.option)) 71 + #expect(!flags.contains(.control)) 72 + } 73 + 74 + @Test func modifierFlagsEmpty() { 75 + let flags: AppShortcutOverride.ModifierFlags = [] 76 + #expect(!flags.contains(.command)) 77 + #expect(!flags.contains(.shift)) 78 + #expect(!flags.contains(.option)) 79 + #expect(!flags.contains(.control)) 80 + } 81 + 82 + @Test func eventModifiersConversion() { 83 + let override = AppShortcutOverride( 84 + from: [.command, .shift], 85 + keyCode: UInt16(kVK_ANSI_N) 86 + ) 87 + #expect(override.modifiers.contains(.command)) 88 + #expect(override.modifiers.contains(.shift)) 89 + #expect(!override.modifiers.contains(.option)) 90 + #expect(!override.modifiers.contains(.control)) 91 + #expect(override.eventModifiers == [.command, .shift]) 92 + } 93 + 94 + @Test func eventModifiersConversionWithOptionAndControl() { 95 + let override = AppShortcutOverride( 96 + from: [.option, .control], 97 + keyCode: UInt16(kVK_ANSI_A) 98 + ) 99 + #expect(override.modifiers.contains(.option)) 100 + #expect(override.modifiers.contains(.control)) 101 + #expect(!override.modifiers.contains(.command)) 102 + #expect(!override.modifiers.contains(.shift)) 103 + #expect(override.eventModifiers == [.option, .control]) 104 + } 105 + 106 + @Test func eventModifiersRoundTrip() { 107 + let original: SwiftUI.EventModifiers = [.command, .shift, .option, .control] 108 + let override = AppShortcutOverride(from: original, keyCode: UInt16(kVK_ANSI_A)) 109 + #expect(override.eventModifiers == original) 110 + } 111 + 112 + // MARK: - Disabled sentinel. 113 + 114 + @Test func disabledSentinel() { 115 + let disabled = AppShortcutOverride.disabled 116 + #expect(disabled.keyCode == 0) 117 + #expect(disabled.modifiers == []) 118 + #expect(disabled.isEnabled == false) 119 + } 120 + 121 + // MARK: - Coding with isEnabled. 122 + 123 + @Test func encodeDecodeWithIsEnabledFalse() throws { 124 + let override = AppShortcutOverride( 125 + keyCode: UInt16(kVK_ANSI_K), 126 + modifiers: [.command], 127 + isEnabled: false 128 + ) 129 + let data = try JSONEncoder().encode(override) 130 + let decoded = try JSONDecoder().decode(AppShortcutOverride.self, from: data) 131 + #expect(decoded == override) 132 + #expect(decoded.isEnabled == false) 133 + } 134 + 135 + // MARK: - Special key display strings. 136 + 137 + @Test func displayStringSpecialKeys() { 138 + let cases: [(Int, String)] = [ 139 + (kVK_Return, "↩"), 140 + (kVK_Escape, "⎋"), 141 + (kVK_Delete, "⌫"), 142 + (kVK_Tab, "⇥"), 143 + (kVK_Space, "Space"), 144 + (kVK_LeftArrow, "←"), 145 + (kVK_RightArrow, "→"), 146 + (kVK_DownArrow, "↓"), 147 + ] 148 + for (code, expected) in cases { 149 + let override = AppShortcutOverride(keyCode: UInt16(code), modifiers: [.command]) 150 + #expect(override.displayString == "⌘\(expected)") 151 + } 152 + } 153 + 154 + // MARK: - Special key ghostty keybinds. 155 + 156 + @Test func ghosttyKeybindSpecialKeys() { 157 + let cases: [(Int, String)] = [ 158 + (kVK_Return, "return"), 159 + (kVK_Escape, "escape"), 160 + (kVK_Delete, "backspace"), 161 + (kVK_Tab, "tab"), 162 + (kVK_Space, "space"), 163 + (kVK_LeftArrow, "arrow_left"), 164 + (kVK_RightArrow, "arrow_right"), 165 + (kVK_DownArrow, "arrow_down"), 166 + ] 167 + for (code, expected) in cases { 168 + let override = AppShortcutOverride(keyCode: UInt16(code), modifiers: [.command]) 169 + #expect(override.ghosttyKeybind == "super+\(expected)") 170 + } 171 + } 172 + 173 + // MARK: - All four modifiers. 174 + 175 + @Test func displayStringAllModifiers() { 176 + let code = UInt16(kVK_ANSI_A) 177 + let override = AppShortcutOverride(keyCode: code, modifiers: [.command, .shift, .option, .control]) 178 + let char = AppShortcutOverride.layoutCharacter(for: code)!.uppercased() 179 + #expect(override.displayString == "⌘⇧⌥⌃\(char)") 180 + } 181 + 182 + @Test func ghosttyKeybindAllModifiers() { 183 + let code = UInt16(kVK_ANSI_A) 184 + let override = AppShortcutOverride(keyCode: code, modifiers: [.command, .shift, .option, .control]) 185 + let char = AppShortcutOverride.layoutCharacter(for: code)!.lowercased() 186 + #expect(override.ghosttyKeybind == "ctrl+alt+shift+super+\(char)") 187 + } 188 + 189 + // MARK: - Reverse key code lookup. 190 + 191 + @Test func keyCodeForCharacterRoundTrips() { 192 + let letters: [Character] = Array("abcdefghijklmnopqrstuvwxyz") 193 + for letter in letters { 194 + let code = AppShortcutOverride.keyCode(for: letter) 195 + #expect(code != nil, "Expected key code for '\(letter)'") 196 + if let code { 197 + let resolved = AppShortcutOverride.layoutCharacter(for: code) 198 + #expect(resolved?.lowercased() == String(letter)) 199 + } 200 + } 201 + } 202 + 203 + @Test func keyCodeForUnknownCharacterReturnsNil() { 204 + #expect(AppShortcutOverride.keyCode(for: "😀") == nil) 205 + } 206 + 207 + // MARK: - Hashing. 208 + 209 + @Test func differentIsEnabledProducesDifferentHash() { 210 + let enabled = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_A), modifiers: [.command], isEnabled: true) 211 + let disabled = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_A), modifiers: [.command], isEnabled: false) 212 + #expect(enabled != disabled) 213 + #expect(enabled.hashValue != disabled.hashValue) 214 + } 215 + }
+113
supacodeTests/AppShortcutsTests.swift
··· 1 + import Carbon.HIToolbox 1 2 import CustomDump 2 3 import SwiftUI 3 4 import Testing ··· 71 72 for argument in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].map({ "--keybind=ctrl+digit_\($0)=unbind" }) { 72 73 #expect(arguments.contains(argument) == false) 73 74 } 75 + } 76 + 77 + // MARK: - Shortcut identity. 78 + 79 + @Test func allShortcutsHaveUniqueIDs() { 80 + let ids = AppShortcuts.all.map(\.id) 81 + #expect(Set(ids).count == ids.count) 82 + } 83 + 84 + @Test func displayNameFromID() { 85 + #expect(AppShortcuts.newWorktree.displayName == "New Worktree") 86 + #expect(AppShortcuts.openPullRequest.displayName == "Open Pull Request") 87 + #expect(AppShortcuts.toggleLeftSidebar.displayName == "Toggle Left Sidebar") 88 + #expect(AppShortcuts.selectWorktree1.displayName == "Select Worktree 1") 89 + #expect(AppShortcuts.selectWorktree0.displayName == "Select Worktree 0") 90 + } 91 + 92 + // MARK: - Effective shortcut resolution. 93 + 94 + @Test func effectiveReturnsDefaultWhenNoOverride() { 95 + let result = AppShortcuts.newWorktree.effective(from: [:]) 96 + #expect(result?.display == AppShortcuts.newWorktree.display) 97 + } 98 + 99 + @Test func effectiveReturnsOverrideWhenPresent() { 100 + let override = AppShortcutOverride( 101 + keyCode: UInt16(kVK_ANSI_R), 102 + modifiers: [.command, .shift] 103 + ) 104 + let result = AppShortcuts.newWorktree.effective(from: [.newWorktree: override]) 105 + #expect(result?.display == "⌘⇧R") 106 + } 107 + 108 + @Test func ghosttyCLIArgumentsWithOverrides() { 109 + let override = AppShortcutOverride( 110 + keyCode: UInt16(kVK_ANSI_K), 111 + modifiers: [.command] 112 + ) 113 + let args = AppShortcuts.ghosttyCLIKeybindArguments(from: [.newWorktree: override]) 114 + // The override should produce an unbind for super+k instead of super+n. 115 + #expect(args.contains("--keybind=super+k=unbind")) 116 + #expect(!args.contains("--keybind=super+n=unbind")) 117 + } 118 + 119 + // MARK: - Groups. 120 + 121 + @Test func groupsCoverAllShortcuts() { 122 + let groupIDs = Set(AppShortcuts.groups.flatMap(\.shortcuts).map(\.id)) 123 + let allIDs = Set(AppShortcuts.all.map(\.id)) 124 + #expect(groupIDs == allIDs) 125 + } 126 + 127 + // MARK: - Effective shortcut disabled. 128 + 129 + @Test func effectiveReturnsNilWhenDisabled() { 130 + let result = AppShortcuts.newWorktree.effective(from: [.newWorktree: .disabled]) 131 + #expect(result == nil) 132 + } 133 + 134 + @Test func effectiveReturnsNilWhenOverrideHasIsEnabledFalse() { 135 + let override = AppShortcutOverride( 136 + keyCode: UInt16(kVK_ANSI_K), 137 + modifiers: [.command], 138 + isEnabled: false 139 + ) 140 + let result = AppShortcuts.newWorktree.effective(from: [.newWorktree: override]) 141 + #expect(result == nil) 142 + } 143 + 144 + // MARK: - Ghostty unbind argument format. 145 + 146 + @Test func ghosttyUnbindArgument() { 147 + let shortcut = AppShortcuts.openSettings 148 + #expect(shortcut.ghosttyUnbindArgument.hasPrefix("--keybind=")) 149 + #expect(shortcut.ghosttyUnbindArgument.hasSuffix("=unbind")) 150 + } 151 + 152 + // MARK: - CLI arguments with disabled overrides. 153 + 154 + @Test func ghosttyCLIArgumentsExcludeDisabledShortcuts() { 155 + let args = AppShortcuts.ghosttyCLIKeybindArguments(from: [.newWorktree: .disabled]) 156 + // A disabled shortcut should not appear in the unbind list. 157 + let defaultUnbind = AppShortcuts.newWorktree.ghosttyUnbindArgument 158 + #expect(!args.contains(defaultUnbind)) 159 + } 160 + 161 + // MARK: - Category display names. 162 + 163 + @Test func categoryDisplayNames() { 164 + expectNoDifference( 165 + AppShortcutCategory.allCases.map(\.displayName), 166 + ["General", "Sidebar", "Worktrees", "Worktree Selection", "Actions"] 167 + ) 168 + } 169 + 170 + // MARK: - Groups match categories. 171 + 172 + @Test func groupsCategoriesMatchAllCases() { 173 + let groupCategories = AppShortcuts.groups.map(\.category) 174 + expectNoDifference(groupCategories, AppShortcutCategory.allCases) 175 + } 176 + 177 + // MARK: - Override ghost keybind propagation. 178 + 179 + @Test func effectiveOverrideGhosttyKeybindMatchesOverrideKeybind() { 180 + let override = AppShortcutOverride( 181 + keyCode: UInt16(kVK_ANSI_R), 182 + modifiers: [.command, .shift] 183 + ) 184 + let effective = AppShortcuts.newWorktree.effective(from: [.newWorktree: override]) 185 + #expect(effective != nil) 186 + #expect(effective?.ghosttyKeybind == override.ghosttyKeybind) 74 187 } 75 188 }
+150
supacodeTests/SettingsFeatureTests.swift
··· 1 + import Carbon.HIToolbox 1 2 import ComposableArchitecture 2 3 import CustomDump 3 4 import DependenciesTestSupport ··· 246 247 await store.receive(\.delegate.settingsChanged) 247 248 #expect(store.state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath == expectedPath) 248 249 #expect(settingsFile.global.defaultWorktreeBaseDirectoryPath == expectedPath) 250 + } 251 + 252 + // MARK: - Keyboard shortcut overrides. 253 + 254 + @Test(.dependencies) func updateShortcutPersistsOverride() async { 255 + @Shared(.settingsFile) var settingsFile 256 + $settingsFile.withLock { $0.global = .default } 257 + 258 + let store = TestStore(initialState: SettingsFeature.State()) { 259 + SettingsFeature() 260 + } 261 + 262 + let override = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) 263 + await store.send(.updateShortcut(id: .newWorktree, override: override)) { 264 + $0.shortcutOverrides[.newWorktree] = override 265 + } 266 + await store.receive(\.delegate.settingsChanged) 267 + #expect(settingsFile.global.shortcutOverrides[.newWorktree] == override) 268 + } 269 + 270 + @Test(.dependencies) func updateShortcutRemovesOverride() async { 271 + let override = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) 272 + var initialSettings = GlobalSettings.default 273 + initialSettings.shortcutOverrides = [.newWorktree: override] 274 + @Shared(.settingsFile) var settingsFile 275 + $settingsFile.withLock { $0.global = initialSettings } 276 + 277 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 278 + SettingsFeature() 279 + } 280 + 281 + await store.send(.updateShortcut(id: .newWorktree, override: nil)) { 282 + $0.shortcutOverrides.removeValue(forKey: .newWorktree) 283 + } 284 + await store.receive(\.delegate.settingsChanged) 285 + #expect(settingsFile.global.shortcutOverrides[.newWorktree] == nil) 286 + } 287 + 288 + @Test(.dependencies) func resetAllShortcutsClearsOverrides() async { 289 + let override = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) 290 + var initialSettings = GlobalSettings.default 291 + initialSettings.shortcutOverrides = [ 292 + .newWorktree: override, 293 + .openSettings: override, 294 + ] 295 + @Shared(.settingsFile) var settingsFile 296 + $settingsFile.withLock { $0.global = initialSettings } 297 + 298 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 299 + SettingsFeature() 300 + } 301 + 302 + await store.send(.resetAllShortcuts) { 303 + $0.shortcutOverrides = [:] 304 + } 305 + await store.receive(\.delegate.settingsChanged) 306 + #expect(settingsFile.global.shortcutOverrides.isEmpty) 307 + } 308 + 309 + // MARK: - Toggle shortcut enabled. 310 + 311 + @Test(.dependencies) func toggleShortcutDisabledInsertsDisabledSentinel() async { 312 + @Shared(.settingsFile) var settingsFile 313 + $settingsFile.withLock { $0.global = .default } 314 + 315 + let store = TestStore(initialState: SettingsFeature.State()) { 316 + SettingsFeature() 317 + } 318 + 319 + await store.send(.toggleShortcutEnabled(id: .newWorktree, enabled: false)) { 320 + $0.shortcutOverrides[.newWorktree] = .disabled 321 + } 322 + await store.receive(\.delegate.settingsChanged) 323 + #expect(settingsFile.global.shortcutOverrides[.newWorktree] == .disabled) 324 + } 325 + 326 + @Test(.dependencies) func toggleShortcutDisabledWithExistingOverrideFlipsFlag() async { 327 + let override = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) 328 + var initialSettings = GlobalSettings.default 329 + initialSettings.shortcutOverrides = [.newWorktree: override] 330 + @Shared(.settingsFile) var settingsFile 331 + $settingsFile.withLock { $0.global = initialSettings } 332 + 333 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 334 + SettingsFeature() 335 + } 336 + 337 + let expected = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command], isEnabled: false) 338 + await store.send(.toggleShortcutEnabled(id: .newWorktree, enabled: false)) { 339 + $0.shortcutOverrides[.newWorktree] = expected 340 + } 341 + await store.receive(\.delegate.settingsChanged) 342 + #expect(settingsFile.global.shortcutOverrides[.newWorktree] == expected) 343 + } 344 + 345 + @Test(.dependencies) func toggleShortcutEnabledRemovesDisabledSentinel() async { 346 + var initialSettings = GlobalSettings.default 347 + initialSettings.shortcutOverrides = [.newWorktree: .disabled] 348 + @Shared(.settingsFile) var settingsFile 349 + $settingsFile.withLock { $0.global = initialSettings } 350 + 351 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 352 + SettingsFeature() 353 + } 354 + 355 + await store.send(.toggleShortcutEnabled(id: .newWorktree, enabled: true)) { 356 + $0.shortcutOverrides.removeValue(forKey: .newWorktree) 357 + } 358 + await store.receive(\.delegate.settingsChanged) 359 + #expect(settingsFile.global.shortcutOverrides[.newWorktree] == nil) 360 + } 361 + 362 + @Test(.dependencies) func toggleShortcutEnabledReEnablesCustomOverride() async { 363 + let override = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command], isEnabled: false) 364 + var initialSettings = GlobalSettings.default 365 + initialSettings.shortcutOverrides = [.newWorktree: override] 366 + @Shared(.settingsFile) var settingsFile 367 + $settingsFile.withLock { $0.global = initialSettings } 368 + 369 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 370 + SettingsFeature() 371 + } 372 + 373 + let expected = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command], isEnabled: true) 374 + await store.send(.toggleShortcutEnabled(id: .newWorktree, enabled: true)) { 375 + $0.shortcutOverrides[.newWorktree] = expected 376 + } 377 + await store.receive(\.delegate.settingsChanged) 378 + #expect(settingsFile.global.shortcutOverrides[.newWorktree] == expected) 379 + } 380 + 381 + // MARK: - Settings loaded includes overrides. 382 + 383 + @Test(.dependencies) func settingsLoadedIncludesShortcutOverrides() async { 384 + let override = AppShortcutOverride(keyCode: UInt16(kVK_ANSI_K), modifiers: [.command]) 385 + var loaded = GlobalSettings.default 386 + loaded.shortcutOverrides = [.openSettings: override] 387 + @Shared(.settingsFile) var settingsFile 388 + $settingsFile.withLock { $0.global = loaded } 389 + 390 + let store = TestStore(initialState: SettingsFeature.State()) { 391 + SettingsFeature() 392 + } 393 + 394 + await store.send(.task) 395 + await store.receive(\.settingsLoaded) { 396 + $0.shortcutOverrides = [.openSettings: override] 397 + } 398 + await store.receive(\.delegate.settingsChanged) 249 399 } 250 400 }