native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #87 from onevcat/onevtail/issue-82-keybinding-m1

[M1] Keybinding schema + resolver + migration layer + tests

authored by

Wei Wang and committed by
GitHub
4cf7611f 691a5f58

+627
+4
supacode/App/AppShortcuts.swift
··· 21 21 KeyboardShortcut(keyEquivalent, modifiers: modifiers) 22 22 } 23 23 24 + var keyToken: String { 25 + ghosttyKeyName 26 + } 27 + 24 28 var ghosttyKeybind: String { 25 29 let parts = ghosttyModifierParts + [ghosttyKeyName] 26 30 return parts.joined(separator: "+")
+392
supacode/App/KeybindingSchema.swift
··· 1 + import Foundation 2 + import SwiftUI 3 + 4 + nonisolated enum KeybindingPlatform: String, Codable, Equatable, Sendable { 5 + case macOS 6 + } 7 + 8 + nonisolated enum KeybindingScope: String, Codable, Equatable, Sendable { 9 + case configurableAppAction 10 + case systemFixedAppAction 11 + case localInteraction 12 + case customCommand 13 + } 14 + 15 + nonisolated enum KeybindingConflictPolicy: String, Codable, Equatable, Sendable { 16 + case warnAndPreferUserOverride 17 + case disallowUserOverride 18 + case localOnly 19 + } 20 + 21 + nonisolated enum KeybindingSource: String, Equatable, Sendable { 22 + case appDefault 23 + case migratedLegacy 24 + case userOverride 25 + } 26 + 27 + nonisolated struct KeybindingModifiers: Codable, Equatable, Sendable { 28 + var command: Bool 29 + var shift: Bool 30 + var option: Bool 31 + var control: Bool 32 + 33 + init(command: Bool = false, shift: Bool = false, option: Bool = false, control: Bool = false) { 34 + self.command = command 35 + self.shift = shift 36 + self.option = option 37 + self.control = control 38 + } 39 + 40 + var eventModifiers: EventModifiers { 41 + var value: EventModifiers = [] 42 + if command { 43 + value.insert(.command) 44 + } 45 + if shift { 46 + value.insert(.shift) 47 + } 48 + if option { 49 + value.insert(.option) 50 + } 51 + if control { 52 + value.insert(.control) 53 + } 54 + return value 55 + } 56 + } 57 + 58 + nonisolated struct Keybinding: Codable, Equatable, Sendable { 59 + var key: String 60 + var modifiers: KeybindingModifiers 61 + 62 + init(key: String, modifiers: KeybindingModifiers) { 63 + self.key = Self.normalizeKey(key) 64 + self.modifiers = modifiers 65 + } 66 + 67 + var isValid: Bool { 68 + !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 69 + } 70 + 71 + var display: String { 72 + var symbols: [String] = [] 73 + if modifiers.command { 74 + symbols.append("⌘") 75 + } 76 + if modifiers.shift { 77 + symbols.append("⇧") 78 + } 79 + if modifiers.option { 80 + symbols.append("⌥") 81 + } 82 + if modifiers.control { 83 + symbols.append("⌃") 84 + } 85 + 86 + symbols.append(Self.displayKey(for: key)) 87 + return symbols.joined() 88 + } 89 + 90 + private static func normalizeKey(_ raw: String) -> String { 91 + raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 92 + } 93 + 94 + private static func displayKey(for key: String) -> String { 95 + switch key { 96 + case "return": 97 + return "↩" 98 + case "arrow_up": 99 + return "↑" 100 + case "arrow_down": 101 + return "↓" 102 + case "arrow_left": 103 + return "←" 104 + case "arrow_right": 105 + return "→" 106 + default: 107 + return key.uppercased() 108 + } 109 + } 110 + } 111 + 112 + /// Versioned command schema for keybinding definitions. 113 + /// 114 + /// `version` only tracks schema structure/versioning of this data model, 115 + /// not the release version of the app. 116 + nonisolated struct KeybindingSchemaDocument: Codable, Equatable, Sendable { 117 + static let currentVersion = 1 118 + 119 + var version: Int 120 + var commands: [KeybindingCommandSchema] 121 + 122 + init(version: Int = currentVersion, commands: [KeybindingCommandSchema]) { 123 + self.version = version 124 + self.commands = commands 125 + } 126 + } 127 + 128 + nonisolated struct KeybindingCommandSchema: Codable, Equatable, Sendable { 129 + var id: String 130 + var title: String 131 + var scope: KeybindingScope 132 + var platform: KeybindingPlatform 133 + var allowUserOverride: Bool 134 + var conflictPolicy: KeybindingConflictPolicy 135 + var defaultBinding: Keybinding? 136 + 137 + init( 138 + id: String, 139 + title: String, 140 + scope: KeybindingScope, 141 + platform: KeybindingPlatform, 142 + allowUserOverride: Bool, 143 + conflictPolicy: KeybindingConflictPolicy, 144 + defaultBinding: Keybinding? 145 + ) { 146 + self.id = id 147 + self.title = title 148 + self.scope = scope 149 + self.platform = platform 150 + self.allowUserOverride = allowUserOverride 151 + self.conflictPolicy = conflictPolicy 152 + self.defaultBinding = defaultBinding 153 + } 154 + } 155 + 156 + nonisolated struct KeybindingUserOverrideStore: Codable, Equatable, Sendable { 157 + static let empty = KeybindingUserOverrideStore(version: KeybindingSchemaDocument.currentVersion, overrides: [:]) 158 + 159 + var version: Int 160 + var overrides: [String: KeybindingUserOverride] 161 + 162 + init(version: Int = KeybindingSchemaDocument.currentVersion, overrides: [String: KeybindingUserOverride]) { 163 + self.version = version 164 + self.overrides = overrides 165 + } 166 + } 167 + 168 + nonisolated struct KeybindingUserOverride: Codable, Equatable, Sendable { 169 + var binding: Keybinding? 170 + var isEnabled: Bool 171 + 172 + init(binding: Keybinding?, isEnabled: Bool = true) { 173 + self.binding = binding 174 + self.isEnabled = isEnabled 175 + } 176 + } 177 + 178 + nonisolated struct ResolvedKeybinding: Equatable, Sendable { 179 + var command: KeybindingCommandSchema 180 + var binding: Keybinding? 181 + var source: KeybindingSource 182 + } 183 + 184 + nonisolated struct ResolvedKeybindingMap: Equatable, Sendable { 185 + var bindingsByCommandID: [String: ResolvedKeybinding] 186 + 187 + func binding(for commandID: String) -> ResolvedKeybinding? { 188 + bindingsByCommandID[commandID] 189 + } 190 + } 191 + 192 + nonisolated enum KeybindingResolver { 193 + static func resolve( 194 + schema: KeybindingSchemaDocument, 195 + userOverrides: KeybindingUserOverrideStore = .empty, 196 + migratedOverrides: [String: KeybindingUserOverride] = [:] 197 + ) -> ResolvedKeybindingMap { 198 + var result: [String: ResolvedKeybinding] = [:] 199 + 200 + for command in schema.commands { 201 + var resolvedBinding = command.defaultBinding 202 + var source: KeybindingSource = .appDefault 203 + 204 + if command.allowUserOverride { 205 + if let migrated = migratedOverrides[command.id] { 206 + let applied = apply(override: migrated, currentBinding: resolvedBinding) 207 + resolvedBinding = applied.binding 208 + if applied.didChange { 209 + source = .migratedLegacy 210 + } 211 + } 212 + 213 + if let user = userOverrides.overrides[command.id] { 214 + let applied = apply(override: user, currentBinding: resolvedBinding) 215 + resolvedBinding = applied.binding 216 + if applied.didChange { 217 + source = .userOverride 218 + } 219 + } 220 + } 221 + 222 + result[command.id] = ResolvedKeybinding( 223 + command: command, 224 + binding: resolvedBinding, 225 + source: source 226 + ) 227 + } 228 + 229 + return ResolvedKeybindingMap(bindingsByCommandID: result) 230 + } 231 + 232 + private static func apply( 233 + override: KeybindingUserOverride, 234 + currentBinding: Keybinding? 235 + ) -> (binding: Keybinding?, didChange: Bool) { 236 + if !override.isEnabled { 237 + return (nil, currentBinding != nil) 238 + } 239 + 240 + guard let binding = override.binding else { 241 + return (currentBinding, false) 242 + } 243 + 244 + return (binding, currentBinding != binding) 245 + } 246 + } 247 + 248 + nonisolated struct KeybindingMigrationIssue: Equatable, Sendable { 249 + nonisolated enum Reason: Equatable, Sendable { 250 + case missingCommandID 251 + case invalidShortcut 252 + } 253 + 254 + var commandTitle: String 255 + var reason: Reason 256 + var debugDescription: String 257 + } 258 + 259 + nonisolated struct KeybindingMigrationResult: Equatable, Sendable { 260 + var overrides: [String: KeybindingUserOverride] 261 + var issues: [KeybindingMigrationIssue] 262 + 263 + var migratedCount: Int { 264 + overrides.count 265 + } 266 + } 267 + 268 + nonisolated enum LegacyCustomCommandShortcutMigration { 269 + private static let logger = SupaLogger("Shortcuts") 270 + 271 + static func migrate(commands: [UserCustomCommand]) -> KeybindingMigrationResult { 272 + var overrides: [String: KeybindingUserOverride] = [:] 273 + var issues: [KeybindingMigrationIssue] = [] 274 + 275 + for command in commands { 276 + let commandID = command.id.trimmingCharacters(in: .whitespacesAndNewlines) 277 + guard !commandID.isEmpty else { 278 + let issue = KeybindingMigrationIssue( 279 + commandTitle: command.resolvedTitle, 280 + reason: .missingCommandID, 281 + debugDescription: "Custom command has an empty id." 282 + ) 283 + issues.append(issue) 284 + logger.warning( 285 + "shortcut_migration status=unmapped reason=missingCommandID title=\(command.resolvedTitle)" 286 + ) 287 + continue 288 + } 289 + 290 + guard let rawShortcut = command.shortcut else { 291 + continue 292 + } 293 + 294 + guard rawShortcut.isValid else { 295 + let issue = KeybindingMigrationIssue( 296 + commandTitle: command.resolvedTitle, 297 + reason: .invalidShortcut, 298 + debugDescription: "Shortcut key must be exactly one character." 299 + ) 300 + issues.append(issue) 301 + logger.warning( 302 + "shortcut_migration status=unmapped reason=invalidShortcut title=\(command.resolvedTitle)" 303 + ) 304 + continue 305 + } 306 + 307 + let shortcut = rawShortcut.normalized() 308 + let binding = Keybinding( 309 + key: shortcut.key, 310 + modifiers: .init(shortcut.modifiers) 311 + ) 312 + overrides[customCommandBindingID(for: commandID)] = KeybindingUserOverride(binding: binding) 313 + } 314 + 315 + return KeybindingMigrationResult(overrides: overrides, issues: issues) 316 + } 317 + 318 + static func customCommandBindingID(for commandID: String) -> String { 319 + "custom_command.\(commandID)" 320 + } 321 + } 322 + 323 + extension KeybindingModifiers { 324 + nonisolated init(_ userModifiers: UserCustomShortcutModifiers) { 325 + self.init( 326 + command: userModifiers.command, 327 + shift: userModifiers.shift, 328 + option: userModifiers.option, 329 + control: userModifiers.control 330 + ) 331 + } 332 + 333 + nonisolated init(_ eventModifiers: EventModifiers) { 334 + self.init( 335 + command: eventModifiers.contains(.command), 336 + shift: eventModifiers.contains(.shift), 337 + option: eventModifiers.contains(.option), 338 + control: eventModifiers.contains(.control) 339 + ) 340 + } 341 + } 342 + 343 + extension KeybindingScope { 344 + init(_ scope: AppShortcuts.Scope) { 345 + switch scope { 346 + case .configurableAppAction: 347 + self = .configurableAppAction 348 + case .systemFixedAppAction: 349 + self = .systemFixedAppAction 350 + case .localInteraction: 351 + self = .localInteraction 352 + } 353 + } 354 + } 355 + 356 + extension KeybindingSchemaDocument { 357 + static var appDefaultsV1: KeybindingSchemaDocument { 358 + KeybindingSchemaDocument( 359 + version: currentVersion, 360 + commands: AppShortcuts.bindings.map { binding in 361 + KeybindingCommandSchema( 362 + id: binding.id, 363 + title: binding.title, 364 + scope: .init(binding.scope), 365 + platform: .macOS, 366 + allowUserOverride: binding.scope == .configurableAppAction, 367 + conflictPolicy: binding.scope.conflictPolicy, 368 + defaultBinding: binding.shortcut.keybinding 369 + ) 370 + } 371 + ) 372 + } 373 + } 374 + 375 + extension AppShortcuts.Scope { 376 + fileprivate var conflictPolicy: KeybindingConflictPolicy { 377 + switch self { 378 + case .configurableAppAction: 379 + return .warnAndPreferUserOverride 380 + case .systemFixedAppAction: 381 + return .disallowUserOverride 382 + case .localInteraction: 383 + return .localOnly 384 + } 385 + } 386 + } 387 + 388 + extension AppShortcut { 389 + fileprivate var keybinding: Keybinding { 390 + Keybinding(key: keyToken, modifiers: .init(modifiers)) 391 + } 392 + }
+231
supacodeTests/KeybindingSchemaTests.swift
··· 1 + import CustomDump 2 + import Foundation 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + @MainActor 8 + struct KeybindingSchemaTests { 9 + @Test func schemaEncodeDecodeRoundTripsWithVersion() throws { 10 + let schema = KeybindingSchemaDocument( 11 + version: 1, 12 + commands: [ 13 + KeybindingCommandSchema( 14 + id: "toggle_left_sidebar", 15 + title: "Toggle Left Sidebar", 16 + scope: .configurableAppAction, 17 + platform: .macOS, 18 + allowUserOverride: true, 19 + conflictPolicy: .warnAndPreferUserOverride, 20 + defaultBinding: Keybinding( 21 + key: "s", 22 + modifiers: KeybindingModifiers(command: true, control: true) 23 + ) 24 + ), 25 + ] 26 + ) 27 + 28 + let encoded = try JSONEncoder().encode(schema) 29 + let decoded = try JSONDecoder().decode(KeybindingSchemaDocument.self, from: encoded) 30 + 31 + expectNoDifference(decoded, schema) 32 + #expect(decoded.version == 1) 33 + } 34 + 35 + @Test func appDefaultsSchemaIncludesCurrentRegistryAndVersion() { 36 + let schema = KeybindingSchemaDocument.appDefaultsV1 37 + 38 + #expect(schema.version == KeybindingSchemaDocument.currentVersion) 39 + 40 + let commandIDs = Set(schema.commands.map(\.id)) 41 + #expect(commandIDs.contains("new_worktree")) 42 + #expect(commandIDs.contains("command_palette")) 43 + #expect(commandIDs.contains("select_all_canvas_cards")) 44 + 45 + let commandPalette = schema.commands.first(where: { $0.id == "command_palette" }) 46 + #expect(commandPalette?.allowUserOverride == false) 47 + #expect(commandPalette?.conflictPolicy == .disallowUserOverride) 48 + } 49 + 50 + @Test func resolverAppliesUserOverrideOverMigratedOverride() { 51 + let schema = KeybindingSchemaDocument( 52 + version: 1, 53 + commands: [ 54 + KeybindingCommandSchema( 55 + id: "command.alpha", 56 + title: "Alpha", 57 + scope: .configurableAppAction, 58 + platform: .macOS, 59 + allowUserOverride: true, 60 + conflictPolicy: .warnAndPreferUserOverride, 61 + defaultBinding: Keybinding( 62 + key: "a", 63 + modifiers: KeybindingModifiers(command: true) 64 + ) 65 + ), 66 + KeybindingCommandSchema( 67 + id: "command.beta", 68 + title: "Beta", 69 + scope: .systemFixedAppAction, 70 + platform: .macOS, 71 + allowUserOverride: false, 72 + conflictPolicy: .disallowUserOverride, 73 + defaultBinding: Keybinding( 74 + key: "b", 75 + modifiers: KeybindingModifiers(command: true) 76 + ) 77 + ), 78 + KeybindingCommandSchema( 79 + id: "command.gamma", 80 + title: "Gamma", 81 + scope: .configurableAppAction, 82 + platform: .macOS, 83 + allowUserOverride: true, 84 + conflictPolicy: .warnAndPreferUserOverride, 85 + defaultBinding: Keybinding( 86 + key: "g", 87 + modifiers: KeybindingModifiers(command: true) 88 + ) 89 + ), 90 + ] 91 + ) 92 + 93 + let migratedOverrides: [String: KeybindingUserOverride] = [ 94 + "command.alpha": KeybindingUserOverride( 95 + binding: Keybinding(key: "m", modifiers: KeybindingModifiers(command: true)) 96 + ), 97 + ] 98 + 99 + let userOverrides = KeybindingUserOverrideStore( 100 + version: 1, 101 + overrides: [ 102 + "command.alpha": KeybindingUserOverride( 103 + binding: Keybinding(key: "u", modifiers: KeybindingModifiers(command: true, shift: true)) 104 + ), 105 + "command.beta": KeybindingUserOverride( 106 + binding: Keybinding(key: "x", modifiers: KeybindingModifiers(command: true)) 107 + ), 108 + "command.gamma": KeybindingUserOverride(binding: nil, isEnabled: false), 109 + ] 110 + ) 111 + 112 + let resolved = KeybindingResolver.resolve( 113 + schema: schema, 114 + userOverrides: userOverrides, 115 + migratedOverrides: migratedOverrides 116 + ) 117 + 118 + #expect(resolved.binding(for: "command.alpha")?.binding?.key == "u") 119 + #expect(resolved.binding(for: "command.alpha")?.source == .userOverride) 120 + 121 + #expect(resolved.binding(for: "command.beta")?.binding?.key == "b") 122 + #expect(resolved.binding(for: "command.beta")?.source == .appDefault) 123 + 124 + #expect(resolved.binding(for: "command.gamma")?.binding == nil) 125 + #expect(resolved.binding(for: "command.gamma")?.source == .userOverride) 126 + } 127 + 128 + @Test func migrationMigratesLegacyCustomShortcutsAndCollectsUnmappedIssues() throws { 129 + let fixture = #""" 130 + { 131 + "customCommands": [ 132 + { 133 + "id": "build", 134 + "title": "Build", 135 + "systemImage": "hammer", 136 + "command": "swift build", 137 + "execution": "shellScript", 138 + "shortcut": { 139 + "key": " B ", 140 + "modifiers": { 141 + "command": true, 142 + "shift": true, 143 + "option": false, 144 + "control": false 145 + } 146 + } 147 + }, 148 + { 149 + "id": "deploy", 150 + "title": "Deploy", 151 + "systemImage": "rocket", 152 + "command": "make release", 153 + "execution": "shellScript", 154 + "shortcut": { 155 + "key": "d", 156 + "modifiers": { 157 + "command": true, 158 + "shift": false, 159 + "option": false, 160 + "control": false 161 + } 162 + } 163 + }, 164 + { 165 + "id": "bad-shortcut", 166 + "title": "Bad", 167 + "systemImage": "xmark", 168 + "command": "echo bad", 169 + "execution": "shellScript", 170 + "shortcut": { 171 + "key": "two", 172 + "modifiers": { 173 + "command": true, 174 + "shift": false, 175 + "option": false, 176 + "control": false 177 + } 178 + } 179 + }, 180 + { 181 + "id": "", 182 + "title": "No ID", 183 + "systemImage": "questionmark", 184 + "command": "echo noid", 185 + "execution": "shellScript", 186 + "shortcut": { 187 + "key": "n", 188 + "modifiers": { 189 + "command": true, 190 + "shift": false, 191 + "option": false, 192 + "control": false 193 + } 194 + } 195 + }, 196 + { 197 + "id": "without-shortcut", 198 + "title": "No Shortcut", 199 + "systemImage": "ellipsis", 200 + "command": "echo none", 201 + "execution": "shellScript", 202 + "shortcut": null 203 + } 204 + ] 205 + } 206 + """# 207 + 208 + let legacySettings = try JSONDecoder().decode( 209 + LegacyCustomCommandShortcutFixture.self, 210 + from: Data(fixture.utf8) 211 + ) 212 + let migration = LegacyCustomCommandShortcutMigration.migrate(commands: legacySettings.customCommands) 213 + 214 + #expect(migration.migratedCount == 2) 215 + 216 + let migratedKeys = Set(migration.overrides.keys) 217 + #expect(migratedKeys == ["custom_command.build", "custom_command.deploy"]) 218 + 219 + #expect(migration.overrides["custom_command.build"]?.binding?.key == "b") 220 + #expect(migration.overrides["custom_command.build"]?.binding?.display == "⌘⇧B") 221 + 222 + expectNoDifference( 223 + migration.issues.map(\.reason), 224 + [.invalidShortcut, .missingCommandID] 225 + ) 226 + } 227 + } 228 + 229 + private struct LegacyCustomCommandShortcutFixture: Decodable { 230 + let customCommands: [UserCustomCommand] 231 + }