native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #118 from onevcat/feature/issue-72-keybinding-integration-base

Config-driven keybinding system with recorder UI

authored by

Wei Wang and committed by
GitHub
ff063e99 aa05916f

+5697 -636
-2
.github/workflows/test.yml
··· 5 5 branches: 6 6 - main 7 7 pull_request: 8 - branches: 9 - - main 10 8 jobs: 11 9 build: 12 10 runs-on: macos-26
+126
doc-onevcat/keybinding-system.md
··· 1 + # Keybinding System 2 + 3 + Guide for agents working on keyboard shortcuts in Prowl. 4 + 5 + ## Architecture Overview 6 + 7 + ``` 8 + AppShortcuts (built-in registry, ~50 commands) 9 + 10 + KeybindingSchemaDocument (versioned schema, all command definitions) 11 + 12 + KeybindingResolver.resolve(schema, userOverrides, migratedOverrides) 13 + 14 + ResolvedKeybindingMap (merged result: default + migrated + user) 15 + 16 + ┌──────────────────────────────┐ 17 + │ SwiftUI Environment │ @Environment(\.resolvedKeybindings) 18 + │ Ghostty CLI args │ --keybind=...=unbind / --keybind=...=action 19 + │ Menu items & tooltip display │ .keyboardShortcut / .help() 20 + └──────────────────────────────┘ 21 + ``` 22 + 23 + ## Scopes & Conflict Policies 24 + 25 + | Scope | `allowUserOverride` | Conflict Policy | Example | 26 + |-------|---------------------|-----------------|---------| 27 + | `configurableAppAction` | true | `warnAndPreferUserOverride` | New Worktree, Command Palette, Toggle Sidebar | 28 + | `systemFixedAppAction` | false | `disallowUserOverride` | Quit App — cannot be changed | 29 + | `localInteraction` | true | `localOnly` | Rename Branch, Select All Canvas Cards | 30 + | `customCommand` | true | `warnAndPreferUserOverride` | User-defined repo commands | 31 + 32 + ## Resolution Precedence 33 + 34 + ``` 35 + appDefault → migratedLegacy (from old custom command shortcuts) → userOverride 36 + ``` 37 + 38 + - Higher priority overrides lower. 39 + - `systemFixedAppAction` ignores all overrides (always stays at default). 40 + - `isEnabled=false` on an override clears the binding (disables the shortcut). 41 + - If migrated or user override is identical to default, source stays `appDefault`. 42 + 43 + ## Key Files 44 + 45 + | Purpose | File | 46 + |---------|------| 47 + | **Data models, resolver, migration** | `supacode/App/KeybindingSchema.swift` | 48 + | **Built-in command registry** | `supacode/App/AppShortcuts.swift` | 49 + | **Conflict detection** | `supacode/Features/Settings/BusinessLogic/ShortcutConflictDetector.swift` | 50 + | **Cascading reset planner** | `supacode/Features/Settings/BusinessLogic/ShortcutResetPlanner.swift` | 51 + | **NSEvent → key token** | `supacode/Features/Settings/BusinessLogic/ShortcutKeyTokenResolver.swift` | 52 + | **Settings UI & recorder** | `supacode/Features/Settings/Views/ShortcutsSettingsView.swift` | 53 + | **Persistence (GlobalSettings)** | `supacode/Features/Settings/Models/GlobalSettings.swift` | 54 + | **Settings reducer** | `supacode/Features/Settings/Reducer/SettingsFeature.swift` | 55 + | **App-level resolution & propagation** | `supacode/Features/App/Reducer/AppFeature.swift` | 56 + | **SwiftUI environment key** | `supacode/App/ResolvedKeybindingsEnvironment.swift` | 57 + | **Ghostty init & keybinding sync** | `supacode/App/supacodeApp.swift` | 58 + 59 + ## How It Works 60 + 61 + ### Storage 62 + 63 + User overrides are stored in `GlobalSettings.keybindingUserOverrides` (`KeybindingUserOverrideStore`), persisted via `@Shared(.settingsFile)`. Each override maps a command ID to an optional `Keybinding` + `isEnabled` flag. 64 + 65 + ### Resolution (AppFeature) 66 + 67 + On settings change, `AppFeature` recomputes `resolvedKeybindings`: 68 + 69 + 1. Builds schema from `AppShortcuts.bindings` + custom command schemas. 70 + 2. Migrates legacy custom command shortcuts via `LegacyCustomCommandShortcutMigration`. 71 + 3. Calls `KeybindingResolver.resolve()` with schema, user overrides, and migrated overrides. 72 + 4. Filters out app-level shortcuts that conflict with custom commands (custom commands win). 73 + 5. Injects result into SwiftUI environment and passes CLI args to Ghostty. 74 + 75 + ### Recorder UI 76 + 77 + `ShortcutsSettingsView` uses `NSEvent.addLocalMonitorForEvents` to capture key-down events. `ShortcutKeyTokenResolver` converts the `NSEvent.keyCode` to a portable key token (e.g., `"a"`, `"digit_1"`, `"arrow_up"`, `"return"`). At least one modifier key is required. 78 + 79 + ### Conflict Handling 80 + 81 + When a new binding is recorded: 82 + 83 + 1. `ShortcutConflictDetector.firstConflictCommandID()` checks for conflicts (skipped for `disallowUserOverride` policy). 84 + 2. If conflict found → alert with Replace / Show Conflict / Cancel. 85 + 3. Replace saves new binding AND disables the conflicting command. 86 + 87 + ### Reset Cascading 88 + 89 + When resetting an override back to default: 90 + 91 + 1. `ShortcutResetPlanner.makePlan()` simulates removal. 92 + 2. If the restored default binding conflicts with another override, that override is also queued for reset. 93 + 3. Cascades transitively until no conflicts remain. 94 + 4. User confirms the full cascade list before reset executes. 95 + 96 + ### Ghostty Integration 97 + 98 + `AppShortcuts.ghosttyCLIKeybindArguments()` generates two kinds of CLI args: 99 + 100 + - **Unbind**: `--keybind=KEY+MODIFIERS=unbind` — prevents Ghostty from intercepting app-level shortcuts. 101 + - **Bind**: `--keybind=KEY+MODIFIERS=goto_tab:N` etc. — routes terminal shortcuts through Ghostty. 102 + 103 + These args are passed at Ghostty init and re-synced whenever keybindings change. 104 + 105 + ### Legacy Migration 106 + 107 + `LegacyCustomCommandShortcutMigration.migrate(commands:)` converts old `UserCustomCommand.shortcut` to keybinding overrides with ID `custom_command.{commandID}`. Only single-character shortcuts with non-empty IDs are migrated; others are logged as issues. 108 + 109 + ## Test Coverage 110 + 111 + | Test file | Covers | 112 + |-----------|--------| 113 + | `KeybindingSchemaTests.swift` | Schema encode/decode, resolver precedence, migration | 114 + | `KeybindingBehaviorMatrixTests.swift` | Scope × policy × state matrix (defaults, overrides, disable, migration, conflict, reset, persistence) | 115 + | `ShortcutConflictDetectorTests.swift` | Conflict detection across policies | 116 + | `ShortcutResetPlannerTests.swift` | Cascading reset behavior | 117 + | `ShortcutKeyTokenResolverTests.swift` | NSEvent key token parsing | 118 + | `AppShortcutsTests.swift` | Display, Ghostty CLI args, app integration | 119 + | `SettingsFeatureTests.swift` | Keybinding persistence fan-out | 120 + 121 + ## Adding a New Shortcut 122 + 123 + 1. Add a new entry in `AppShortcuts.bindings` with command ID, title, scope, and default binding. 124 + 2. The schema and resolver pick it up automatically. 125 + 3. Use `resolvedKeybindings.keyboardShortcut(for: "your_command_id")` in views. 126 + 4. If it's a terminal action, add Ghostty bind args in `ghosttyCLIKeybindArguments()`.
+396 -56
supacode/App/AppShortcuts.swift
··· 34 34 "--keybind=\(ghosttyKeybind)=unbind" 35 35 } 36 36 37 + func ghosttyBindArguments(action: String) -> [String] { 38 + var arguments = ["--keybind=\(ghosttyKeybind)=\(action)"] 39 + if let physicalKeyAlias { 40 + let parts = ghosttyModifierParts + [physicalKeyAlias] 41 + arguments.append("--keybind=\(parts.joined(separator: "+"))=\(action)") 42 + } 43 + return arguments 44 + } 45 + 37 46 var display: String { 38 47 let parts = displayModifierParts + [keyEquivalent.display] 39 48 return parts.joined() ··· 64 73 if modifiers.contains(.option) { parts.append("⌥") } 65 74 if modifiers.contains(.control) { parts.append("⌃") } 66 75 return parts 76 + } 77 + 78 + private var physicalKeyAlias: String? { 79 + let value = String(keyEquivalent.character).lowercased() 80 + guard value.count == 1, let character = value.first, character.isNumber else { return nil } 81 + return "digit_\(value)" 67 82 } 68 83 } 69 84 70 85 enum AppShortcuts { 86 + enum CommandID { 87 + static let newWorktree = "new_worktree" 88 + static let commandPalette = "command_palette" 89 + static let quitApplication = "quit_application" 90 + static let openSettings = "open_settings" 91 + static let openWorktree = "open_worktree" 92 + static let copyPath = "copy_path" 93 + static let openRepository = "open_repository" 94 + static let openPullRequest = "open_pull_request" 95 + static let toggleLeftSidebar = "toggle_left_sidebar" 96 + static let refreshWorktrees = "refresh_worktrees" 97 + static let runScript = "run_script" 98 + static let stopScript = "stop_script" 99 + static let checkForUpdates = "check_for_updates" 100 + static let showDiff = "show_diff" 101 + static let toggleCanvas = "toggle_canvas" 102 + static let archivedWorktrees = "archived_worktrees" 103 + static let selectNextWorktree = "select_next_worktree" 104 + static let selectPreviousWorktree = "select_previous_worktree" 105 + static let selectWorktree1 = "select_worktree_1" 106 + static let selectWorktree2 = "select_worktree_2" 107 + static let selectWorktree3 = "select_worktree_3" 108 + static let selectWorktree4 = "select_worktree_4" 109 + static let selectWorktree5 = "select_worktree_5" 110 + static let selectWorktree6 = "select_worktree_6" 111 + static let selectWorktree7 = "select_worktree_7" 112 + static let selectWorktree8 = "select_worktree_8" 113 + static let selectWorktree9 = "select_worktree_9" 114 + static let selectWorktree0 = "select_worktree_0" 115 + static let selectTerminalTab1 = "select_terminal_tab_1" 116 + static let selectTerminalTab2 = "select_terminal_tab_2" 117 + static let selectTerminalTab3 = "select_terminal_tab_3" 118 + static let selectTerminalTab4 = "select_terminal_tab_4" 119 + static let selectTerminalTab5 = "select_terminal_tab_5" 120 + static let selectTerminalTab6 = "select_terminal_tab_6" 121 + static let selectTerminalTab7 = "select_terminal_tab_7" 122 + static let selectTerminalTab8 = "select_terminal_tab_8" 123 + static let selectTerminalTab9 = "select_terminal_tab_9" 124 + static let selectTerminalTab0 = "select_terminal_tab_0" 125 + static let renameBranch = "rename_branch" 126 + static let selectAllCanvasCards = "select_all_canvas_cards" 127 + static let selectPreviousTerminalTab = "select_previous_terminal_tab" 128 + static let selectNextTerminalTab = "select_next_terminal_tab" 129 + static let selectPreviousTerminalPane = "select_previous_terminal_pane" 130 + static let selectNextTerminalPane = "select_next_terminal_pane" 131 + static let selectTerminalPaneUp = "select_terminal_pane_up" 132 + static let selectTerminalPaneDown = "select_terminal_pane_down" 133 + static let selectTerminalPaneLeft = "select_terminal_pane_left" 134 + static let selectTerminalPaneRight = "select_terminal_pane_right" 135 + } 136 + 71 137 enum Scope: String { 72 138 case configurableAppAction 73 139 case systemFixedAppAction ··· 92 158 let actionTitle: String 93 159 let shortcut: AppShortcut 94 160 } 95 - 96 - private struct TabSelectionBinding { 97 - let unicode: String 98 - let physical: String 99 - let tabIndex: Int 100 - } 101 - 102 - private static let tabSelectionBindings: [TabSelectionBinding] = [ 103 - TabSelectionBinding(unicode: "1", physical: "digit_1", tabIndex: 1), 104 - TabSelectionBinding(unicode: "2", physical: "digit_2", tabIndex: 2), 105 - TabSelectionBinding(unicode: "3", physical: "digit_3", tabIndex: 3), 106 - TabSelectionBinding(unicode: "4", physical: "digit_4", tabIndex: 4), 107 - TabSelectionBinding(unicode: "5", physical: "digit_5", tabIndex: 5), 108 - TabSelectionBinding(unicode: "6", physical: "digit_6", tabIndex: 6), 109 - TabSelectionBinding(unicode: "7", physical: "digit_7", tabIndex: 7), 110 - TabSelectionBinding(unicode: "8", physical: "digit_8", tabIndex: 8), 111 - TabSelectionBinding(unicode: "9", physical: "digit_9", tabIndex: 9), 112 - TabSelectionBinding(unicode: "0", physical: "digit_0", tabIndex: 10), 113 - ] 114 161 115 162 static let newWorktree = AppShortcut(key: "n", modifiers: .command) 116 163 static let commandPalette = AppShortcut(key: "p", modifiers: .command) ··· 146 193 static let selectWorktree8 = AppShortcut(key: "8", modifiers: [.control]) 147 194 static let selectWorktree9 = AppShortcut(key: "9", modifiers: [.control]) 148 195 static let selectWorktree0 = AppShortcut(key: "0", modifiers: [.control]) 196 + static let selectTerminalTab1 = AppShortcut(key: "1", modifiers: [.command]) 197 + static let selectTerminalTab2 = AppShortcut(key: "2", modifiers: [.command]) 198 + static let selectTerminalTab3 = AppShortcut(key: "3", modifiers: [.command]) 199 + static let selectTerminalTab4 = AppShortcut(key: "4", modifiers: [.command]) 200 + static let selectTerminalTab5 = AppShortcut(key: "5", modifiers: [.command]) 201 + static let selectTerminalTab6 = AppShortcut(key: "6", modifiers: [.command]) 202 + static let selectTerminalTab7 = AppShortcut(key: "7", modifiers: [.command]) 203 + static let selectTerminalTab8 = AppShortcut(key: "8", modifiers: [.command]) 204 + static let selectTerminalTab9 = AppShortcut(key: "9", modifiers: [.command]) 205 + static let selectTerminalTab0 = AppShortcut(key: "0", modifiers: [.command]) 206 + static let selectPreviousTerminalTab = AppShortcut(key: "[", modifiers: [.command, .shift]) 207 + static let selectNextTerminalTab = AppShortcut(key: "]", modifiers: [.command, .shift]) 208 + static let selectPreviousTerminalPane = AppShortcut(key: "[", modifiers: [.command]) 209 + static let selectNextTerminalPane = AppShortcut(key: "]", modifiers: [.command]) 210 + static let selectTerminalPaneUp = AppShortcut( 211 + keyEquivalent: .upArrow, ghosttyKeyName: "arrow_up", modifiers: [.command, .option] 212 + ) 213 + static let selectTerminalPaneDown = AppShortcut( 214 + keyEquivalent: .downArrow, ghosttyKeyName: "arrow_down", modifiers: [.command, .option] 215 + ) 216 + static let selectTerminalPaneLeft = AppShortcut( 217 + keyEquivalent: .leftArrow, ghosttyKeyName: "arrow_left", modifiers: [.command, .option] 218 + ) 219 + static let selectTerminalPaneRight = AppShortcut( 220 + keyEquivalent: .rightArrow, ghosttyKeyName: "arrow_right", modifiers: [.command, .option] 221 + ) 149 222 static let renameBranch = AppShortcut(key: "m", modifiers: [.command, .shift]) 150 223 static let selectAllCanvasCards = AppShortcut(key: "a", modifiers: [.command, .option]) 151 224 static let worktreeSelection: [AppShortcut] = [ ··· 161 234 selectWorktree0, 162 235 ] 163 236 237 + static let worktreeSelectionCommandIDs: [String] = [ 238 + CommandID.selectWorktree1, 239 + CommandID.selectWorktree2, 240 + CommandID.selectWorktree3, 241 + CommandID.selectWorktree4, 242 + CommandID.selectWorktree5, 243 + CommandID.selectWorktree6, 244 + CommandID.selectWorktree7, 245 + CommandID.selectWorktree8, 246 + CommandID.selectWorktree9, 247 + CommandID.selectWorktree0, 248 + ] 249 + 250 + static let terminalTabSelection: [AppShortcut] = [ 251 + selectTerminalTab1, 252 + selectTerminalTab2, 253 + selectTerminalTab3, 254 + selectTerminalTab4, 255 + selectTerminalTab5, 256 + selectTerminalTab6, 257 + selectTerminalTab7, 258 + selectTerminalTab8, 259 + selectTerminalTab9, 260 + selectTerminalTab0, 261 + ] 262 + 263 + static let terminalTabSelectionCommandIDs: [String] = [ 264 + CommandID.selectTerminalTab1, 265 + CommandID.selectTerminalTab2, 266 + CommandID.selectTerminalTab3, 267 + CommandID.selectTerminalTab4, 268 + CommandID.selectTerminalTab5, 269 + CommandID.selectTerminalTab6, 270 + CommandID.selectTerminalTab7, 271 + CommandID.selectTerminalTab8, 272 + CommandID.selectTerminalTab9, 273 + CommandID.selectTerminalTab0, 274 + ] 275 + 164 276 private static let reservedCustomCommandBindings: [ReservedCustomCommandBinding] = [ 165 277 .init(actionTitle: "Open Settings", shortcut: openSettings), 166 278 .init(actionTitle: "Toggle Left Sidebar", shortcut: toggleLeftSidebar), ··· 170 282 .init(actionTitle: "Show Diff", shortcut: showDiff), 171 283 .init(actionTitle: "Open Worktree", shortcut: openFinder), 172 284 .init(actionTitle: "Open Repository", shortcut: openRepository), 285 + .init(actionTitle: "Select Terminal Tab 1", shortcut: selectTerminalTab1), 286 + .init(actionTitle: "Select Terminal Tab 2", shortcut: selectTerminalTab2), 287 + .init(actionTitle: "Select Terminal Tab 3", shortcut: selectTerminalTab3), 288 + .init(actionTitle: "Select Terminal Tab 4", shortcut: selectTerminalTab4), 289 + .init(actionTitle: "Select Terminal Tab 5", shortcut: selectTerminalTab5), 290 + .init(actionTitle: "Select Terminal Tab 6", shortcut: selectTerminalTab6), 291 + .init(actionTitle: "Select Terminal Tab 7", shortcut: selectTerminalTab7), 292 + .init(actionTitle: "Select Terminal Tab 8", shortcut: selectTerminalTab8), 293 + .init(actionTitle: "Select Terminal Tab 9", shortcut: selectTerminalTab9), 294 + .init(actionTitle: "Select Terminal Tab 0", shortcut: selectTerminalTab0), 295 + .init(actionTitle: "Select Previous Tab", shortcut: selectPreviousTerminalTab), 296 + .init(actionTitle: "Select Next Tab", shortcut: selectNextTerminalTab), 297 + .init(actionTitle: "Select Previous Pane", shortcut: selectPreviousTerminalPane), 298 + .init(actionTitle: "Select Next Pane", shortcut: selectNextTerminalPane), 299 + .init(actionTitle: "Select Pane Up", shortcut: selectTerminalPaneUp), 300 + .init(actionTitle: "Select Pane Down", shortcut: selectTerminalPaneDown), 301 + .init(actionTitle: "Select Pane Left", shortcut: selectTerminalPaneLeft), 302 + .init(actionTitle: "Select Pane Right", shortcut: selectTerminalPaneRight), 173 303 ] 174 304 175 305 static let bindings: [Binding] = [ 176 306 .init( 177 - id: "new_worktree", 307 + id: CommandID.newWorktree, 178 308 title: "New Worktree", 179 309 scope: .configurableAppAction, 180 310 shortcut: newWorktree 181 311 ), 182 312 .init( 183 - id: "open_settings", 313 + id: CommandID.openSettings, 184 314 title: "Open Settings", 185 315 scope: .configurableAppAction, 186 316 shortcut: openSettings 187 317 ), 188 318 .init( 189 - id: "open_worktree", 319 + id: CommandID.openWorktree, 190 320 title: "Open Worktree", 191 321 scope: .configurableAppAction, 192 322 shortcut: openFinder 193 323 ), 194 324 .init( 195 - id: "copy_path", 325 + id: CommandID.copyPath, 196 326 title: "Copy Path", 197 327 scope: .configurableAppAction, 198 328 shortcut: copyPath 199 329 ), 200 330 .init( 201 - id: "open_repository", 331 + id: CommandID.openRepository, 202 332 title: "Open Repository", 203 333 scope: .configurableAppAction, 204 334 shortcut: openRepository 205 335 ), 206 336 .init( 207 - id: "open_pull_request", 337 + id: CommandID.openPullRequest, 208 338 title: "Open Pull Request", 209 339 scope: .configurableAppAction, 210 340 shortcut: openPullRequest 211 341 ), 212 342 .init( 213 - id: "toggle_left_sidebar", 343 + id: CommandID.toggleLeftSidebar, 214 344 title: "Toggle Left Sidebar", 215 345 scope: .configurableAppAction, 216 346 shortcut: toggleLeftSidebar 217 347 ), 218 348 .init( 219 - id: "refresh_worktrees", 349 + id: CommandID.refreshWorktrees, 220 350 title: "Refresh Worktrees", 221 351 scope: .configurableAppAction, 222 352 shortcut: refreshWorktrees 223 353 ), 224 354 .init( 225 - id: "run_script", 355 + id: CommandID.runScript, 226 356 title: "Run Script", 227 357 scope: .configurableAppAction, 228 358 shortcut: runScript 229 359 ), 230 360 .init( 231 - id: "stop_script", 361 + id: CommandID.stopScript, 232 362 title: "Stop Script", 233 363 scope: .configurableAppAction, 234 364 shortcut: stopRunScript 235 365 ), 236 366 .init( 237 - id: "check_for_updates", 367 + id: CommandID.checkForUpdates, 238 368 title: "Check for Updates", 239 369 scope: .configurableAppAction, 240 370 shortcut: checkForUpdates 241 371 ), 242 372 .init( 243 - id: "show_diff", 373 + id: CommandID.showDiff, 244 374 title: "Show Diff", 245 375 scope: .configurableAppAction, 246 376 shortcut: showDiff 247 377 ), 248 378 .init( 249 - id: "toggle_canvas", 379 + id: CommandID.toggleCanvas, 250 380 title: "Toggle Canvas", 251 381 scope: .configurableAppAction, 252 382 shortcut: toggleCanvas 253 383 ), 254 384 .init( 255 - id: "archived_worktrees", 385 + id: CommandID.archivedWorktrees, 256 386 title: "Archived Worktrees", 257 387 scope: .configurableAppAction, 258 388 shortcut: archivedWorktrees 259 389 ), 260 390 .init( 261 - id: "select_next_worktree", 391 + id: CommandID.selectNextWorktree, 262 392 title: "Select Next Worktree", 263 393 scope: .configurableAppAction, 264 394 shortcut: selectNextWorktree 265 395 ), 266 396 .init( 267 - id: "select_previous_worktree", 397 + id: CommandID.selectPreviousWorktree, 268 398 title: "Select Previous Worktree", 269 399 scope: .configurableAppAction, 270 400 shortcut: selectPreviousWorktree 271 401 ), 272 402 .init( 273 - id: "select_worktree_1", 403 + id: CommandID.selectWorktree1, 274 404 title: "Select Worktree 1", 275 405 scope: .configurableAppAction, 276 406 shortcut: selectWorktree1 277 407 ), 278 408 .init( 279 - id: "select_worktree_2", 409 + id: CommandID.selectWorktree2, 280 410 title: "Select Worktree 2", 281 411 scope: .configurableAppAction, 282 412 shortcut: selectWorktree2 283 413 ), 284 414 .init( 285 - id: "select_worktree_3", 415 + id: CommandID.selectWorktree3, 286 416 title: "Select Worktree 3", 287 417 scope: .configurableAppAction, 288 418 shortcut: selectWorktree3 289 419 ), 290 420 .init( 291 - id: "select_worktree_4", 421 + id: CommandID.selectWorktree4, 292 422 title: "Select Worktree 4", 293 423 scope: .configurableAppAction, 294 424 shortcut: selectWorktree4 295 425 ), 296 426 .init( 297 - id: "select_worktree_5", 427 + id: CommandID.selectWorktree5, 298 428 title: "Select Worktree 5", 299 429 scope: .configurableAppAction, 300 430 shortcut: selectWorktree5 301 431 ), 302 432 .init( 303 - id: "select_worktree_6", 433 + id: CommandID.selectWorktree6, 304 434 title: "Select Worktree 6", 305 435 scope: .configurableAppAction, 306 436 shortcut: selectWorktree6 307 437 ), 308 438 .init( 309 - id: "select_worktree_7", 439 + id: CommandID.selectWorktree7, 310 440 title: "Select Worktree 7", 311 441 scope: .configurableAppAction, 312 442 shortcut: selectWorktree7 313 443 ), 314 444 .init( 315 - id: "select_worktree_8", 445 + id: CommandID.selectWorktree8, 316 446 title: "Select Worktree 8", 317 447 scope: .configurableAppAction, 318 448 shortcut: selectWorktree8 319 449 ), 320 450 .init( 321 - id: "select_worktree_9", 451 + id: CommandID.selectWorktree9, 322 452 title: "Select Worktree 9", 323 453 scope: .configurableAppAction, 324 454 shortcut: selectWorktree9 325 455 ), 326 456 .init( 327 - id: "select_worktree_0", 457 + id: CommandID.selectWorktree0, 328 458 title: "Select Worktree 0", 329 459 scope: .configurableAppAction, 330 460 shortcut: selectWorktree0 331 461 ), 332 462 .init( 333 - id: "command_palette", 463 + id: CommandID.selectTerminalTab1, 464 + title: "Select Terminal Tab 1", 465 + scope: .configurableAppAction, 466 + shortcut: selectTerminalTab1 467 + ), 468 + .init( 469 + id: CommandID.selectTerminalTab2, 470 + title: "Select Terminal Tab 2", 471 + scope: .configurableAppAction, 472 + shortcut: selectTerminalTab2 473 + ), 474 + .init( 475 + id: CommandID.selectTerminalTab3, 476 + title: "Select Terminal Tab 3", 477 + scope: .configurableAppAction, 478 + shortcut: selectTerminalTab3 479 + ), 480 + .init( 481 + id: CommandID.selectTerminalTab4, 482 + title: "Select Terminal Tab 4", 483 + scope: .configurableAppAction, 484 + shortcut: selectTerminalTab4 485 + ), 486 + .init( 487 + id: CommandID.selectTerminalTab5, 488 + title: "Select Terminal Tab 5", 489 + scope: .configurableAppAction, 490 + shortcut: selectTerminalTab5 491 + ), 492 + .init( 493 + id: CommandID.selectTerminalTab6, 494 + title: "Select Terminal Tab 6", 495 + scope: .configurableAppAction, 496 + shortcut: selectTerminalTab6 497 + ), 498 + .init( 499 + id: CommandID.selectTerminalTab7, 500 + title: "Select Terminal Tab 7", 501 + scope: .configurableAppAction, 502 + shortcut: selectTerminalTab7 503 + ), 504 + .init( 505 + id: CommandID.selectTerminalTab8, 506 + title: "Select Terminal Tab 8", 507 + scope: .configurableAppAction, 508 + shortcut: selectTerminalTab8 509 + ), 510 + .init( 511 + id: CommandID.selectTerminalTab9, 512 + title: "Select Terminal Tab 9", 513 + scope: .configurableAppAction, 514 + shortcut: selectTerminalTab9 515 + ), 516 + .init( 517 + id: CommandID.selectTerminalTab0, 518 + title: "Select Terminal Tab 0", 519 + scope: .configurableAppAction, 520 + shortcut: selectTerminalTab0 521 + ), 522 + .init( 523 + id: CommandID.selectPreviousTerminalTab, 524 + title: "Select Previous Tab", 525 + scope: .configurableAppAction, 526 + shortcut: selectPreviousTerminalTab 527 + ), 528 + .init( 529 + id: CommandID.selectNextTerminalTab, 530 + title: "Select Next Tab", 531 + scope: .configurableAppAction, 532 + shortcut: selectNextTerminalTab 533 + ), 534 + .init( 535 + id: CommandID.selectPreviousTerminalPane, 536 + title: "Select Previous Pane", 537 + scope: .configurableAppAction, 538 + shortcut: selectPreviousTerminalPane 539 + ), 540 + .init( 541 + id: CommandID.selectNextTerminalPane, 542 + title: "Select Next Pane", 543 + scope: .configurableAppAction, 544 + shortcut: selectNextTerminalPane 545 + ), 546 + .init( 547 + id: CommandID.selectTerminalPaneUp, 548 + title: "Select Pane Up", 549 + scope: .configurableAppAction, 550 + shortcut: selectTerminalPaneUp 551 + ), 552 + .init( 553 + id: CommandID.selectTerminalPaneDown, 554 + title: "Select Pane Down", 555 + scope: .configurableAppAction, 556 + shortcut: selectTerminalPaneDown 557 + ), 558 + .init( 559 + id: CommandID.selectTerminalPaneLeft, 560 + title: "Select Pane Left", 561 + scope: .configurableAppAction, 562 + shortcut: selectTerminalPaneLeft 563 + ), 564 + .init( 565 + id: CommandID.selectTerminalPaneRight, 566 + title: "Select Pane Right", 567 + scope: .configurableAppAction, 568 + shortcut: selectTerminalPaneRight 569 + ), 570 + .init( 571 + id: CommandID.commandPalette, 334 572 title: "Command Palette", 335 - scope: .systemFixedAppAction, 573 + scope: .configurableAppAction, 336 574 shortcut: commandPalette 337 575 ), 338 576 .init( 339 - id: "quit_application", 577 + id: CommandID.quitApplication, 340 578 title: "Quit Application", 341 579 scope: .systemFixedAppAction, 342 580 shortcut: quitApplication 343 581 ), 344 582 .init( 345 - id: "rename_branch", 583 + id: CommandID.renameBranch, 346 584 title: "Rename Branch", 347 585 scope: .localInteraction, 348 586 shortcut: renameBranch 349 587 ), 350 588 .init( 351 - id: "select_all_canvas_cards", 589 + id: CommandID.selectAllCanvasCards, 352 590 title: "Select All Canvas Cards", 353 591 scope: .localInteraction, 354 592 shortcut: selectAllCanvasCards ··· 386 624 } 387 625 } 388 626 389 - static let tabSelectionGhosttyKeybindArguments: [String] = tabSelectionBindings.flatMap { binding in 390 - [ 391 - "--keybind=ctrl+\(binding.unicode)=goto_tab:\(binding.tabIndex)", 392 - "--keybind=ctrl+\(binding.physical)=goto_tab:\(binding.tabIndex)", 393 - ] 627 + static func binding(for id: String) -> Binding? { 628 + bindings.first { $0.id == id } 629 + } 630 + 631 + static func defaultShortcut(for id: String) -> AppShortcut? { 632 + binding(for: id)?.shortcut 633 + } 634 + 635 + static func resolvedShortcut(for id: String, in resolvedKeybindings: ResolvedKeybindingMap) -> AppShortcut? { 636 + guard let resolvedBinding = resolvedKeybindings.binding(for: id) else { 637 + return defaultShortcut(for: id) 638 + } 639 + return resolvedBinding.binding?.appShortcut 640 + } 641 + 642 + static func display(for commandID: String, in resolvedKeybindings: ResolvedKeybindingMap) -> String? { 643 + resolvedShortcut(for: commandID, in: resolvedKeybindings)?.display 644 + } 645 + 646 + static func helpText( 647 + title: String, 648 + commandID: String, 649 + in resolvedKeybindings: ResolvedKeybindingMap 650 + ) -> String { 651 + if let shortcut = display(for: commandID, in: resolvedKeybindings) { 652 + return "\(title) (\(shortcut))" 653 + } 654 + return title 655 + } 656 + 657 + static func worktreeSelectionDisplay(at index: Int, in resolvedKeybindings: ResolvedKeybindingMap) -> String? { 658 + guard worktreeSelectionCommandIDs.indices.contains(index) else { return nil } 659 + return display(for: worktreeSelectionCommandIDs[index], in: resolvedKeybindings) 660 + } 661 + 662 + static func terminalTabSelectionDisplay(at index: Int, in resolvedKeybindings: ResolvedKeybindingMap) -> String? { 663 + guard terminalTabSelectionCommandIDs.indices.contains(index) else { return nil } 664 + return display(for: terminalTabSelectionCommandIDs[index], in: resolvedKeybindings) 665 + } 666 + 667 + private static let ghosttyManagedActionBindings: [(commandID: String, action: String)] = [ 668 + (CommandID.selectTerminalTab1, "goto_tab:1"), 669 + (CommandID.selectTerminalTab2, "goto_tab:2"), 670 + (CommandID.selectTerminalTab3, "goto_tab:3"), 671 + (CommandID.selectTerminalTab4, "goto_tab:4"), 672 + (CommandID.selectTerminalTab5, "goto_tab:5"), 673 + (CommandID.selectTerminalTab6, "goto_tab:6"), 674 + (CommandID.selectTerminalTab7, "goto_tab:7"), 675 + (CommandID.selectTerminalTab8, "goto_tab:8"), 676 + (CommandID.selectTerminalTab9, "goto_tab:9"), 677 + (CommandID.selectTerminalTab0, "goto_tab:10"), 678 + (CommandID.selectPreviousTerminalTab, "previous_tab"), 679 + (CommandID.selectNextTerminalTab, "next_tab"), 680 + (CommandID.selectPreviousTerminalPane, "goto_split:previous"), 681 + (CommandID.selectNextTerminalPane, "goto_split:next"), 682 + (CommandID.selectTerminalPaneUp, "goto_split:up"), 683 + (CommandID.selectTerminalPaneDown, "goto_split:down"), 684 + (CommandID.selectTerminalPaneLeft, "goto_split:left"), 685 + (CommandID.selectTerminalPaneRight, "goto_split:right"), 686 + ] 687 + 688 + static func ghosttyCLIKeybindArguments(from resolvedKeybindings: ResolvedKeybindingMap) -> [String] { 689 + var unbindArguments: [String] = [] 690 + var seenUnbindArguments = Set<String>() 691 + func appendUnbindArgument(_ argument: String) { 692 + if seenUnbindArguments.insert(argument).inserted { 693 + unbindArguments.append(argument) 694 + } 695 + } 696 + 697 + for binding in bindings where binding.scope == .configurableAppAction { 698 + if let argument = resolvedShortcut(for: binding.id, in: resolvedKeybindings)?.ghosttyUnbindArgument { 699 + appendUnbindArgument(argument) 700 + } 701 + } 702 + 703 + for (commandID, _) in ghosttyManagedActionBindings { 704 + if let defaultUnbind = binding(for: commandID)?.shortcut.ghosttyUnbindArgument { 705 + appendUnbindArgument(defaultUnbind) 706 + } 707 + } 708 + 709 + var managedActionArguments: [String] = [] 710 + for (commandID, action) in ghosttyManagedActionBindings { 711 + guard let shortcut = resolvedShortcut(for: commandID, in: resolvedKeybindings) else { continue } 712 + managedActionArguments.append(contentsOf: shortcut.ghosttyBindArguments(action: action)) 713 + } 714 + 715 + return unbindArguments + managedActionArguments 394 716 } 395 717 396 718 static var ghosttyCLIKeybindArguments: [String] { 397 - all.map(\.ghosttyUnbindArgument) + tabSelectionGhosttyKeybindArguments 719 + ghosttyCLIKeybindArguments(from: .appDefaults) 398 720 } 399 721 400 722 static let all: [AppShortcut] = [ ··· 424 746 selectWorktree8, 425 747 selectWorktree9, 426 748 selectWorktree0, 749 + selectTerminalTab1, 750 + selectTerminalTab2, 751 + selectTerminalTab3, 752 + selectTerminalTab4, 753 + selectTerminalTab5, 754 + selectTerminalTab6, 755 + selectTerminalTab7, 756 + selectTerminalTab8, 757 + selectTerminalTab9, 758 + selectTerminalTab0, 759 + selectPreviousTerminalTab, 760 + selectNextTerminalTab, 761 + selectPreviousTerminalPane, 762 + selectNextTerminalPane, 763 + selectTerminalPaneUp, 764 + selectTerminalPaneDown, 765 + selectTerminalPaneLeft, 766 + selectTerminalPaneRight, 427 767 ] 428 768 } 429 769
+8 -15
supacode/App/ContentView.swift
··· 94 94 items: CommandPaletteFeature.commandPaletteItems( 95 95 from: store.repositories, 96 96 ghosttyCommands: ghosttyShortcuts.commandPaletteEntries 97 - ) 97 + ), 98 + resolvedKeybindings: store.resolvedKeybindings 98 99 ) 99 100 } 100 101 .background(WindowTabbingDisabler()) ··· 163 164 .foregroundStyle(.secondary) 164 165 } 165 166 166 - ZStack(alignment: .topLeading) { 167 - PlainTextEditor( 168 - text: $script, 169 - isMonospaced: true 170 - ) 171 - .frame(minHeight: 160) 172 - if script.isEmpty { 173 - Text("npm run dev") 174 - .foregroundStyle(.secondary) 175 - .padding(.leading, 6) 176 - .font(.body.monospaced()) 177 - .allowsHitTesting(false) 178 - } 179 - } 167 + PlainTextEditor( 168 + text: $script, 169 + isMonospaced: true, 170 + placeholder: "npm run dev" 171 + ) 172 + .frame(minHeight: 160) 180 173 181 174 HStack { 182 175 Spacer()
+143 -1
supacode/App/KeybindingSchema.swift
··· 37 37 self.control = control 38 38 } 39 39 40 + var isEmpty: Bool { 41 + !command && !shift && !option && !control 42 + } 43 + 40 44 var eventModifiers: EventModifiers { 41 45 var value: EventModifiers = [] 42 46 if command { ··· 104 108 case "arrow_right": 105 109 return "→" 106 110 default: 111 + if let digitCharacter = physicalDigitCharacter(for: key) { 112 + return String(digitCharacter) 113 + } 107 114 return key.uppercased() 108 115 } 109 116 } 117 + 118 + private static func physicalDigitCharacter(for key: String) -> Character? { 119 + guard key.hasPrefix("digit_") else { return nil } 120 + let value = key.dropFirst("digit_".count) 121 + guard value.count == 1, let character = value.first, character.isNumber else { return nil } 122 + return character 123 + } 110 124 } 111 125 112 126 /// Versioned command schema for keybinding definitions. ··· 168 182 169 183 func binding(for commandID: String) -> ResolvedKeybinding? { 170 184 bindingsByCommandID[commandID] 185 + } 186 + } 187 + 188 + extension ResolvedKeybindingMap { 189 + static let appDefaults = KeybindingResolver.resolve(schema: .appResolverSchema()) 190 + 191 + func keybinding(for commandID: String) -> Keybinding? { 192 + binding(for: commandID)?.binding 193 + } 194 + 195 + func appShortcut(for commandID: String) -> AppShortcut? { 196 + keybinding(for: commandID)?.appShortcut 197 + } 198 + 199 + func keyboardShortcut(for commandID: String) -> KeyboardShortcut? { 200 + keybinding(for: commandID)?.keyboardShortcut 201 + } 202 + 203 + func display(for commandID: String) -> String? { 204 + keybinding(for: commandID)?.display 171 205 } 172 206 } 173 207 ··· 345 379 title: binding.title, 346 380 scope: .init(binding.scope), 347 381 platform: .macOS, 348 - allowUserOverride: binding.scope == .configurableAppAction, 382 + allowUserOverride: binding.scope != .systemFixedAppAction, 349 383 conflictPolicy: binding.scope.conflictPolicy, 350 384 defaultBinding: binding.shortcut.keybinding 351 385 ) 352 386 } 353 387 ) 354 388 } 389 + 390 + static func appResolverSchema(customCommands: [UserCustomCommand] = []) -> KeybindingSchemaDocument { 391 + KeybindingSchemaDocument( 392 + version: currentVersion, 393 + commands: appDefaultsV1.commands + customCommands.map(\.keybindingCommandSchema) 394 + ) 395 + } 355 396 } 356 397 357 398 extension AppShortcuts.Scope { ··· 372 413 Keybinding(key: keyToken, modifiers: .init(modifiers)) 373 414 } 374 415 } 416 + 417 + extension UserCustomCommand { 418 + fileprivate var keybindingCommandSchema: KeybindingCommandSchema { 419 + KeybindingCommandSchema( 420 + id: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: id), 421 + title: resolvedTitle, 422 + scope: .customCommand, 423 + platform: .macOS, 424 + allowUserOverride: true, 425 + conflictPolicy: .warnAndPreferUserOverride, 426 + defaultBinding: nil 427 + ) 428 + } 429 + } 430 + 431 + extension Keybinding { 432 + var keyEquivalent: KeyEquivalent? { 433 + if let specialKeyEquivalent { 434 + return specialKeyEquivalent 435 + } 436 + if let singleCharacter { 437 + return KeyEquivalent(singleCharacter) 438 + } 439 + if let physicalDigitCharacter { 440 + return KeyEquivalent(physicalDigitCharacter) 441 + } 442 + return nil 443 + } 444 + 445 + var keyboardShortcut: KeyboardShortcut? { 446 + guard let keyEquivalent else { return nil } 447 + return KeyboardShortcut(keyEquivalent, modifiers: modifiers.eventModifiers) 448 + } 449 + 450 + var appShortcut: AppShortcut? { 451 + if let specialKeyEquivalent { 452 + return AppShortcut( 453 + keyEquivalent: specialKeyEquivalent, 454 + ghosttyKeyName: key, 455 + modifiers: modifiers.eventModifiers 456 + ) 457 + } 458 + if let singleCharacter { 459 + return AppShortcut(key: singleCharacter, modifiers: modifiers.eventModifiers) 460 + } 461 + if let physicalDigitCharacter { 462 + return AppShortcut(key: physicalDigitCharacter, modifiers: modifiers.eventModifiers) 463 + } 464 + return nil 465 + } 466 + 467 + var userCustomShortcut: UserCustomShortcut? { 468 + if key.count == 1 { 469 + return UserCustomShortcut(key: key, modifiers: .init(modifiers)) 470 + } 471 + if let physicalDigitCharacter { 472 + return UserCustomShortcut(key: String(physicalDigitCharacter), modifiers: .init(modifiers)) 473 + } 474 + return nil 475 + } 476 + 477 + private var specialKeyEquivalent: KeyEquivalent? { 478 + switch key { 479 + case "return": 480 + return .return 481 + case "arrow_up": 482 + return .upArrow 483 + case "arrow_down": 484 + return .downArrow 485 + case "arrow_left": 486 + return .leftArrow 487 + case "arrow_right": 488 + return .rightArrow 489 + default: 490 + return nil 491 + } 492 + } 493 + 494 + private var singleCharacter: Character? { 495 + guard key.count == 1 else { return nil } 496 + return key.first 497 + } 498 + 499 + private var physicalDigitCharacter: Character? { 500 + guard key.hasPrefix("digit_") else { return nil } 501 + let value = key.dropFirst("digit_".count) 502 + guard value.count == 1, let character = value.first, character.isNumber else { return nil } 503 + return character 504 + } 505 + } 506 + 507 + extension UserCustomShortcutModifiers { 508 + nonisolated init(_ modifiers: KeybindingModifiers) { 509 + self.init( 510 + command: modifiers.command, 511 + shift: modifiers.shift, 512 + option: modifiers.option, 513 + control: modifiers.control 514 + ) 515 + } 516 + }
+12
supacode/App/ResolvedKeybindingsEnvironment.swift
··· 1 + import SwiftUI 2 + 3 + private struct ResolvedKeybindingsEnvironmentKey: EnvironmentKey { 4 + static let defaultValue: ResolvedKeybindingMap = .appDefaults 5 + } 6 + 7 + extension EnvironmentValues { 8 + var resolvedKeybindings: ResolvedKeybindingMap { 9 + get { self[ResolvedKeybindingsEnvironmentKey.self] } 10 + set { self[ResolvedKeybindingsEnvironmentKey.self] = newValue } 11 + } 12 + }
+56 -18
supacode/App/supacodeApp.swift
··· 15 15 import SwiftUI 16 16 17 17 private enum GhosttyCLI { 18 - static let argv: [UnsafeMutablePointer<CChar>?] = { 18 + static func argv(resolvedKeybindings: ResolvedKeybindingMap) -> [UnsafeMutablePointer<CChar>?] { 19 19 var args: [UnsafeMutablePointer<CChar>?] = [] 20 20 let executable = CommandLine.arguments.first ?? "supacode" 21 21 args.append(strdup(executable)) 22 - for keybindArgument in AppShortcuts.ghosttyCLIKeybindArguments { 22 + for keybindArgument in AppShortcuts.ghosttyCLIKeybindArguments(from: resolvedKeybindings) { 23 23 args.append(strdup(keybindArgument)) 24 24 } 25 25 args.append(nil) 26 26 return args 27 - }() 27 + } 28 28 } 29 29 30 30 @MainActor ··· 35 35 func applicationDidFinishLaunching(_ notification: Notification) { 36 36 // Disable press-and-hold accent menu so that key repeat works in the terminal. 37 37 UserDefaults.standard.register(defaults: [ 38 - "ApplePressAndHoldEnabled": false, 38 + "ApplePressAndHoldEnabled": false 39 39 ]) 40 40 appStore?.send(.appLaunched) 41 41 } ··· 97 97 UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay") 98 98 @Shared(.settingsFile) var settingsFile 99 99 let initialSettings = settingsFile.global 100 + let initialResolvedKeybindings = KeybindingResolver.resolve( 101 + schema: .appResolverSchema(), 102 + userOverrides: initialSettings.keybindingUserOverrides 103 + ) 100 104 #if !DEBUG 101 105 if initialSettings.crashReportsEnabled { 102 106 SentrySDK.start { options in ··· 119 123 if let resourceURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty") { 120 124 setenv("GHOSTTY_RESOURCES_DIR", resourceURL.path, 1) 121 125 } 122 - GhosttyCLI.argv.withUnsafeBufferPointer { buffer in 126 + let ghosttyArgv = GhosttyCLI.argv(resolvedKeybindings: initialResolvedKeybindings) 127 + ghosttyArgv.withUnsafeBufferPointer { buffer in 123 128 let argc = UInt(max(0, buffer.count - 1)) 124 129 let argv = UnsafeMutablePointer(mutating: buffer.baseAddress) 125 130 if ghostty_init(argc, argv) != GHOSTTY_SUCCESS { ··· 184 189 ContentView(store: store, terminalManager: terminalManager) 185 190 .environment(ghosttyShortcuts) 186 191 .environment(commandKeyObserver) 192 + .environment(\.resolvedKeybindings, store.resolvedKeybindings) 193 + } 194 + .onAppear { 195 + syncGhosttyManagedShortcuts(with: store.resolvedKeybindings) 196 + } 197 + .onChange(of: store.resolvedKeybindings) { _, newValue in 198 + syncGhosttyManagedShortcuts(with: newValue) 187 199 } 188 200 .preferredColorScheme(store.settings.appearanceMode.colorScheme) 189 201 } ··· 193 205 WorktreeCommands(store: store) 194 206 SidebarCommands(store: store) 195 207 TerminalCommands(ghosttyShortcuts: ghosttyShortcuts) 196 - WindowCommands(ghosttyShortcuts: ghosttyShortcuts) 208 + WindowCommands( 209 + ghosttyShortcuts: ghosttyShortcuts, 210 + resolvedKeybindings: store.resolvedKeybindings 211 + ) 197 212 CommandGroup(after: .textEditing) { 198 213 Button("Command Palette") { 199 214 store.send(.commandPalette(.togglePresented)) 200 215 } 201 - .keyboardShortcut( 202 - AppShortcuts.commandPalette.keyEquivalent, 203 - modifiers: AppShortcuts.commandPalette.modifiers 216 + .modifier( 217 + KeyboardShortcutModifier( 218 + shortcut: store.resolvedKeybindings.keyboardShortcut( 219 + for: AppShortcuts.CommandID.commandPalette 220 + ) 221 + ) 204 222 ) 205 - .help("Command Palette (\(AppShortcuts.commandPalette.display))") 223 + .help(helpText(title: "Command Palette", commandID: AppShortcuts.CommandID.commandPalette)) 206 224 } 207 - UpdateCommands(store: store.scope(state: \.updates, action: \.updates)) 225 + UpdateCommands( 226 + store: store.scope(state: \.updates, action: \.updates), 227 + resolvedKeybindings: store.resolvedKeybindings 228 + ) 208 229 CommandGroup(replacing: .appSettings) { 209 230 Button("Settings...") { 210 231 SettingsWindowManager.shared.show() 211 232 } 212 - .keyboardShortcut( 213 - AppShortcuts.openSettings.keyEquivalent, 214 - modifiers: AppShortcuts.openSettings.modifiers 233 + .modifier( 234 + KeyboardShortcutModifier( 235 + shortcut: store.resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.openSettings) 236 + ) 215 237 ) 216 238 } 217 239 CommandGroup(replacing: .appTermination) { 218 240 Button("Quit Prowl") { 219 241 store.send(.requestQuit) 220 242 } 221 - .keyboardShortcut( 222 - AppShortcuts.quitApplication.keyEquivalent, 223 - modifiers: AppShortcuts.quitApplication.modifiers 243 + .modifier( 244 + KeyboardShortcutModifier( 245 + shortcut: store.resolvedKeybindings.keyboardShortcut( 246 + for: AppShortcuts.CommandID.quitApplication 247 + ) 248 + ) 224 249 ) 225 - .help("Quit Prowl (\(AppShortcuts.quitApplication.display))") 250 + .help(helpText(title: "Quit Prowl", commandID: AppShortcuts.CommandID.quitApplication)) 226 251 } 227 252 } 253 + } 254 + 255 + private func syncGhosttyManagedShortcuts(with resolvedKeybindings: ResolvedKeybindingMap) { 256 + ghostty.applyAppKeybindArguments( 257 + AppShortcuts.ghosttyCLIKeybindArguments(from: resolvedKeybindings) 258 + ) 259 + } 260 + 261 + private func helpText(title: String, commandID: String) -> String { 262 + if let shortcut = store.resolvedKeybindings.display(for: commandID) { 263 + return "\(title) (\(shortcut))" 264 + } 265 + return title 228 266 } 229 267 }
+18 -14
supacode/Commands/SidebarCommands.swift
··· 10 10 Button("Toggle Left Sidebar") { 11 11 toggleLeftSidebarAction?() 12 12 } 13 - .keyboardShortcut( 14 - AppShortcuts.toggleLeftSidebar.keyEquivalent, modifiers: AppShortcuts.toggleLeftSidebar.modifiers 15 - ) 16 - .help("Toggle Left Sidebar (\(AppShortcuts.toggleLeftSidebar.display))") 13 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.toggleLeftSidebar))) 14 + .help(helpText(title: "Toggle Left Sidebar", commandID: AppShortcuts.CommandID.toggleLeftSidebar)) 17 15 .disabled(toggleLeftSidebarAction == nil) 18 16 Divider() 19 17 Button("Canvas") { 20 18 store.send(.repositories(.toggleCanvas)) 21 19 } 22 - .keyboardShortcut( 23 - AppShortcuts.toggleCanvas.keyEquivalent, 24 - modifiers: AppShortcuts.toggleCanvas.modifiers 25 - ) 26 - .help("Canvas (\(AppShortcuts.toggleCanvas.display))") 20 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.toggleCanvas))) 21 + .help(helpText(title: "Canvas", commandID: AppShortcuts.CommandID.toggleCanvas)) 27 22 Button("Show Diff") { 28 23 let repos = store.repositories 29 24 guard let worktreeID = repos.selectedWorktreeID, ··· 32 27 DiffWindowManager.shared.show( 33 28 worktreeURL: worktree.workingDirectory, 34 29 branchName: worktree.name, 30 + resolvedKeybindings: store.resolvedKeybindings 35 31 ) 36 32 } 37 - .keyboardShortcut( 38 - AppShortcuts.showDiff.keyEquivalent, 39 - modifiers: AppShortcuts.showDiff.modifiers 40 - ) 41 - .help("Show Diff (\(AppShortcuts.showDiff.display))") 33 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.showDiff))) 34 + .help(helpText(title: "Show Diff", commandID: AppShortcuts.CommandID.showDiff)) 42 35 .disabled(store.repositories.selectedWorktreeID == nil) 43 36 } 37 + } 38 + 39 + private func keyboardShortcut(for commandID: String) -> KeyboardShortcut? { 40 + store.resolvedKeybindings.keyboardShortcut(for: commandID) 41 + } 42 + 43 + private func helpText(title: String, commandID: String) -> String { 44 + if let shortcut = store.resolvedKeybindings.display(for: commandID) { 45 + return "\(title) (\(shortcut))" 46 + } 47 + return title 44 48 } 45 49 } 46 50
+14 -5
supacode/Commands/UpdateCommands.swift
··· 3 3 4 4 struct UpdateCommands: Commands { 5 5 let store: StoreOf<UpdatesFeature> 6 + let resolvedKeybindings: ResolvedKeybindingMap 6 7 7 8 var body: some Commands { 8 9 CommandGroup(after: .appInfo) { 9 10 Button("Check for Updates...") { 10 11 store.send(.checkForUpdates) 11 12 } 12 - .keyboardShortcut( 13 - AppShortcuts.checkForUpdates.keyEquivalent, 14 - modifiers: AppShortcuts.checkForUpdates.modifiers 15 - ) 16 - .help("Check for Updates (\(AppShortcuts.checkForUpdates.display))") 13 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.checkForUpdates))) 14 + .help(helpText(title: "Check for Updates", commandID: AppShortcuts.CommandID.checkForUpdates)) 15 + } 16 + } 17 + 18 + private func keyboardShortcut(for commandID: String) -> KeyboardShortcut? { 19 + resolvedKeybindings.keyboardShortcut(for: commandID) 20 + } 21 + 22 + private func helpText(title: String, commandID: String) -> String { 23 + if let shortcut = resolvedKeybindings.display(for: commandID) { 24 + return "\(title) (\(shortcut))" 17 25 } 26 + return title 18 27 } 19 28 }
+33 -8
supacode/Commands/WindowCommands.swift
··· 2 2 3 3 struct WindowCommands: Commands { 4 4 let ghosttyShortcuts: GhosttyShortcutManager 5 + let resolvedKeybindings: ResolvedKeybindingMap 5 6 @FocusedValue(\.closeSurfaceAction) private var closeSurfaceAction 6 7 @FocusedValue(\.selectPreviousTerminalTabAction) private var selectPreviousTerminalTabAction 7 8 @FocusedValue(\.selectNextTerminalTabAction) private var selectNextTerminalTabAction ··· 41 42 selectPreviousTerminalTabAction?() 42 43 } 43 44 .modifier( 44 - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "previous_tab")) 45 + KeyboardShortcutModifier( 46 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.selectPreviousTerminalTab) 47 + ) 45 48 ) 46 49 .disabled(selectPreviousTerminalTabAction == nil) 47 50 ··· 49 52 selectNextTerminalTabAction?() 50 53 } 51 54 .modifier( 52 - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "next_tab")) 55 + KeyboardShortcutModifier( 56 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.selectNextTerminalTab) 57 + ) 53 58 ) 54 59 .disabled(selectNextTerminalTabAction == nil) 55 60 ··· 59 64 selectPreviousTerminalPaneAction?() 60 65 } 61 66 .modifier( 62 - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "goto_split:previous")) 67 + KeyboardShortcutModifier( 68 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.selectPreviousTerminalPane) 69 + ) 63 70 ) 64 71 .disabled(selectPreviousTerminalPaneAction == nil) 65 72 ··· 67 74 selectNextTerminalPaneAction?() 68 75 } 69 76 .modifier( 70 - KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "goto_split:next")) 77 + KeyboardShortcutModifier( 78 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.selectNextTerminalPane) 79 + ) 71 80 ) 72 81 .disabled(selectNextTerminalPaneAction == nil) 73 82 ··· 75 84 Button("Select Pane Above") { 76 85 selectTerminalPaneAboveAction?() 77 86 } 78 - .keyboardShortcut(.upArrow, modifiers: [.command, .option]) 87 + .modifier( 88 + KeyboardShortcutModifier( 89 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.selectTerminalPaneUp) 90 + ) 91 + ) 79 92 .disabled(selectTerminalPaneAboveAction == nil) 80 93 81 94 Button("Select Pane Below") { 82 95 selectTerminalPaneBelowAction?() 83 96 } 84 - .keyboardShortcut(.downArrow, modifiers: [.command, .option]) 97 + .modifier( 98 + KeyboardShortcutModifier( 99 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.selectTerminalPaneDown) 100 + ) 101 + ) 85 102 .disabled(selectTerminalPaneBelowAction == nil) 86 103 87 104 Button("Select Pane Left") { 88 105 selectTerminalPaneLeftAction?() 89 106 } 90 - .keyboardShortcut(.leftArrow, modifiers: [.command, .option]) 107 + .modifier( 108 + KeyboardShortcutModifier( 109 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.selectTerminalPaneLeft) 110 + ) 111 + ) 91 112 .disabled(selectTerminalPaneLeftAction == nil) 92 113 93 114 Button("Select Pane Right") { 94 115 selectTerminalPaneRightAction?() 95 116 } 96 - .keyboardShortcut(.rightArrow, modifiers: [.command, .option]) 117 + .modifier( 118 + KeyboardShortcutModifier( 119 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.selectTerminalPaneRight) 120 + ) 121 + ) 97 122 .disabled(selectTerminalPaneRightAction == nil) 98 123 } 99 124 }
+66 -57
supacode/Commands/WorktreeCommands.swift
··· 28 28 Button("Select Next Worktree") { 29 29 store.send(.repositories(.selectNextWorktree)) 30 30 } 31 - .keyboardShortcut( 32 - AppShortcuts.selectNextWorktree.keyEquivalent, 33 - modifiers: AppShortcuts.selectNextWorktree.modifiers 31 + .modifier( 32 + KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.selectNextWorktree)) 34 33 ) 35 - .help("Select Next Worktree (\(AppShortcuts.selectNextWorktree.display))") 34 + .help(helpText(title: "Select Next Worktree", commandID: AppShortcuts.CommandID.selectNextWorktree)) 36 35 .disabled(orderedRows.isEmpty) 37 36 Button("Select Previous Worktree") { 38 37 store.send(.repositories(.selectPreviousWorktree)) 39 38 } 40 - .keyboardShortcut( 41 - AppShortcuts.selectPreviousWorktree.keyEquivalent, 42 - modifiers: AppShortcuts.selectPreviousWorktree.modifiers 39 + .modifier( 40 + KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.selectPreviousWorktree)) 43 41 ) 44 - .help("Select Previous Worktree (\(AppShortcuts.selectPreviousWorktree.display))") 42 + .help(helpText(title: "Select Previous Worktree", commandID: AppShortcuts.CommandID.selectPreviousWorktree)) 45 43 .disabled(orderedRows.isEmpty) 46 44 Divider() 47 - ForEach(worktreeShortcuts.indices, id: \.self) { index in 48 - let shortcut = worktreeShortcuts[index] 49 - worktreeShortcutButton(index: index, shortcut: shortcut, orderedRows: orderedRows) 45 + ForEach(worktreeShortcutCommandIDs.indices, id: \.self) { index in 46 + let commandID = worktreeShortcutCommandIDs[index] 47 + worktreeShortcutButton(index: index, commandID: commandID, orderedRows: orderedRows) 50 48 } 51 49 } 52 50 CommandGroup(replacing: .newItem) { ··· 63 61 Button("Open Repository...", systemImage: "folder") { 64 62 store.send(.repositories(.setOpenPanelPresented(true))) 65 63 } 66 - .keyboardShortcut( 67 - AppShortcuts.openRepository.keyEquivalent, 68 - modifiers: AppShortcuts.openRepository.modifiers 69 - ) 70 - .help("Open Repository (\(AppShortcuts.openRepository.display))") 64 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.openRepository))) 65 + .help(helpText(title: "Open Repository", commandID: AppShortcuts.CommandID.openRepository)) 71 66 Button("Open Worktree") { 72 67 openSelectedWorktreeAction?() 73 68 } 74 - .keyboardShortcut( 75 - AppShortcuts.openFinder.keyEquivalent, 76 - modifiers: AppShortcuts.openFinder.modifiers 77 - ) 78 - .help("Open Worktree (\(AppShortcuts.openFinder.display))") 69 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.openWorktree))) 70 + .help(helpText(title: "Open Worktree", commandID: AppShortcuts.CommandID.openWorktree)) 79 71 .disabled(openSelectedWorktreeAction == nil) 80 72 Button("Open Pull Request on GitHub") { 81 73 if let pullRequestURL { 82 74 NSWorkspace.shared.open(pullRequestURL) 83 75 } 84 76 } 85 - .keyboardShortcut( 86 - AppShortcuts.openPullRequest.keyEquivalent, 87 - modifiers: AppShortcuts.openPullRequest.modifiers 88 - ) 89 - .help("Open Pull Request on GitHub (\(AppShortcuts.openPullRequest.display))") 77 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.openPullRequest))) 78 + .help(helpText(title: "Open Pull Request on GitHub", commandID: AppShortcuts.CommandID.openPullRequest)) 90 79 .disabled(pullRequestURL == nil || !githubIntegrationEnabled) 91 80 Button("New Worktree", systemImage: "plus") { 92 81 store.send(.repositories(.createRandomWorktree)) 93 82 } 94 - .keyboardShortcut( 95 - AppShortcuts.newWorktree.keyEquivalent, modifiers: AppShortcuts.newWorktree.modifiers 96 - ) 97 - .help("New Worktree (\(AppShortcuts.newWorktree.display))") 83 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.newWorktree))) 84 + .help(helpText(title: "New Worktree", commandID: AppShortcuts.CommandID.newWorktree)) 98 85 .disabled(!repositories.canCreateWorktree) 99 86 Button("Archived Worktrees") { 100 87 store.send(.repositories(.selectArchivedWorktrees)) 101 88 } 102 - .keyboardShortcut( 103 - AppShortcuts.archivedWorktrees.keyEquivalent, 104 - modifiers: AppShortcuts.archivedWorktrees.modifiers 105 - ) 106 - .help("Archived Worktrees (\(AppShortcuts.archivedWorktrees.display))") 89 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.archivedWorktrees))) 90 + .help(helpText(title: "Archived Worktrees", commandID: AppShortcuts.CommandID.archivedWorktrees)) 107 91 Button("Archive Worktree") { 108 92 archiveWorktreeAction?() 109 93 } ··· 124 108 Button("Refresh Worktrees") { 125 109 store.send(.repositories(.refreshWorktrees)) 126 110 } 127 - .keyboardShortcut( 128 - AppShortcuts.refreshWorktrees.keyEquivalent, 129 - modifiers: AppShortcuts.refreshWorktrees.modifiers 130 - ) 131 - .help("Refresh Worktrees (\(AppShortcuts.refreshWorktrees.display))") 111 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.refreshWorktrees))) 112 + .help(helpText(title: "Refresh Worktrees", commandID: AppShortcuts.CommandID.refreshWorktrees)) 132 113 Divider() 133 114 Button("Run Script") { 134 115 runScriptAction?() 135 116 } 136 - .keyboardShortcut( 137 - AppShortcuts.runScript.keyEquivalent, 138 - modifiers: AppShortcuts.runScript.modifiers 139 - ) 140 - .help("Run Script (\(AppShortcuts.runScript.display))") 117 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.runScript))) 118 + .help(helpText(title: "Run Script", commandID: AppShortcuts.CommandID.runScript)) 141 119 .disabled(runScriptAction == nil) 142 120 Button("Stop Script") { 143 121 stopRunScriptAction?() 144 122 } 145 - .keyboardShortcut( 146 - AppShortcuts.stopRunScript.keyEquivalent, 147 - modifiers: AppShortcuts.stopRunScript.modifiers 148 - ) 149 - .help("Stop Script (\(AppShortcuts.stopRunScript.display))") 123 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.stopScript))) 124 + .help(helpText(title: "Stop Script", commandID: AppShortcuts.CommandID.stopScript)) 150 125 .disabled(stopRunScriptAction == nil) 151 126 } 152 127 } 153 128 154 - private var worktreeShortcuts: [AppShortcut] { 155 - AppShortcuts.worktreeSelection 129 + private var worktreeShortcutCommandIDs: [String] { 130 + AppShortcuts.worktreeSelectionCommandIDs 156 131 } 157 132 158 133 private var selectedPullRequestURL: URL? { ··· 162 137 return pullRequest.flatMap { URL(string: $0.url) } 163 138 } 164 139 140 + private func keyboardShortcut(for commandID: String) -> KeyboardShortcut? { 141 + store.resolvedKeybindings.keyboardShortcut(for: commandID) 142 + } 143 + 144 + private func shortcutDisplay(for commandID: String) -> String? { 145 + store.resolvedKeybindings.display(for: commandID) 146 + } 147 + 148 + private func helpText(title: String, commandID: String) -> String { 149 + if let shortcut = shortcutDisplay(for: commandID) { 150 + return "\(title) (\(shortcut))" 151 + } 152 + return title 153 + } 154 + 155 + private func customCommandID(for command: UserCustomCommand) -> String { 156 + LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) 157 + } 158 + 159 + private func customCommandShortcut(for command: UserCustomCommand) -> KeyboardShortcut? { 160 + store.resolvedKeybindings.keyboardShortcut(for: customCommandID(for: command)) 161 + } 162 + 163 + private func customCommandShortcutDisplay(for command: UserCustomCommand) -> String? { 164 + store.resolvedKeybindings.display(for: customCommandID(for: command)) 165 + } 166 + 165 167 private func worktreeShortcutButton( 166 168 index: Int, 167 - shortcut: AppShortcut, 169 + commandID: String, 168 170 orderedRows: [WorktreeRowModel] 169 171 ) -> some View { 170 172 let row = orderedRows.indices.contains(index) ? orderedRows[index] : nil ··· 173 175 guard let row else { return } 174 176 store.send(.repositories(.selectWorktree(row.id))) 175 177 } 176 - .keyboardShortcut(shortcut.keyEquivalent, modifiers: shortcut.modifiers) 177 - .help("Switch to \(title) (\(shortcut.display))") 178 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: commandID))) 179 + .help( 180 + { 181 + if let shortcut = shortcutDisplay(for: commandID) { 182 + return "Switch to \(title) (\(shortcut))" 183 + } 184 + return "Switch to \(title)" 185 + }() 186 + ) 178 187 .disabled(row == nil) 179 188 } 180 189 ··· 192 201 ) -> some View { 193 202 let title = command.resolvedTitle 194 203 let helpText: String = 195 - if let shortcut = command.shortcut?.keyboardShortcut?.display { 204 + if let shortcut = customCommandShortcutDisplay(for: command) { 196 205 "\(title) (\(shortcut))" 197 206 } else { 198 207 title ··· 200 209 Button(title, systemImage: command.resolvedSystemImage) { 201 210 store.send(.runCustomCommand(index)) 202 211 } 203 - .modifier(KeyboardShortcutModifier(shortcut: command.shortcut?.keyboardShortcut)) 212 + .modifier(KeyboardShortcutModifier(shortcut: customCommandShortcut(for: command))) 204 213 .help(helpText) 205 214 .disabled(!hasActiveWorktree) 206 215 }
+54 -4
supacode/Features/App/Reducer/AppFeature.swift
··· 44 44 var openActionSelection: OpenWorktreeAction = .finder 45 45 var selectedRunScript: String = "" 46 46 var selectedCustomCommands: [UserCustomCommand] = [] 47 + var resolvedKeybindings: ResolvedKeybindingMap = .appDefaults 47 48 var runScriptDraft: String = "" 48 49 var isRunScriptPromptPresented = false 49 50 var runScriptStatusByWorktreeID: [Worktree.ID: Bool] = [:] ··· 111 112 @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher 112 113 @Dependency(CustomShortcutRegistryClient.self) private var customShortcutRegistryClient 113 114 115 + private func resolvedKeybindings( 116 + settings: SettingsFeature.State, 117 + customCommands: [UserCustomCommand] 118 + ) -> ResolvedKeybindingMap { 119 + let migration = LegacyCustomCommandShortcutMigration.migrate(commands: customCommands) 120 + var resolved = KeybindingResolver.resolve( 121 + schema: .appResolverSchema(customCommands: customCommands), 122 + userOverrides: settings.keybindingUserOverrides, 123 + migratedOverrides: migration.overrides 124 + ) 125 + let customCommandIDs = customCommands.map { command in 126 + LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) 127 + } 128 + let customCommandBindings = customCommandIDs.compactMap { resolved.keybinding(for: $0) } 129 + guard !customCommandBindings.isEmpty else { 130 + return resolved 131 + } 132 + for binding in AppShortcuts.bindings where binding.scope == .configurableAppAction { 133 + guard let resolvedBinding = resolved.binding(for: binding.id), 134 + let shortcut = resolvedBinding.binding, 135 + customCommandBindings.contains(shortcut) 136 + else { 137 + continue 138 + } 139 + resolved.bindingsByCommandID[binding.id] = ResolvedKeybinding( 140 + command: resolvedBinding.command, 141 + binding: nil, 142 + source: resolvedBinding.source 143 + ) 144 + } 145 + return resolved 146 + } 147 + 114 148 var body: some Reducer<State, Action> { 115 149 let core = Reduce<State, Action> { state, action in 116 150 switch action { ··· 174 208 state.openActionSelection = .finder 175 209 state.selectedRunScript = "" 176 210 state.selectedCustomCommands = [] 211 + state.resolvedKeybindings = resolvedKeybindings( 212 + settings: state.settings, 213 + customCommands: state.selectedCustomCommands 214 + ) 177 215 state.runScriptDraft = "" 178 216 state.isRunScriptPromptPresented = false 179 217 var effects: [Effect<Action>] = [ ··· 202 240 let rootURL = worktree.repositoryRootURL 203 241 let worktreeID = worktree.id 204 242 state.selectedCustomCommands = [] 243 + state.resolvedKeybindings = resolvedKeybindings( 244 + settings: state.settings, 245 + customCommands: state.selectedCustomCommands 246 + ) 205 247 state.runScriptDraft = "" 206 248 state.isRunScriptPromptPresented = false 207 249 @Shared(.repositorySettings(rootURL)) var repositorySettings ··· 340 382 settings: repositorySettings, 341 383 userSettings: userRepositorySettings 342 384 ) 343 - case .general, .notifications, .worktree, .updates, .advanced, .github: 385 + case .general, .notifications, .shortcuts, .worktree, .updates, .advanced, .github: 344 386 state.settings.repositorySettings = nil 345 387 } 346 388 return .none ··· 349 391 let shouldCheckSystemNotificationPermission = 350 392 settings.systemNotificationsEnabled && !state.lastKnownSystemNotificationsEnabled 351 393 state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled 394 + state.settings.keybindingUserOverrides = settings.keybindingUserOverrides 352 395 if let selectedWorktree = state.repositories.selectedTerminalWorktree { 353 396 let rootURL = selectedWorktree.repositoryRootURL 354 397 @Shared(.repositorySettings(rootURL)) var repositorySettings ··· 357 400 defaultEditorID: settings.defaultEditorID 358 401 ) 359 402 } 403 + state.resolvedKeybindings = resolvedKeybindings( 404 + settings: state.settings, 405 + customCommands: state.selectedCustomCommands 406 + ) 360 407 return .merge( 361 408 .send(.repositories(.setGithubIntegrationEnabled(settings.githubIntegrationEnabled))), 362 409 .send( ··· 700 747 return .none 701 748 } 702 749 state.selectedCustomCommands = UserRepositorySettings.normalizedCommands(settings.customCommands) 703 - .filter(\.hasRunnableCommand) 750 + state.resolvedKeybindings = resolvedKeybindings( 751 + settings: state.settings, 752 + customCommands: state.selectedCustomCommands 753 + ) 704 754 let userOverrideConflicts = AppShortcuts.userOverrideConflicts(in: state.selectedCustomCommands) 705 755 let shortcuts: [UserCustomShortcut] = state.selectedCustomCommands.compactMap { command in 706 - guard let shortcut = command.shortcut, shortcut.isValid else { return nil } 707 - return shortcut.normalized() 756 + let commandID = LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) 757 + return state.resolvedKeybindings.keybinding(for: commandID)?.userCustomShortcut 708 758 } 709 759 return .run { _ in 710 760 let logger = SupaLogger("Shortcuts")
+11 -3
supacode/Features/Canvas/Views/CanvasSidebarButton.swift
··· 5 5 let store: StoreOf<RepositoriesFeature> 6 6 let isSelected: Bool 7 7 @Environment(CommandKeyObserver.self) private var commandKeyObserver 8 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 8 9 9 10 var body: some View { 10 11 Button { ··· 14 15 Label("Canvas", systemImage: "square.grid.2x2") 15 16 .font(.callout) 16 17 .frame(maxWidth: .infinity, alignment: .leading) 17 - if commandKeyObserver.isPressed { 18 - ShortcutHintView(text: AppShortcuts.toggleCanvas.display, color: .secondary) 18 + if commandKeyObserver.isPressed, 19 + let shortcut = AppShortcuts.display(for: AppShortcuts.CommandID.toggleCanvas, in: resolvedKeybindings) 20 + { 21 + ShortcutHintView(text: shortcut, color: .secondary) 19 22 } 20 23 } 21 24 } ··· 23 26 .padding(.horizontal, 12) 24 27 .padding(.vertical, 6) 25 28 .background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: .rect(cornerRadius: 6)) 26 - .help("Canvas (\(AppShortcuts.toggleCanvas.display))") 29 + .help( 30 + AppShortcuts.helpText( 31 + title: "Canvas", 32 + commandID: AppShortcuts.CommandID.toggleCanvas, 33 + in: resolvedKeybindings 34 + )) 27 35 } 28 36 }
+21 -7
supacode/Features/Canvas/Views/CanvasView.swift
··· 3 3 4 4 struct CanvasView: View { 5 5 @Environment(CommandKeyObserver.self) private var commandKeyObserver 6 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 6 7 7 8 let terminalManager: WorktreeTerminalManager 8 9 var onExitToTab: () -> Void = {} ··· 26 27 private let cardSpacing: CGFloat = 20 27 28 28 29 var body: some View { 30 + let selectAllCanvasShortcut = AppShortcuts.resolvedShortcut( 31 + for: AppShortcuts.CommandID.selectAllCanvasCards, 32 + in: resolvedKeybindings 33 + ) 29 34 CanvasScrollContainer(offset: $canvasOffset, lastOffset: $lastCanvasOffset) { 30 35 GeometryReader { _ in 31 36 let activeStates = terminalManager.activeWorktreeStates ··· 153 158 clearSelection(states: terminalManager.activeWorktreeStates) 154 159 return .handled 155 160 } 156 - .onKeyPress(AppShortcuts.selectAllCanvasCards.keyEquivalent, phases: .down) { keyPress in 157 - guard keyPress.modifiers == AppShortcuts.selectAllCanvasCards.modifiers else { return .ignored } 161 + .onKeyPress( 162 + selectAllCanvasShortcut?.keyEquivalent ?? AppShortcuts.selectAllCanvasCards.keyEquivalent, 163 + phases: .down 164 + ) { keyPress in 165 + let shortcutModifiers = selectAllCanvasShortcut?.modifiers ?? AppShortcuts.selectAllCanvasCards.modifiers 166 + guard keyPress.modifiers == shortcutModifiers else { return .ignored } 158 167 selectAllCards() 159 168 return .handled 160 169 } ··· 409 418 "Broadcasting to \(selectionState.selectedTabIDs.count) cards", 410 419 systemImage: "dot.radiowaves.left.and.right" 411 420 ) 412 - .font(.callout) 413 - .padding(.horizontal, 10) 414 - .padding(.vertical, 6) 415 - .background(.bar, in: Capsule()) 421 + .font(.callout) 422 + .padding(.horizontal, 10) 423 + .padding(.vertical, 6) 424 + .background(.bar, in: Capsule()) 416 425 } 417 426 418 427 Button { ··· 423 432 .accessibilityLabel("Select All") 424 433 } 425 434 .buttonStyle(.bordered) 426 - .help("Select all cards for broadcast (\(AppShortcuts.selectAllCanvasCards.display))") 435 + .help( 436 + AppShortcuts.helpText( 437 + title: "Select all cards for broadcast", 438 + commandID: AppShortcuts.CommandID.selectAllCanvasCards, 439 + in: resolvedKeybindings 440 + )) 427 441 428 442 Button { 429 443 withAnimation(.easeInOut(duration: 0.2)) {
+18 -14
supacode/Features/CommandPalette/CommandPaletteItem.swift
··· 93 93 } 94 94 } 95 95 96 - var appShortcut: AppShortcut? { 96 + var appShortcutCommandID: String? { 97 97 switch kind { 98 98 case .checkForUpdates: 99 - return AppShortcuts.checkForUpdates 99 + return AppShortcuts.CommandID.checkForUpdates 100 100 case .openRepository: 101 - return AppShortcuts.openRepository 101 + return AppShortcuts.CommandID.openRepository 102 102 case .openSettings: 103 - return AppShortcuts.openSettings 103 + return AppShortcuts.CommandID.openSettings 104 104 case .newWorktree: 105 - return AppShortcuts.newWorktree 105 + return AppShortcuts.CommandID.newWorktree 106 106 case .refreshWorktrees: 107 - return AppShortcuts.refreshWorktrees 108 - case .ghosttyCommand: 109 - return nil 107 + return AppShortcuts.CommandID.refreshWorktrees 110 108 case .openPullRequest: 111 - return AppShortcuts.openPullRequest 112 - case .markPullRequestReady, 109 + return AppShortcuts.CommandID.openPullRequest 110 + case .ghosttyCommand, 111 + .markPullRequestReady, 113 112 .mergePullRequest, 114 113 .closePullRequest, 115 114 .copyFailingJobURL, ··· 127 126 } 128 127 } 129 128 130 - var appShortcutLabel: String? { 131 - appShortcut?.display 129 + func appShortcut(in resolvedKeybindings: ResolvedKeybindingMap) -> AppShortcut? { 130 + guard let commandID = appShortcutCommandID else { return nil } 131 + return AppShortcuts.resolvedShortcut(for: commandID, in: resolvedKeybindings) 132 + } 133 + 134 + func appShortcutLabel(in resolvedKeybindings: ResolvedKeybindingMap) -> String? { 135 + appShortcut(in: resolvedKeybindings)?.display 132 136 } 133 137 134 - var appShortcutSymbols: [String]? { 135 - appShortcut?.displaySymbols 138 + func appShortcutSymbols(in resolvedKeybindings: ResolvedKeybindingMap) -> [String]? { 139 + appShortcut(in: resolvedKeybindings)?.displaySymbols 136 140 } 137 141 }
+9 -2
supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift
··· 6 6 struct CommandPaletteOverlayView: View { 7 7 @Bindable var store: StoreOf<CommandPaletteFeature> 8 8 let items: [CommandPaletteItem] 9 + let resolvedKeybindings: ResolvedKeybindingMap 9 10 @FocusState private var isQueryFocused: Bool 10 11 @State private var hoveredID: CommandPaletteItem.ID? 11 12 @State private var filteredItems: [CommandPaletteItem] = [] ··· 34 35 query: $store.query, 35 36 selectedIndex: $store.selectedIndex, 36 37 items: filteredItems, 38 + resolvedKeybindings: resolvedKeybindings, 37 39 hoveredID: $hoveredID, 38 40 isQueryFocused: _isQueryFocused, 39 41 onEvent: { event in ··· 153 155 @Binding var query: String 154 156 @Binding var selectedIndex: Int? 155 157 let items: [CommandPaletteItem] 158 + let resolvedKeybindings: ResolvedKeybindingMap 156 159 @Binding var hoveredID: CommandPaletteItem.ID? 157 160 let isQueryFocused: FocusState<Bool> 158 161 let onEvent: (CommandPaletteKeyboardEvent) -> Void ··· 180 183 181 184 CommandPaletteList( 182 185 rows: items, 186 + resolvedKeybindings: resolvedKeybindings, 183 187 selectedIndex: $selectedIndex, 184 188 hoveredID: $hoveredID 185 189 ) { id in ··· 288 292 static let listHeight: CGFloat = 200 289 293 290 294 let rows: [CommandPaletteItem] 295 + let resolvedKeybindings: ResolvedKeybindingMap 291 296 @Binding var selectedIndex: Int? 292 297 @Binding var hoveredID: CommandPaletteItem.ID? 293 298 let activate: (CommandPaletteItem.ID) -> Void ··· 302 307 ForEach(Array(rows.enumerated()), id: \.1.id) { index, row in 303 308 CommandPaletteRowView( 304 309 row: row, 310 + resolvedKeybindings: resolvedKeybindings, 305 311 shortcutIndex: index < 5 ? index : nil, 306 312 isSelected: isRowSelected(index: index), 307 313 hoveredID: $hoveredID ··· 333 339 334 340 private struct CommandPaletteRowView: View { 335 341 let row: CommandPaletteItem 342 + let resolvedKeybindings: ResolvedKeybindingMap 336 343 let shortcutIndex: Int? 337 344 let isSelected: Bool 338 345 @Binding var hoveredID: CommandPaletteItem.ID? ··· 533 540 } 534 541 535 542 private var titleText: String { 536 - guard let shortcutLabel = row.appShortcutLabel else { 543 + guard let shortcutLabel = row.appShortcutLabel(in: resolvedKeybindings) else { 537 544 return row.title 538 545 } 539 546 return "\(row.title) (\(shortcutLabel))" 540 547 } 541 548 542 549 private var explicitShortcutLabel: String? { 543 - row.appShortcutLabel 550 + row.appShortcutLabel(in: resolvedKeybindings) 544 551 } 545 552 } 546 553
+7 -1
supacode/Features/DiffView/DiffWindowContentView.swift
··· 5 5 var state: DiffWindowState 6 6 @State private var columnVisibility: NavigationSplitViewVisibility = .automatic 7 7 @AppStorage("diffViewStyle") private var diffStyleRaw = DiffStyle.split.rawValue 8 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 8 9 9 10 private var diffStyle: DiffStyle { 10 11 DiffStyle(rawValue: diffStyleRaw) ?? .split ··· 37 38 Image(systemName: "sidebar.left") 38 39 .accessibilityLabel("Toggle Sidebar") 39 40 } 40 - .help("Toggle Sidebar (\(AppShortcuts.toggleLeftSidebar.display))") 41 + .help( 42 + AppShortcuts.helpText( 43 + title: "Toggle Sidebar", 44 + commandID: AppShortcuts.CommandID.toggleLeftSidebar, 45 + in: resolvedKeybindings 46 + )) 41 47 } 42 48 ToolbarItem(id: "diffStyle", placement: .primaryAction) { 43 49 Picker("Diff Style", selection: $diffStyleRaw) {
+13 -3
supacode/Features/DiffView/DiffWindowManager.swift
··· 12 12 13 13 private init() {} 14 14 15 - func show(worktreeURL: URL, branchName: String) { 15 + func show( 16 + worktreeURL: URL, 17 + branchName: String, 18 + resolvedKeybindings: ResolvedKeybindingMap = .appDefaults 19 + ) { 16 20 state.load(worktreeURL: worktreeURL, branchName: branchName) 17 21 skipNextFocusRefresh = true 22 + let rootView = AnyView( 23 + DiffWindowContentView(state: state) 24 + .environment(\.resolvedKeybindings, resolvedKeybindings) 25 + ) 18 26 19 27 if let existingWindow = window { 28 + if let hostingController = existingWindow.contentViewController as? NSHostingController<AnyView> { 29 + hostingController.rootView = rootView 30 + } 20 31 existingWindow.title = windowTitle(branchName: branchName) 21 32 if existingWindow.isMiniaturized { 22 33 existingWindow.deminiaturize(nil) ··· 25 36 return 26 37 } 27 38 28 - let contentView = DiffWindowContentView(state: state) 29 - let hostingController = NSHostingController(rootView: contentView) 39 + let hostingController = NSHostingController(rootView: rootView) 30 40 31 41 let newWindow = NSWindow(contentViewController: hostingController) 32 42 newWindow.title = windowTitle(branchName: branchName)
-9
supacode/Features/Repositories/Views/DetailToolbarTitle.swift
··· 24 24 } 25 25 } 26 26 27 - var helpText: String? { 28 - switch kind { 29 - case .branch: 30 - return "Rename branch (\(AppShortcuts.renameBranch.display))" 31 - case .folder: 32 - return nil 33 - } 34 - } 35 - 36 27 var supportsRename: Bool { 37 28 if case .branch = kind { 38 29 return true
+22 -10
supacode/Features/Repositories/Views/EmptyStateView.swift
··· 3 3 4 4 struct EmptyStateView: View { 5 5 let store: StoreOf<RepositoriesFeature> 6 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 6 7 7 8 var body: some View { 9 + let shortcutDisplay = AppShortcuts.display(for: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings) 8 10 VStack { 9 11 Image(systemName: "tray") 10 12 .font(.title2) 11 13 .accessibilityHidden(true) 12 14 Text("Open a repository or folder") 13 15 .font(.headline) 14 - Text( 15 - "Press \(AppShortcuts.openRepository.display) " 16 - + "or click Open Repository to choose a folder." 17 - ) 18 - .font(.subheadline) 19 - .foregroundStyle(.secondary) 16 + Text(promptText(shortcutDisplay: shortcutDisplay)) 17 + .font(.subheadline) 18 + .foregroundStyle(.secondary) 20 19 Button("Open Repository...") { 21 20 store.send(.setOpenPanelPresented(true)) 22 21 } 23 - .keyboardShortcut( 24 - AppShortcuts.openRepository.keyEquivalent, 25 - modifiers: AppShortcuts.openRepository.modifiers 22 + .modifier( 23 + KeyboardShortcutModifier( 24 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.openRepository) 25 + ) 26 26 ) 27 - .help("Open Repository (\(AppShortcuts.openRepository.display))") 27 + .help( 28 + AppShortcuts.helpText( 29 + title: "Open Repository", 30 + commandID: AppShortcuts.CommandID.openRepository, 31 + in: resolvedKeybindings 32 + )) 28 33 } 29 34 .frame(maxWidth: .infinity, maxHeight: .infinity) 30 35 .background(Color(nsColor: .windowBackgroundColor)) 31 36 .multilineTextAlignment(.center) 37 + } 38 + 39 + private func promptText(shortcutDisplay: String?) -> String { 40 + if let shortcutDisplay { 41 + return "Press \(shortcutDisplay) or click Open Repository to choose a folder." 42 + } 43 + return "Click Open Repository to choose a folder." 32 44 } 33 45 }
+16 -1
supacode/Features/Repositories/Views/PullRequestChecksPopoverButton.swift
··· 8 8 @State private var isHoveringPopover = false 9 9 @State private var closeTask: Task<Void, Never>? 10 10 @Environment(\.openURL) private var openURL 11 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 11 12 12 13 var body: some View { 13 14 let pullRequestURL = URL(string: pullRequest.url) ··· 21 22 } 22 23 .buttonStyle(.plain) 23 24 .contentShape(.rect) 24 - .help("Open pull request on GitHub (\(AppShortcuts.openPullRequest.display)). Hover to show checks.") 25 + .help(openPullRequestHelpText) 25 26 .accessibilityLabel("Open pull request on GitHub") 26 27 .onHover { hovering in 27 28 isHoveringButton = hovering ··· 59 60 isPresented = false 60 61 } 61 62 } 63 + } 64 + 65 + private var openPullRequestShortcut: AppShortcut? { 66 + AppShortcuts.resolvedShortcut( 67 + for: AppShortcuts.CommandID.openPullRequest, 68 + in: resolvedKeybindings 69 + ) 70 + } 71 + 72 + private var openPullRequestHelpText: String { 73 + if let display = openPullRequestShortcut?.display { 74 + return "Open pull request on GitHub (\(display)). Hover to show checks." 75 + } 76 + return "Open pull request on GitHub. Hover to show checks." 62 77 } 63 78 }
+16 -2
supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift
··· 7 7 private let sortedChecks: [GithubPullRequestStatusCheck] 8 8 @Environment(\.analyticsClient) private var analyticsClient 9 9 @Environment(\.openURL) private var openURL 10 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 10 11 11 12 init( 12 13 pullRequest: GithubPullRequest, ··· 58 59 } 59 60 .buttonStyle(.plain) 60 61 .focusable(false) 61 - .help("Open pull request on GitHub (\(AppShortcuts.openPullRequest.display))") 62 - .keyboardShortcut(AppShortcuts.openPullRequest.keyboardShortcut) 62 + .help(openPullRequestHelpText) 63 + .modifier(KeyboardShortcutModifier(shortcut: openPullRequestShortcut?.keyboardShortcut)) 63 64 .font(.headline) 64 65 } else { 65 66 titleLine ··· 145 146 } 146 147 } 147 148 149 + private var openPullRequestShortcut: AppShortcut? { 150 + AppShortcuts.resolvedShortcut( 151 + for: AppShortcuts.CommandID.openPullRequest, 152 + in: resolvedKeybindings 153 + ) 154 + } 155 + 156 + private var openPullRequestHelpText: String { 157 + if let display = openPullRequestShortcut?.display { 158 + return "Open pull request on GitHub (\(display))" 159 + } 160 + return "Open pull request on GitHub" 161 + } 148 162 }
+14 -2
supacode/Features/Repositories/Views/PullRequestStatusButton.swift
··· 3 3 struct PullRequestStatusButton: View { 4 4 let model: PullRequestStatusModel 5 5 @Environment(CommandKeyObserver.self) private var commandKeyObserver 6 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 6 7 7 8 var body: some View { 8 9 PullRequestChecksPopoverButton(pullRequest: model.pullRequest) { ··· 20 21 if let detailText = model.detailText { 21 22 Text( 22 23 commandKeyObserver.isPressed 23 - ? "Open on GitHub \(AppShortcuts.openPullRequest.display)" : detailText 24 + ? openPullRequestLabel : detailText 24 25 ) 25 26 .lineLimit(1) 26 27 } else if commandKeyObserver.isPressed { 27 - Text("Open on GitHub \(AppShortcuts.openPullRequest.display)") 28 + Text(openPullRequestLabel) 28 29 .lineLimit(1) 29 30 } 30 31 if model.detailText == nil, !commandKeyObserver.isPressed { ··· 34 35 } 35 36 } 36 37 .font(.caption) 38 + } 39 + 40 + private var openPullRequestLabel: String { 41 + let shortcut = AppShortcuts.resolvedShortcut( 42 + for: AppShortcuts.CommandID.openPullRequest, 43 + in: resolvedKeybindings 44 + )?.display 45 + if let shortcut { 46 + return "Open on GitHub \(shortcut)" 47 + } 48 + return "Open on GitHub" 37 49 } 38 50 } 39 51
+8 -1
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 12 12 @Bindable var store: StoreOf<RepositoriesFeature> 13 13 let terminalManager: WorktreeTerminalManager 14 14 @Environment(\.colorScheme) private var colorScheme 15 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 15 16 @State private var isHovering = false 16 17 17 18 var body: some View { ··· 122 123 } 123 124 .buttonStyle(.plain) 124 125 .foregroundStyle(.secondary) 125 - .help("New Worktree (\(AppShortcuts.newWorktree.display))") 126 + .help( 127 + AppShortcuts.helpText( 128 + title: "New Worktree", 129 + commandID: AppShortcuts.CommandID.newWorktree, 130 + in: resolvedKeybindings 131 + ) 132 + ) 126 133 .disabled(isRemovingRepository) 127 134 } 128 135 if repository.capabilities.supportsWorktrees {
+34 -6
supacode/Features/Repositories/Views/SidebarFooterView.swift
··· 6 6 @Environment(\.surfaceBottomChromeBackgroundOpacity) private var surfaceBottomChromeBackgroundOpacity 7 7 @Environment(\.openURL) private var openURL 8 8 @Environment(CommandKeyObserver.self) private var commandKeyObserver 9 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 9 10 10 11 var body: some View { 11 12 HStack { ··· 15 16 HStack(spacing: 6) { 16 17 Label("Add Repository", systemImage: "folder.badge.plus") 17 18 .font(.callout) 18 - if commandKeyObserver.isPressed { 19 - ShortcutHintView(text: AppShortcuts.openRepository.display, color: .secondary) 19 + if commandKeyObserver.isPressed, 20 + let shortcut = shortcutDisplay(for: AppShortcuts.CommandID.openRepository) 21 + { 22 + ShortcutHintView(text: shortcut, color: .secondary) 20 23 } 21 24 } 22 25 } 23 - .help("Add Repository (\(AppShortcuts.openRepository.display))") 26 + .help( 27 + AppShortcuts.helpText( 28 + title: "Add Repository", 29 + commandID: AppShortcuts.CommandID.openRepository, 30 + in: resolvedKeybindings 31 + )) 24 32 Spacer() 25 33 Menu { 26 34 Button("Submit GitHub issue", systemImage: "exclamationmark.bubble") { ··· 42 50 .symbolEffect(.rotate, options: .repeating, isActive: store.state.isRefreshingWorktrees) 43 51 .accessibilityLabel("Refresh Worktrees") 44 52 } 45 - .help("Refresh Worktrees (\(AppShortcuts.refreshWorktrees.display))") 53 + .help( 54 + AppShortcuts.helpText( 55 + title: "Refresh Worktrees", 56 + commandID: AppShortcuts.CommandID.refreshWorktrees, 57 + in: resolvedKeybindings 58 + ) 59 + ) 46 60 .disabled(store.state.repositoryRoots.isEmpty && !store.state.isRefreshingWorktrees) 47 61 Button { 48 62 store.send(.selectArchivedWorktrees) ··· 50 64 Image(systemName: "archivebox") 51 65 .accessibilityLabel("Archived Worktrees") 52 66 } 53 - .help("Archived Worktrees (\(AppShortcuts.archivedWorktrees.display))") 67 + .help( 68 + AppShortcuts.helpText( 69 + title: "Archived Worktrees", 70 + commandID: AppShortcuts.CommandID.archivedWorktrees, 71 + in: resolvedKeybindings 72 + )) 54 73 Button("Settings", systemImage: "gearshape") { 55 74 SettingsWindowManager.shared.show() 56 75 } 57 76 .labelStyle(.iconOnly) 58 - .help("Settings (\(AppShortcuts.openSettings.display))") 77 + .help( 78 + AppShortcuts.helpText( 79 + title: "Settings", 80 + commandID: AppShortcuts.CommandID.openSettings, 81 + in: resolvedKeybindings 82 + )) 59 83 } 60 84 .buttonStyle(.plain) 61 85 .font(.callout) ··· 66 90 .overlay(alignment: .top) { 67 91 Divider() 68 92 } 93 + } 94 + 95 + private func shortcutDisplay(for commandID: String) -> String? { 96 + AppShortcuts.display(for: commandID, in: resolvedKeybindings) 69 97 } 70 98 }
+8 -1
supacode/Features/Repositories/Views/ToolbarStatusView.swift
··· 41 41 } 42 42 43 43 private struct MotivationalStatusView: View { 44 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 45 + 44 46 var body: some View { 45 47 TimelineView(.everyMinute) { context in 46 48 let hour = Calendar.current.component(.hour, from: context.date) 47 49 let style = timeStyle(for: hour) 50 + let commandPaletteHint = AppShortcuts.helpText( 51 + title: "Open Command Palette", 52 + commandID: AppShortcuts.CommandID.commandPalette, 53 + in: resolvedKeybindings 54 + ) 48 55 HStack(spacing: 8) { 49 56 Image(systemName: style.icon) 50 57 .foregroundStyle(style.color) 51 58 .font(.callout) 52 59 .accessibilityHidden(true) 53 - Text("\(context.date, format: .dateTime.hour().minute()) – Open Command Palette (⌘P)") 60 + Text("\(context.date, format: .dateTime.hour().minute()) – \(commandPaletteHint)") 54 61 .font(.footnote) 55 62 .monospaced() 56 63 .foregroundStyle(.secondary)
+11 -4
supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift
··· 3 3 struct WorktreeDetailTitleView: View { 4 4 let title: DetailToolbarTitle 5 5 let onSubmit: ((String) -> Void)? 6 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 6 7 7 8 @State private var isPresented = false 8 9 @State private var isHovered = false ··· 17 18 } label: { 18 19 labelContent 19 20 } 20 - .help(title.helpText ?? "") 21 - .keyboardShortcut( 22 - AppShortcuts.renameBranch.keyEquivalent, 23 - modifiers: AppShortcuts.renameBranch.modifiers 21 + .help( 22 + AppShortcuts.helpText( 23 + title: "Rename branch", 24 + commandID: AppShortcuts.CommandID.renameBranch, 25 + in: resolvedKeybindings 26 + ) 24 27 ) 28 + .modifier( 29 + KeyboardShortcutModifier( 30 + shortcut: resolvedKeybindings.keyboardShortcut(for: AppShortcuts.CommandID.renameBranch) 31 + )) 25 32 } else { 26 33 labelContent 27 34 }
+129 -44
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 121 121 } 122 122 123 123 private func toolbarState(input: ToolbarStateInput) -> WorktreeToolbarState? { 124 - guard let title = DetailToolbarTitle.forSelection( 125 - worktree: input.selectedWorktree, 126 - repository: input.repositories.selectedRepository 127 - ) else { 124 + guard 125 + let title = DetailToolbarTitle.forSelection( 126 + worktree: input.selectedWorktree, 127 + repository: input.repositories.selectedRepository 128 + ) 129 + else { 128 130 return nil 129 131 } 130 132 let pullRequest = input.selectedWorktree.flatMap { input.repositories.worktreeInfo(for: $0.id)?.pullRequest } ··· 189 191 selectedWorktreeSummaries: [MultiSelectedWorktreeSummary] 190 192 ) -> some View { 191 193 if repositories.isShowingCanvas { 192 - CanvasView(terminalManager: terminalManager, onExitToTab: { 193 - store.send(.repositories(.toggleCanvas)) 194 - }) 194 + CanvasView( 195 + terminalManager: terminalManager, 196 + onExitToTab: { 197 + store.send(.repositories(.toggleCanvas)) 198 + }) 195 199 } else if repositories.isShowingArchivedWorktrees { 196 200 ArchivedWorktreesDetailView( 197 201 store: store.scope(state: \.repositories, action: \.repositories) ··· 386 390 let runScriptEnabled: Bool 387 391 let runScriptIsRunning: Bool 388 392 let customCommands: [UserCustomCommand] 389 - 390 - var runScriptHelpText: String { 391 - "Run Script (\(AppShortcuts.runScript.display))" 392 - } 393 - 394 - var stopRunScriptHelpText: String { 395 - "Stop Script (\(AppShortcuts.stopRunScript.display))" 396 - } 397 393 } 398 394 399 395 fileprivate struct WorktreeToolbarContent: ToolbarContent { ··· 407 403 let onRunScript: () -> Void 408 404 let onStopRunScript: () -> Void 409 405 let onRunCustomCommand: (Int) -> Void 406 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 410 407 411 408 var body: some ToolbarContent { 412 409 ToolbarItem { ··· 458 455 } label: { 459 456 OpenWorktreeActionMenuLabelView( 460 457 action: resolvedOpenActionSelection, 461 - shortcutHint: showExtras ? AppShortcuts.openFinder.display : nil 458 + shortcutHint: showExtras ? shortcutDisplay(for: AppShortcuts.CommandID.openWorktree) : nil 462 459 ) 463 460 } 464 461 .help(openActionHelpText(for: resolvedOpenActionSelection, isDefault: true)) ··· 493 490 } 494 491 495 492 private func openActionHelpText(for action: OpenWorktreeAction, isDefault: Bool) -> String { 496 - isDefault 497 - ? "\(action.title) (\(AppShortcuts.openFinder.display))" 498 - : action.title 493 + guard isDefault else { return action.title } 494 + return AppShortcuts.helpText( 495 + title: action.title, 496 + commandID: AppShortcuts.CommandID.openWorktree, 497 + in: resolvedKeybindings 498 + ) 499 499 } 500 500 501 501 @ToolbarContentBuilder ··· 505 505 RunScriptToolbarButton( 506 506 isRunning: toolbarState.runScriptIsRunning, 507 507 isEnabled: toolbarState.runScriptEnabled, 508 - runHelpText: toolbarState.runScriptHelpText, 509 - stopHelpText: toolbarState.stopRunScriptHelpText, 510 - runShortcut: AppShortcuts.runScript.display, 511 - stopShortcut: AppShortcuts.stopRunScript.display, 508 + runHelpText: AppShortcuts.helpText( 509 + title: "Run Script", 510 + commandID: AppShortcuts.CommandID.runScript, 511 + in: resolvedKeybindings 512 + ), 513 + stopHelpText: AppShortcuts.helpText( 514 + title: "Stop Script", 515 + commandID: AppShortcuts.CommandID.stopScript, 516 + in: resolvedKeybindings 517 + ), 518 + runShortcut: shortcutDisplay(for: AppShortcuts.CommandID.runScript), 519 + stopShortcut: shortcutDisplay(for: AppShortcuts.CommandID.stopScript), 512 520 runAction: onRunScript, 513 521 stopAction: onStopRunScript 514 522 ) 515 523 } 516 524 } 517 525 518 - if let command = customCommand(at: 0) { 519 - ToolbarItem { 520 - customCommandButton(command, index: 0) 526 + let entries = customCommandEntries 527 + let inlineEntries = Array(entries.prefix(3)) 528 + let overflowEntries = Array(entries.dropFirst(3)) 529 + 530 + if !inlineEntries.isEmpty { 531 + ToolbarItemGroup { 532 + ForEach(inlineEntries, id: \.command.id) { entry in 533 + customCommandButton(entry.command, index: entry.index) 534 + } 521 535 } 522 536 } 523 - if let command = customCommand(at: 1) { 537 + 538 + if !overflowEntries.isEmpty { 524 539 ToolbarItem { 525 - customCommandButton(command, index: 1) 526 - } 527 - } 528 - if let command = customCommand(at: 2) { 529 - ToolbarItem { 530 - customCommandButton(command, index: 2) 540 + CustomCommandOverflowButton( 541 + entries: overflowEntries, 542 + shortcutDisplay: customCommandShortcutDisplay(for:), 543 + onRunCustomCommand: onRunCustomCommand 544 + ) 531 545 } 532 546 } 533 547 } 534 548 535 - private func customCommand(at index: Int) -> UserCustomCommand? { 536 - guard toolbarState.customCommands.indices.contains(index) else { 537 - return nil 538 - } 539 - return toolbarState.customCommands[index] 549 + private var customCommandEntries: [(index: Int, command: UserCustomCommand)] { 550 + Array(toolbarState.customCommands.enumerated()).map { (index: $0.offset, command: $0.element) } 540 551 } 541 552 542 553 private func customCommandButton(_ command: UserCustomCommand, index: Int) -> some View { 543 554 UserCustomCommandToolbarButton( 544 555 title: command.resolvedTitle, 545 556 systemImage: command.resolvedSystemImage, 546 - shortcut: command.shortcut?.isValid == true ? command.shortcut?.display : nil, 557 + shortcut: customCommandShortcutDisplay(for: command), 558 + isEnabled: command.hasRunnableCommand, 547 559 action: { 548 560 onRunCustomCommand(index) 549 561 } 550 562 ) 563 + } 564 + 565 + private func customCommandShortcutDisplay(for command: UserCustomCommand) -> String? { 566 + shortcutDisplay(for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id)) 567 + } 568 + 569 + private func shortcutDisplay(for commandID: String) -> String? { 570 + AppShortcuts.display(for: commandID, in: resolvedKeybindings) 551 571 } 552 572 } 553 573 ··· 656 676 let isEnabled: Bool 657 677 let runHelpText: String 658 678 let stopHelpText: String 659 - let runShortcut: String 660 - let stopShortcut: String 679 + let runShortcut: String? 680 + let stopShortcut: String? 661 681 let runAction: () -> Void 662 682 let stopAction: () -> Void 663 683 @Environment(CommandKeyObserver.self) private var commandKeyObserver ··· 696 716 .accessibilityHidden(true) 697 717 Text(config.title) 698 718 699 - if commandKeyObserver.isPressed { 700 - Text(config.shortcut) 719 + if commandKeyObserver.isPressed, let shortcut = config.shortcut { 720 + Text(shortcut) 701 721 .font(.caption) 702 722 .foregroundStyle(.secondary) 703 723 } ··· 712 732 let title: String 713 733 let systemImage: String 714 734 let helpText: String 715 - let shortcut: String 735 + let shortcut: String? 716 736 let isEnabled: Bool 717 737 let action: () -> Void 718 738 } ··· 722 742 let title: String 723 743 let systemImage: String 724 744 let shortcut: String? 745 + let isEnabled: Bool 725 746 let action: () -> Void 726 747 @Environment(CommandKeyObserver.self) private var commandKeyObserver 727 748 ··· 742 763 } 743 764 .font(.caption) 744 765 .help(helpText) 766 + .disabled(!isEnabled) 745 767 } 746 768 747 769 private var helpText: String { 770 + guard isEnabled else { 771 + return "\(title) (Set command script in Repository Settings)" 772 + } 748 773 if let shortcut { 749 774 return "\(title) (\(shortcut))" 750 775 } 751 776 return title 777 + } 778 + } 779 + 780 + private struct CustomCommandOverflowButton: View { 781 + let entries: [(index: Int, command: UserCustomCommand)] 782 + let shortcutDisplay: (UserCustomCommand) -> String? 783 + let onRunCustomCommand: (Int) -> Void 784 + 785 + @State private var isPresented = false 786 + private let maxVisibleRows = 10 787 + 788 + var body: some View { 789 + Button { 790 + isPresented.toggle() 791 + } label: { 792 + Image(systemName: "chevron.down") 793 + .font(.caption2) 794 + .accessibilityLabel("More custom commands") 795 + } 796 + .help("More custom commands") 797 + .popover(isPresented: $isPresented, arrowEdge: .bottom) { 798 + ScrollView { 799 + VStack(alignment: .leading, spacing: 2) { 800 + ForEach(entries, id: \.command.id) { entry in 801 + Button { 802 + isPresented = false 803 + onRunCustomCommand(entry.index) 804 + } label: { 805 + HStack(spacing: 8) { 806 + Image(systemName: entry.command.resolvedSystemImage) 807 + .foregroundStyle(.secondary) 808 + .frame(width: 14) 809 + .accessibilityHidden(true) 810 + Text(entry.command.resolvedTitle) 811 + .lineLimit(1) 812 + Spacer(minLength: 0) 813 + if let shortcut = shortcutDisplay(entry.command) { 814 + Text(shortcut) 815 + .font(.caption.monospaced()) 816 + .foregroundStyle(.secondary) 817 + .lineLimit(1) 818 + } 819 + } 820 + .padding(.horizontal, 8) 821 + .padding(.vertical, 6) 822 + .contentShape(Rectangle()) 823 + } 824 + .buttonStyle(.plain) 825 + .disabled(!entry.command.hasRunnableCommand) 826 + } 827 + } 828 + .padding(8) 829 + } 830 + .frame(width: 320, height: popoverHeight) 831 + } 832 + } 833 + 834 + private var popoverHeight: CGFloat { 835 + let visibleRows = min(maxVisibleRows, max(entries.count, 1)) 836 + return CGFloat(visibleRows) * 32 + 16 752 837 } 753 838 } 754 839
+7 -1
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 21 21 let archiveAction: (() -> Void)? 22 22 let onDiffTap: (() -> Void)? 23 23 @Environment(\.colorScheme) private var colorScheme 24 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 24 25 25 26 var body: some View { 26 27 let showsSpinner = isLoading || taskStatus == .running ··· 90 91 ) 91 92 } 92 93 .buttonStyle(.plain) 93 - .help("Show Diff (\(AppShortcuts.showDiff.display))") 94 + .help( 95 + AppShortcuts.helpText( 96 + title: "Show Diff", 97 + commandID: AppShortcuts.CommandID.showDiff, 98 + in: resolvedKeybindings 99 + )) 94 100 } 95 101 if isHovered { 96 102 Button {
+4 -2
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 11 11 let terminalManager: WorktreeTerminalManager 12 12 @Environment(CommandKeyObserver.self) private var commandKeyObserver 13 13 @Environment(\.colorScheme) private var colorScheme 14 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 14 15 @State private var draggingWorktreeIDs: Set<Worktree.ID> = [] 15 16 @State private var hoveredWorktreeID: Worktree.ID? 16 17 ··· 123 124 DiffWindowManager.shared.show( 124 125 worktreeURL: worktree.workingDirectory, 125 126 branchName: worktree.name, 127 + resolvedKeybindings: resolvedKeybindings 126 128 ) 127 129 } 128 130 let config = WorktreeRowViewConfig( ··· 286 288 } 287 289 288 290 private func worktreeShortcutHint(for index: Int?) -> String? { 289 - guard let index, AppShortcuts.worktreeSelection.indices.contains(index) else { return nil } 290 - return AppShortcuts.worktreeSelection[index].display 291 + guard let index else { return nil } 292 + return AppShortcuts.worktreeSelectionDisplay(at: index, in: resolvedKeybindings) 291 293 } 292 294 293 295 private func togglePin(for worktreeID: Worktree.ID, isPinned: Bool) {
+29 -30
supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
··· 14 14 var branchOptions: [String] = [] 15 15 var defaultWorktreeBaseRef = "origin/main" 16 16 var isBranchDataLoaded = false 17 + var keybindingUserOverrides: KeybindingUserOverrideStore = .empty 17 18 18 19 var capabilities: Repository.Capabilities { 19 20 switch repositoryKind { ··· 63 64 RepositorySettings, 64 65 UserRepositorySettings, 65 66 isBareRepository: Bool, 66 - globalDefaultWorktreeBaseDirectoryPath: String? 67 + globalDefaultWorktreeBaseDirectoryPath: String?, 68 + keybindingUserOverrides: KeybindingUserOverrideStore 67 69 ) 68 70 case branchDataLoaded([String], defaultBaseRef: String) 69 71 case delegate(Delegate) ··· 83 85 switch action { 84 86 case .task: 85 87 let rootURL = state.rootURL 86 - @Shared(.repositorySettings(rootURL)) var repositorySettings 87 - @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 88 - @Shared(.settingsFile) var settingsFile 89 - let settings = repositorySettings 90 - let userSettings = userRepositorySettings 91 - let globalDefaultWorktreeBaseDirectoryPath = 92 - settingsFile.global.defaultWorktreeBaseDirectoryPath 93 88 guard state.capabilities.supportsRepositoryGitSettings else { 94 - return .send( 95 - .settingsLoaded( 96 - settings, 97 - userSettings, 98 - isBareRepository: false, 99 - globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath 89 + return .run { send in 90 + @Shared(.repositorySettings(rootURL)) var repositorySettings 91 + @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 92 + @Shared(.settingsFile) var settingsFile 93 + await send( 94 + .settingsLoaded( 95 + repositorySettings, 96 + userRepositorySettings, 97 + isBareRepository: false, 98 + globalDefaultWorktreeBaseDirectoryPath: settingsFile.global.defaultWorktreeBaseDirectoryPath, 99 + keybindingUserOverrides: settingsFile.global.keybindingUserOverrides 100 + ) 100 101 ) 101 - ) 102 + } 102 103 } 103 104 let gitClient = gitClient 104 105 return .run { send in 105 106 let isBareRepository = (try? await gitClient.isBareRepository(rootURL)) ?? false 107 + @Shared(.repositorySettings(rootURL)) var repositorySettings 108 + @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 109 + @Shared(.settingsFile) var settingsFile 106 110 await send( 107 111 .settingsLoaded( 108 - settings, 109 - userSettings, 112 + repositorySettings, 113 + userRepositorySettings, 110 114 isBareRepository: isBareRepository, 111 - globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath 115 + globalDefaultWorktreeBaseDirectoryPath: settingsFile.global.defaultWorktreeBaseDirectoryPath, 116 + keybindingUserOverrides: settingsFile.global.keybindingUserOverrides 112 117 ) 113 118 ) 114 119 let branches: [String] ··· 126 131 } 127 132 128 133 case .settingsLoaded( 129 - let settings, let userSettings, let isBareRepository, let globalDefaultWorktreeBaseDirectoryPath 134 + let settings, 135 + let userSettings, 136 + let isBareRepository, 137 + let globalDefaultWorktreeBaseDirectoryPath, 138 + let keybindingUserOverrides 130 139 ): 131 140 var updatedSettings = settings 132 141 updatedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( ··· 142 151 state.globalDefaultWorktreeBaseDirectoryPath = 143 152 SupacodePaths.normalizedWorktreeBaseDirectoryPath(globalDefaultWorktreeBaseDirectoryPath) 144 153 state.isBareRepository = isBareRepository 154 + state.keybindingUserOverrides = keybindingUserOverrides 145 155 guard updatedSettings != settings else { return .none } 146 156 let rootURL = state.rootURL 147 157 @Shared(.repositorySettings(rootURL)) var repositorySettings ··· 175 185 ) 176 186 @Shared(.repositorySettings(rootURL)) var repositorySettings 177 187 @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 178 - let previousUserSettings = userRepositorySettings 179 188 $repositorySettings.withLock { $0 = normalizedSettings } 180 189 $userRepositorySettings.withLock { $0 = state.userSettings } 181 - if previousUserSettings != state.userSettings { 182 - let logger = SupaLogger("Settings") 183 - for conflict in AppShortcuts.userOverrideConflicts(in: state.userSettings.customCommands) { 184 - logger.warning( 185 - "shortcut_conflict reason=userOverride app_action=\"\(conflict.appActionTitle)\" " 186 - + "app_shortcut=\(conflict.appShortcutDisplay) custom_command=\"\(conflict.commandTitle)\" " 187 - + "custom_shortcut=\(conflict.commandShortcutDisplay) result=customOverride" 188 - ) 189 - } 190 - } 191 190 return .send(.delegate(.settingsChanged(rootURL))) 192 191 193 192 case .delegate:
+39
supacode/Features/Settings/BusinessLogic/ShortcutConflictDetector.swift
··· 1 + import Foundation 2 + 3 + enum ShortcutConflictDetector { 4 + static func firstConflictCommandID( 5 + commandID: String, 6 + binding: Keybinding, 7 + policy: KeybindingConflictPolicy, 8 + schema: KeybindingSchemaDocument, 9 + userOverrides: KeybindingUserOverrideStore 10 + ) -> String? { 11 + guard shouldWarnForConflict(policy: policy) else { 12 + return nil 13 + } 14 + 15 + var tentative = userOverrides 16 + tentative.overrides[commandID] = KeybindingUserOverride(binding: binding) 17 + 18 + let resolved = KeybindingResolver.resolve( 19 + schema: schema, 20 + userOverrides: tentative 21 + ) 22 + 23 + for command in schema.commands where command.allowUserOverride && command.id != commandID { 24 + guard resolved.binding(for: command.id)?.binding == binding else { continue } 25 + return command.id 26 + } 27 + 28 + return nil 29 + } 30 + 31 + private static func shouldWarnForConflict(policy: KeybindingConflictPolicy) -> Bool { 32 + switch policy { 33 + case .warnAndPreferUserOverride, .localOnly: 34 + return true 35 + case .disallowUserOverride: 36 + return false 37 + } 38 + } 39 + }
+146
supacode/Features/Settings/BusinessLogic/ShortcutKeyTokenResolver.swift
··· 1 + import Carbon 2 + import Foundation 3 + 4 + @MainActor 5 + struct ShortcutKeyTokenResolver { 6 + struct KeyboardLayoutProvider { 7 + let baseScalarForKeyCode: @MainActor @Sendable (UInt16) -> UnicodeScalar? 8 + 9 + @MainActor 10 + static var live: KeyboardLayoutProvider { 11 + KeyboardLayoutProvider { keyCode in 12 + ShortcutKeyTokenResolver.baseScalarFromCurrentKeyboardLayout(for: keyCode) 13 + } 14 + } 15 + } 16 + 17 + let keyboardLayoutProvider: KeyboardLayoutProvider 18 + 19 + init(keyboardLayoutProvider: KeyboardLayoutProvider = .live) { 20 + self.keyboardLayoutProvider = keyboardLayoutProvider 21 + } 22 + 23 + func resolveKeyToken( 24 + keyCode: UInt16, 25 + charactersIgnoringModifiers: String? 26 + ) -> String? { 27 + if let token = specialKeyToken(for: keyCode) { 28 + return token 29 + } 30 + 31 + if let token = physicalDigitToken(for: keyCode) { 32 + return token 33 + } 34 + 35 + if let scalar = keyboardLayoutProvider.baseScalarForKeyCode(keyCode), 36 + let token = normalizedToken(from: scalar) 37 + { 38 + return token 39 + } 40 + 41 + guard let fallbackScalar = charactersIgnoringModifiers?.trimmingCharacters(in: .whitespacesAndNewlines).first else { 42 + return nil 43 + } 44 + 45 + return String(fallbackScalar).lowercased() 46 + } 47 + 48 + private func specialKeyToken(for keyCode: UInt16) -> String? { 49 + switch keyCode { 50 + case 36, 76: 51 + return "return" 52 + case 123: 53 + return "arrow_left" 54 + case 124: 55 + return "arrow_right" 56 + case 125: 57 + return "arrow_down" 58 + case 126: 59 + return "arrow_up" 60 + default: 61 + return nil 62 + } 63 + } 64 + 65 + private func physicalDigitToken(for keyCode: UInt16) -> String? { 66 + switch keyCode { 67 + case 29, 82: 68 + return "digit_0" 69 + case 18, 83: 70 + return "digit_1" 71 + case 19, 84: 72 + return "digit_2" 73 + case 20, 85: 74 + return "digit_3" 75 + case 21, 86: 76 + return "digit_4" 77 + case 23, 87: 78 + return "digit_5" 79 + case 22, 88: 80 + return "digit_6" 81 + case 26, 89: 82 + return "digit_7" 83 + case 28, 91: 84 + return "digit_8" 85 + case 25, 92: 86 + return "digit_9" 87 + default: 88 + return nil 89 + } 90 + } 91 + 92 + private func normalizedToken(from scalar: UnicodeScalar) -> String? { 93 + let raw = String(scalar).trimmingCharacters(in: .whitespacesAndNewlines) 94 + guard let token = raw.first else { return nil } 95 + return String(token).lowercased() 96 + } 97 + 98 + private static func baseScalarFromCurrentKeyboardLayout(for keyCode: UInt16) -> UnicodeScalar? { 99 + guard let layoutData = currentKeyboardLayoutData(), 100 + let bytes = CFDataGetBytePtr(layoutData) 101 + else { 102 + return nil 103 + } 104 + 105 + let keyboardLayout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self) 106 + var deadKeyState: UInt32 = 0 107 + let maxLength: Int = 4 108 + var actualLength: Int = 0 109 + var unicodeChars = [UniChar](repeating: 0, count: maxLength) 110 + 111 + let status = UCKeyTranslate( 112 + keyboardLayout, 113 + keyCode, 114 + UInt16(kUCKeyActionDisplay), 115 + 0, 116 + UInt32(LMGetKbdType()), 117 + OptionBits(kUCKeyTranslateNoDeadKeysBit), 118 + &deadKeyState, 119 + maxLength, 120 + &actualLength, 121 + &unicodeChars 122 + ) 123 + 124 + guard status == noErr, actualLength > 0 else { 125 + return nil 126 + } 127 + 128 + return UnicodeScalar(unicodeChars[0]) 129 + } 130 + 131 + private static func currentKeyboardLayoutData() -> CFData? { 132 + if let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), 133 + let rawLayoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) 134 + { 135 + return unsafeBitCast(rawLayoutData, to: CFData.self) 136 + } 137 + 138 + if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), 139 + let rawLayoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) 140 + { 141 + return unsafeBitCast(rawLayoutData, to: CFData.self) 142 + } 143 + 144 + return nil 145 + } 146 + }
+9 -2
supacode/Features/Settings/Models/GlobalSettings.swift
··· 20 20 var defaultWorktreeBaseDirectoryPath: String? 21 21 var restoreTerminalLayoutOnLaunch: Bool 22 22 var terminalFontSize: Float32? 23 + var keybindingUserOverrides: KeybindingUserOverrideStore 23 24 24 25 static let `default` = GlobalSettings( 25 26 appearanceMode: .dark, ··· 42 43 promptForWorktreeCreation: true, 43 44 defaultWorktreeBaseDirectoryPath: nil, 44 45 restoreTerminalLayoutOnLaunch: false, 45 - terminalFontSize: nil 46 + terminalFontSize: nil, 47 + keybindingUserOverrides: .empty 46 48 ) 47 49 48 50 init( ··· 66 68 promptForWorktreeCreation: Bool, 67 69 defaultWorktreeBaseDirectoryPath: String? = nil, 68 70 restoreTerminalLayoutOnLaunch: Bool = false, 69 - terminalFontSize: Float32? = nil 71 + terminalFontSize: Float32? = nil, 72 + keybindingUserOverrides: KeybindingUserOverrideStore = .empty 70 73 ) { 71 74 self.appearanceMode = appearanceMode 72 75 self.defaultEditorID = defaultEditorID ··· 89 92 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 90 93 self.restoreTerminalLayoutOnLaunch = restoreTerminalLayoutOnLaunch 91 94 self.terminalFontSize = terminalFontSize 95 + self.keybindingUserOverrides = keybindingUserOverrides 92 96 } 93 97 94 98 init(from decoder: any Decoder) throws { ··· 150 154 terminalFontSize = 151 155 try container.decodeIfPresent(Float32.self, forKey: .terminalFontSize) 152 156 ?? Self.default.terminalFontSize 157 + keybindingUserOverrides = 158 + try container.decodeIfPresent(KeybindingUserOverrideStore.self, forKey: .keybindingUserOverrides) 159 + ?? Self.default.keybindingUserOverrides 153 160 } 154 161 }
+3 -5
supacode/Features/Settings/Models/UserRepositorySettings.swift
··· 1 1 import Foundation 2 2 3 3 nonisolated struct UserRepositorySettings: Codable, Equatable, Sendable { 4 - static let maxCustomCommands = 3 5 - 6 4 var customCommands: [UserCustomCommand] 7 5 8 6 static let `default` = UserRepositorySettings(customCommands: []) ··· 26 24 } 27 25 28 26 static func normalizedCommands(_ commands: [UserCustomCommand]) -> [UserCustomCommand] { 29 - Array(commands.prefix(maxCustomCommands)).map { $0.normalized() } 27 + commands.map { $0.normalized() } 30 28 } 31 29 } 32 30 ··· 105 103 var title: String { 106 104 switch self { 107 105 case .shellScript: 108 - return "Shell Script" 106 + return "New Tab" 109 107 case .terminalInput: 110 - return "Terminal Input" 108 + return "In Place" 111 109 } 112 110 } 113 111 }
+5 -1
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 26 26 var defaultWorktreeBaseDirectoryPath: String 27 27 var restoreTerminalLayoutOnLaunch: Bool 28 28 var terminalFontSize: Float32? 29 + var keybindingUserOverrides: KeybindingUserOverrideStore 29 30 var selection: SettingsSection? = .general 30 31 var repositorySettings: RepositorySettingsFeature.State? 31 32 @Presents var alert: AlertState<Alert>? ··· 54 55 SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" 55 56 restoreTerminalLayoutOnLaunch = settings.restoreTerminalLayoutOnLaunch 56 57 terminalFontSize = settings.terminalFontSize 58 + keybindingUserOverrides = settings.keybindingUserOverrides 57 59 } 58 60 59 61 var globalSettings: GlobalSettings { ··· 80 82 defaultWorktreeBaseDirectoryPath 81 83 ), 82 84 restoreTerminalLayoutOnLaunch: restoreTerminalLayoutOnLaunch, 83 - terminalFontSize: terminalFontSize 85 + terminalFontSize: terminalFontSize, 86 + keybindingUserOverrides: keybindingUserOverrides 84 87 ) 85 88 } 86 89 } ··· 162 165 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 163 166 state.restoreTerminalLayoutOnLaunch = normalizedSettings.restoreTerminalLayoutOnLaunch 164 167 state.terminalFontSize = normalizedSettings.terminalFontSize 168 + state.keybindingUserOverrides = normalizedSettings.keybindingUserOverrides 165 169 state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 166 170 normalizedSettings.defaultWorktreeBaseDirectoryPath 167 171 return .send(.delegate(.settingsChanged(normalizedSettings)))
+1125 -178
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 1 + import AppKit 1 2 import ComposableArchitecture 2 3 import SwiftUI 3 4 ··· 6 7 @State private var isBranchPickerPresented = false 7 8 @State private var branchSearchText = "" 8 9 10 + @State private var selectedCustomCommandID: UserCustomCommand.ID? 11 + @State private var recordingCustomCommandID: UserCustomCommand.ID? 12 + @State private var recorderMonitor: Any? 13 + @State private var invalidMessageByCommandID: [UserCustomCommand.ID: String] = [:] 14 + @State private var pendingShortcutConflict: CustomCommandShortcutConflict? 15 + @State private var pendingShortcut: PendingCustomShortcut? 16 + @State private var iconPickerCommandID: UserCustomCommand.ID? 17 + @State private var customCommandsFocusAnchor: NSView? 18 + @State private var popoverRefocusTask: Task<Void, Never>? 19 + @State private var commandEditorCommandID: UserCustomCommand.ID? 20 + @State private var editingNameCommandID: UserCustomCommand.ID? 21 + @FocusState private var focusedNameEditorCommandID: UserCustomCommand.ID? 22 + 23 + private let keyTokenResolver = ShortcutKeyTokenResolver() 24 + 25 + private static let symbolPresets = [ 26 + "terminal", 27 + "terminal.fill", 28 + "play.fill", 29 + "stop.fill", 30 + "hammer.fill", 31 + "shippingbox.fill", 32 + "doc.text.fill", 33 + "sparkles", 34 + "bolt.fill", 35 + "flame.fill", 36 + "wand.and.stars", 37 + "wrench.and.screwdriver.fill", 38 + "checkmark.circle.fill", 39 + "xmark.circle.fill", 40 + "exclamationmark.triangle.fill", 41 + "ladybug.fill", 42 + "clock.fill", 43 + "repeat", 44 + "arrow.clockwise", 45 + "folder.fill", 46 + "archivebox.fill", 47 + "paperplane.fill", 48 + "cloud.fill", 49 + "tray.and.arrow.down.fill", 50 + "tray.and.arrow.up.fill", 51 + "icloud.and.arrow.up.fill", 52 + "square.and.arrow.up.fill", 53 + "arrow.triangle.2.circlepath", 54 + "folder.badge.plus", 55 + "doc.badge.plus", 56 + ] 57 + 9 58 var body: some View { 10 59 let baseRefOptions = 11 60 store.branchOptions.isEmpty ? [store.defaultWorktreeBaseRef] : store.branchOptions 12 61 let settings = $store.settings 13 - let userSettings = $store.userSettings 14 62 let worktreeBaseDirectoryPath = Binding( 15 63 get: { settings.worktreeBaseDirectoryPath.wrappedValue ?? "" }, 16 64 set: { settings.worktreeBaseDirectoryPath.wrappedValue = $0 }, 17 65 ) 18 66 let exampleWorktreePath = store.exampleWorktreePath 67 + 19 68 Form { 20 69 if store.showsWorktreeSettings { 21 70 Section { ··· 59 108 .foregroundStyle(.secondary) 60 109 } 61 110 } 111 + 62 112 Section { 63 113 VStack(alignment: .leading) { 64 114 TextField( ··· 66 116 text: worktreeBaseDirectoryPath 67 117 ) 68 118 .textFieldStyle(.roundedBorder) 119 + 69 120 Text("Set a repository-specific worktree base directory. Leave empty to inherit the global setting.") 70 121 .foregroundStyle(.secondary) 71 122 Text("Example new worktree path: \(exampleWorktreePath)") ··· 73 124 .monospaced() 74 125 } 75 126 .frame(maxWidth: .infinity, alignment: .leading) 127 + 76 128 Toggle( 77 129 "Copy ignored files to new worktrees", 78 130 isOn: settings.copyIgnoredOnWorktreeCreate 79 131 ) 80 132 .disabled(store.isBareRepository) 133 + 81 134 Toggle( 82 135 "Copy untracked files to new worktrees", 83 136 isOn: settings.copyUntrackedOnWorktreeCreate 84 137 ) 85 138 .disabled(store.isBareRepository) 139 + 86 140 if store.isBareRepository { 87 141 Text("Copy flags are ignored for bare repositories.") 88 142 .foregroundStyle(.secondary) ··· 95 149 } 96 150 } 97 151 } 152 + 98 153 if store.showsPullRequestSettings { 99 154 Section { 100 155 Picker( ··· 115 170 } 116 171 } 117 172 } 173 + 118 174 if store.showsSetupScriptSettings { 119 175 Section { 120 - ZStack(alignment: .topLeading) { 121 - PlainTextEditor( 122 - text: settings.setupScript 123 - ) 124 - .frame(minHeight: 120) 125 - if store.settings.setupScript.isEmpty { 126 - Text("claude --dangerously-skip-permissions") 127 - .foregroundStyle(.secondary) 128 - .padding(.leading, 6) 129 - .font(.body) 130 - .allowsHitTesting(false) 131 - } 132 - } 176 + PlainTextEditor( 177 + text: settings.setupScript, 178 + placeholder: "claude --dangerously-skip-permissions" 179 + ) 180 + .frame(minHeight: 120) 133 181 } header: { 134 182 VStack(alignment: .leading, spacing: 4) { 135 183 Text("Setup Script") ··· 138 186 } 139 187 } 140 188 } 189 + 141 190 if store.showsArchiveScriptSettings { 142 191 Section { 143 - ZStack(alignment: .topLeading) { 144 - PlainTextEditor( 145 - text: settings.archiveScript 146 - ) 147 - .frame(minHeight: 120) 148 - if store.settings.archiveScript.isEmpty { 149 - Text("docker compose down") 150 - .foregroundStyle(.secondary) 151 - .padding(.leading, 6) 152 - .font(.body) 153 - .allowsHitTesting(false) 154 - } 155 - } 192 + PlainTextEditor( 193 + text: settings.archiveScript, 194 + placeholder: "docker compose down" 195 + ) 196 + .frame(minHeight: 120) 156 197 } header: { 157 198 VStack(alignment: .leading, spacing: 4) { 158 199 Text("Archive Script") ··· 161 202 } 162 203 } 163 204 } 205 + 164 206 if store.showsRunScriptSettings { 165 207 Section { 166 - ZStack(alignment: .topLeading) { 167 - PlainTextEditor( 168 - text: settings.runScript 169 - ) 170 - .frame(minHeight: 120) 171 - if store.settings.runScript.isEmpty { 172 - Text("npm run dev") 173 - .foregroundStyle(.secondary) 174 - .padding(.leading, 6) 175 - .font(.body) 176 - .allowsHitTesting(false) 177 - } 178 - } 208 + PlainTextEditor( 209 + text: settings.runScript, 210 + placeholder: "npm run dev" 211 + ) 212 + .frame(minHeight: 120) 179 213 } header: { 180 214 VStack(alignment: .leading, spacing: 4) { 181 215 Text("Run Script") ··· 184 218 } 185 219 } 186 220 } 221 + 187 222 if store.showsCustomCommandsSettings { 188 223 Section { 189 - ForEach(userSettings.customCommands) { $command in 190 - UserCustomCommandCard( 191 - command: $command, 192 - onRemove: { 193 - removeCustomCommand(id: command.id) 194 - } 195 - ) 196 - } 197 - if store.userSettings.customCommands.count < UserRepositorySettings.maxCustomCommands { 198 - Button { 199 - addCustomCommand() 200 - } label: { 201 - Label("Add Command", systemImage: "plus") 202 - } 203 - .help("Add a custom command") 204 - } 224 + customCommandsEditor 205 225 } header: { 206 226 VStack(alignment: .leading, spacing: 4) { 207 227 Text("Custom Commands") 208 - Text("Custom commands shown after Run in the toolbar (up to 3)") 228 + Text("Repository-local terminal actions. Custom command shortcuts take precedence in this repository.") 209 229 .foregroundStyle(.secondary) 210 230 } 211 231 } ··· 215 235 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 216 236 .task { 217 237 store.send(.task) 238 + syncSelectedCommandID(with: store.userSettings.customCommands) 239 + } 240 + .onChange(of: store.userSettings.customCommands) { _, commands in 241 + syncSelectedCommandID(with: commands) 242 + clearRemovedCommandState(using: commands) 243 + } 244 + .onChange(of: selectedCustomCommandID) { _, selectedID in 245 + if editingNameCommandID != selectedID { 246 + editingNameCommandID = nil 247 + } 248 + focusedNameEditorCommandID = nil 249 + if let iconPickerCommandID, iconPickerCommandID != selectedID { 250 + self.iconPickerCommandID = nil 251 + } 252 + if let commandEditorCommandID, commandEditorCommandID != selectedID { 253 + self.commandEditorCommandID = nil 254 + } 255 + if let recordingCustomCommandID, recordingCustomCommandID != selectedID { 256 + self.recordingCustomCommandID = nil 257 + } 258 + } 259 + .onChange(of: recordingCustomCommandID) { _, commandID in 260 + if commandID == nil { 261 + stopRecorderMonitor() 262 + } else { 263 + startRecorderMonitor() 264 + } 265 + } 266 + .onDisappear { 267 + stopRecorderMonitor() 268 + popoverRefocusTask?.cancel() 269 + popoverRefocusTask = nil 270 + focusedNameEditorCommandID = nil 271 + } 272 + .alert( 273 + "Shortcut Conflict", 274 + isPresented: isShortcutConflictAlertPresented, 275 + presenting: pendingShortcutConflict 276 + ) { _ in 277 + Button("Replace", role: .destructive) { 278 + applyPendingShortcut(replacingConflict: true) 279 + } 280 + Button("Cancel", role: .cancel) { 281 + clearPendingShortcutConflict() 282 + } 283 + } message: { conflict in 284 + Text( 285 + "“\(conflict.newCommandTitle)” and “\(conflict.existingCommandTitle)” both use \(conflict.shortcutDisplay)." 286 + + "\n\nChoose Replace to keep the new shortcut and clear the conflicting command." 287 + ) 288 + } 289 + } 290 + 291 + @ViewBuilder 292 + private var customCommandsEditor: some View { 293 + VStack(alignment: .leading, spacing: 10) { 294 + VStack(spacing: 0) { 295 + customCommandsHeaderRow 296 + Divider() 297 + ScrollView { 298 + LazyVStack(spacing: 4) { 299 + ForEach(store.userSettings.customCommands) { command in 300 + customCommandRow(command) 301 + .id(command.id) 302 + } 303 + } 304 + .padding(.horizontal, 6) 305 + .padding(.vertical, 6) 306 + } 307 + .frame(height: customCommandsListHeight) 308 + } 309 + .clipShape(RoundedRectangle(cornerRadius: 8)) 310 + 311 + HStack(spacing: 8) { 312 + Button { 313 + addCustomCommand() 314 + } label: { 315 + ZStack { 316 + Image(systemName: "plus") 317 + .frame(width: 16, height: 16) 318 + } 319 + .frame(width: 28, height: 28) 320 + .contentShape(Rectangle()) 321 + .accessibilityLabel("Add command") 322 + } 323 + .buttonStyle(.plain) 324 + .help("Add command") 325 + 326 + Button { 327 + removeSelectedCustomCommand() 328 + } label: { 329 + ZStack { 330 + Image(systemName: "minus") 331 + .frame(width: 16, height: 16) 332 + } 333 + .frame(width: 28, height: 28) 334 + .contentShape(Rectangle()) 335 + .accessibilityLabel("Remove selected command") 336 + } 337 + .buttonStyle(.plain) 338 + .disabled(store.userSettings.customCommands.isEmpty) 339 + .help("Remove selected command") 340 + 341 + Spacer(minLength: 0) 342 + 343 + Text("\(store.userSettings.customCommands.count) commands") 344 + .font(.caption) 345 + .foregroundStyle(.secondary) 346 + } 347 + if let invalidMessage = selectedCommandInvalidMessage { 348 + Text(invalidMessage) 349 + .font(.caption) 350 + .foregroundStyle(.red) 351 + } else { 352 + Text("Click cells to edit icon, name, command, and shortcut inline.") 353 + .font(.caption) 354 + .foregroundStyle(.secondary) 355 + } 356 + } 357 + .background { 358 + FirstResponderAnchorView { anchor in 359 + if customCommandsFocusAnchor !== anchor { 360 + customCommandsFocusAnchor = anchor 361 + } 362 + } 363 + .frame(width: 0, height: 0) 364 + } 365 + } 366 + 367 + @ViewBuilder 368 + private func customCommandIconCell(_ command: UserCustomCommand) -> some View { 369 + if let binding = bindingForCustomCommand(id: command.id) { 370 + InlineEditableCellButton( 371 + isActive: iconPickerCommandID == command.id, 372 + contentAlignment: .center 373 + ) { 374 + selectCustomCommand(command.id) 375 + toggleIconEditor(for: command.id) 376 + } label: { 377 + Image(systemName: binding.wrappedValue.resolvedSystemImage) 378 + .foregroundStyle(.secondary) 379 + .frame(width: 16, alignment: .center) 380 + .accessibilityHidden(true) 381 + } 382 + .popover( 383 + isPresented: Binding( 384 + get: { iconPickerCommandID == command.id }, 385 + set: { isPresented in 386 + if !isPresented { 387 + closePopoverAndRestoreCommandFocus(for: command.id) 388 + } 389 + } 390 + ), 391 + arrowEdge: .bottom 392 + ) { 393 + iconEditorPopover(for: binding, commandID: command.id) 394 + } 395 + } else { 396 + InlineEditableCellButton( 397 + contentAlignment: .center 398 + ) { 399 + selectCustomCommand(command.id) 400 + } label: { 401 + Image(systemName: command.resolvedSystemImage) 402 + .foregroundStyle(.secondary) 403 + .frame(width: 16, alignment: .center) 404 + .accessibilityHidden(true) 405 + } 406 + } 407 + } 408 + 409 + @ViewBuilder 410 + private func customCommandNameCell(_ command: UserCustomCommand) -> some View { 411 + let isSelected = selectedCustomCommandID == command.id 412 + if isSelected, 413 + editingNameCommandID == command.id, 414 + let binding = bindingForCustomCommand(id: command.id) 415 + { 416 + InlineEditableFieldContainer(isActive: true) { 417 + TextField("", text: binding.title) 418 + .textFieldStyle(.plain) 419 + .padding(.leading, -4) 420 + .focused($focusedNameEditorCommandID, equals: command.id) 421 + .onSubmit { 422 + endNameEditing() 423 + } 424 + } 425 + .onAppear { 426 + focusedNameEditorCommandID = command.id 427 + } 428 + } else { 429 + InlineEditableCellButton { 430 + selectCustomCommand(command.id) 431 + beginNameEditing(for: command.id) 432 + } label: { 433 + Text(bindingForCustomCommand(id: command.id)?.wrappedValue.resolvedTitle ?? command.resolvedTitle) 434 + .lineLimit(1) 435 + } 436 + } 437 + } 438 + 439 + @ViewBuilder 440 + private func customCommandCell(_ command: UserCustomCommand) -> some View { 441 + if let binding = bindingForCustomCommand(id: command.id) { 442 + InlineEditableCellButton( 443 + isActive: commandEditorCommandID == command.id 444 + ) { 445 + selectCustomCommand(command.id) 446 + toggleCommandEditor(for: command.id) 447 + } label: { 448 + VStack(alignment: .leading, spacing: 2) { 449 + Text(inlineCommandTitle(for: binding.wrappedValue.execution)) 450 + .font(.caption) 451 + .foregroundStyle(.secondary) 452 + Text(inlineCommandScriptPreview(for: binding.wrappedValue.command)) 453 + .lineLimit(1) 454 + } 455 + } 456 + .popover( 457 + isPresented: Binding( 458 + get: { commandEditorCommandID == command.id }, 459 + set: { isPresented in 460 + if !isPresented { 461 + closePopoverAndRestoreCommandFocus(for: command.id) 462 + } 463 + } 464 + ), 465 + arrowEdge: .bottom 466 + ) { 467 + commandEditorPopover(for: binding) 468 + } 469 + .help("New Tab runs in a new tab. In Place sends input to the focused terminal.") 470 + } else { 471 + InlineEditableCellButton { 472 + selectCustomCommand(command.id) 473 + } label: { 474 + VStack(alignment: .leading, spacing: 2) { 475 + Text(inlineCommandTitle(for: command.execution)) 476 + .font(.caption) 477 + .foregroundStyle(.secondary) 478 + Text(inlineCommandScriptPreview(for: command.command)) 479 + .lineLimit(1) 480 + } 481 + } 482 + } 483 + } 484 + 485 + @ViewBuilder 486 + private func customCommandShortcutCell(_ command: UserCustomCommand) -> some View { 487 + let resolvedBinding = resolvedCustomCommandBindings.keybinding(for: customCommandBindingID(for: command.id)) 488 + let shortcutDisplay = resolvedBinding?.display ?? "Unassigned" 489 + let isRecording = recordingCustomCommandID == command.id 490 + 491 + InlineEditableCellButton( 492 + isActive: isRecording, 493 + activeColor: .orange 494 + ) { 495 + selectCustomCommand(command.id) 496 + toggleRecording(for: command.id) 497 + } label: { 498 + Text(isRecording ? "Recording…" : shortcutDisplay) 499 + .font(.body.monospaced()) 500 + .foregroundStyle(isRecording ? Color.orange : (resolvedBinding == nil ? .secondary : .primary)) 501 + .lineLimit(1) 502 + } 503 + .contextMenu { 504 + if command.shortcut != nil { 505 + Button("Clear Shortcut") { 506 + clearShortcut(for: command.id) 507 + } 508 + } 509 + } 510 + .help(isRecording ? "Recording shortcut. Press Esc to cancel." : "Click to record a shortcut.") 511 + } 512 + 513 + private var effectiveSelectedCommandID: UserCustomCommand.ID? { 514 + selectedCustomCommandID ?? editingNameCommandID ?? commandEditorCommandID ?? iconPickerCommandID 515 + ?? recordingCustomCommandID 516 + } 517 + 518 + private var removableCommandID: UserCustomCommand.ID? { 519 + let commands = store.userSettings.customCommands 520 + if let selectedCustomCommandID, 521 + commands.contains(where: { $0.id == selectedCustomCommandID }) 522 + { 523 + return selectedCustomCommandID 524 + } 525 + if let effectiveSelectedCommandID, 526 + commands.contains(where: { $0.id == effectiveSelectedCommandID }) 527 + { 528 + return effectiveSelectedCommandID 529 + } 530 + return commands.last?.id 531 + } 532 + 533 + private var customCommandsHeaderRow: some View { 534 + HStack(spacing: 8) { 535 + customCommandHeaderCell("", width: customCommandsIconColumnWidth, alignment: .center) 536 + customCommandHeaderCell("Name", width: customCommandsNameColumnWidth) 537 + customCommandHeaderCell("Command") 538 + customCommandHeaderCell("Shortcut", width: customCommandsShortcutColumnWidth) 539 + } 540 + .padding(.horizontal, 14) 541 + .padding(.vertical, 8) 542 + .font(.headline) 543 + .foregroundStyle(.secondary) 544 + } 545 + 546 + @ViewBuilder 547 + private func customCommandRow(_ command: UserCustomCommand) -> some View { 548 + let isSelected = selectedCustomCommandID == command.id 549 + HStack(spacing: 8) { 550 + customCommandRowCell(width: customCommandsIconColumnWidth, alignment: .center) { 551 + customCommandIconCell(command) 552 + } 553 + customCommandRowCell(width: customCommandsNameColumnWidth) { 554 + customCommandNameCell(command) 555 + } 556 + customCommandRowCell { 557 + customCommandCell(command) 558 + } 559 + customCommandRowCell(width: customCommandsShortcutColumnWidth) { 560 + customCommandShortcutCell(command) 561 + } 562 + } 563 + .padding(.horizontal, 8) 564 + .padding(.vertical, 2) 565 + .background { 566 + RoundedRectangle(cornerRadius: 8) 567 + .fill(isSelected ? Color.accentColor.opacity(0.35) : .clear) 568 + } 569 + .contentShape(RoundedRectangle(cornerRadius: 8)) 570 + .accessibilityAddTraits(.isButton) 571 + .onTapGesture { 572 + selectCustomCommand(command.id) 573 + } 574 + } 575 + 576 + @ViewBuilder 577 + private func customCommandHeaderCell( 578 + _ title: String, 579 + width: CGFloat? = nil, 580 + alignment: Alignment = .leading 581 + ) -> some View { 582 + if let width { 583 + Text(title) 584 + .frame(width: width, alignment: alignment) 585 + } else { 586 + Text(title) 587 + .frame(maxWidth: .infinity, alignment: alignment) 588 + } 589 + } 590 + 591 + @ViewBuilder 592 + private func customCommandRowCell<Content: View>( 593 + width: CGFloat? = nil, 594 + alignment: Alignment = .leading, 595 + @ViewBuilder content: () -> Content 596 + ) -> some View { 597 + if let width { 598 + content() 599 + .frame(width: width, alignment: alignment) 600 + .frame(maxHeight: .infinity, alignment: alignment) 601 + } else { 602 + content() 603 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) 604 + } 605 + } 606 + 607 + private func selectCustomCommand(_ commandID: UserCustomCommand.ID) { 608 + if selectedCustomCommandID != commandID { 609 + selectedCustomCommandID = commandID 610 + } 611 + } 612 + 613 + private func inlineCommandTitle(for execution: UserCustomCommandExecution) -> String { 614 + switch execution { 615 + case .shellScript: 616 + return "New Tab" 617 + case .terminalInput: 618 + return "In Place" 619 + } 620 + } 621 + 622 + private func inlineCommandScriptPreview(for script: String) -> String { 623 + let firstLine = 624 + script 625 + .split(separator: "\n", omittingEmptySubsequences: false) 626 + .first 627 + .map(String.init)? 628 + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 629 + return firstLine.isEmpty ? "Click to set command script" : firstLine 630 + } 631 + 632 + private func iconEditorPopover( 633 + for command: Binding<UserCustomCommand>, 634 + commandID: UserCustomCommand.ID 635 + ) -> some View { 636 + VStack(alignment: .leading, spacing: 10) { 637 + Text("Icon") 638 + .font(.headline) 639 + Text("Pick from common symbols or enter any SF Symbol name available in your system.") 640 + .font(.caption) 641 + .foregroundStyle(.secondary) 642 + 643 + HStack(spacing: 8) { 644 + TextField("SF Symbol name", text: command.systemImage) 645 + .textFieldStyle(.roundedBorder) 646 + Button("Open SF Symbols") { 647 + openSFSymbolsReference() 648 + } 649 + } 650 + 651 + ScrollView { 652 + LazyVGrid( 653 + columns: Array(repeating: GridItem(.fixed(24), spacing: 8), count: 10), 654 + spacing: 8 655 + ) { 656 + ForEach(Self.symbolPresets, id: \.self) { symbol in 657 + Button { 658 + command.wrappedValue.systemImage = symbol 659 + closePopoverAndRestoreCommandFocus(for: commandID) 660 + } label: { 661 + Image(systemName: symbol) 662 + .frame(width: 24, height: 24) 663 + .accessibilityHidden(true) 664 + } 665 + .buttonStyle(.plain) 666 + .help(symbol) 667 + } 668 + } 669 + .padding(12) 670 + } 671 + .frame(maxHeight: 124) 672 + } 673 + .padding(12) 674 + .frame(width: 360) 675 + } 676 + 677 + private func commandEditorPopover(for command: Binding<UserCustomCommand>) -> some View { 678 + VStack(alignment: .leading, spacing: 10) { 679 + Text("Command") 680 + .font(.headline) 681 + Text("Choose where this command runs and edit the script used by this repository custom command.") 682 + .font(.caption) 683 + .foregroundStyle(.secondary) 684 + 685 + Picker("Execution", selection: command.execution) { 686 + Text("New Tab") 687 + .tag(UserCustomCommandExecution.shellScript) 688 + Text("In Place") 689 + .tag(UserCustomCommandExecution.terminalInput) 690 + } 691 + .pickerStyle(.segmented) 692 + 693 + PlainTextEditor( 694 + text: command.command, 695 + isMonospaced: true, 696 + shouldFocus: true, 697 + placeholder: scriptPlaceholder(for: command.wrappedValue.execution) 698 + ) 699 + .frame(height: 140) 700 + 701 + Text(scriptDescription(for: command.wrappedValue.execution)) 702 + .font(.caption) 703 + .foregroundStyle(.secondary) 704 + } 705 + .padding(12) 706 + .frame(width: 420) 707 + } 708 + 709 + private var selectedCommandInvalidMessage: String? { 710 + guard let selectedCustomCommandID else { 711 + return nil 712 + } 713 + return invalidMessageByCommandID[selectedCustomCommandID] 714 + } 715 + 716 + private func openSFSymbolsReference() { 717 + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.SFSymbols") { 718 + let configuration = NSWorkspace.OpenConfiguration() 719 + NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { _, _ in } 720 + return 721 + } 722 + guard let url = URL(string: "https://developer.apple.com/sf-symbols/") else { 723 + return 724 + } 725 + NSWorkspace.shared.open(url) 726 + } 727 + 728 + private func toggleIconEditor(for commandID: UserCustomCommand.ID) { 729 + if iconPickerCommandID == commandID { 730 + closePopoverAndRestoreCommandFocus(for: commandID) 731 + return 732 + } 733 + iconPickerCommandID = commandID 734 + commandEditorCommandID = nil 735 + endNameEditing() 736 + if recordingCustomCommandID == commandID { 737 + recordingCustomCommandID = nil 738 + } 739 + } 740 + 741 + private func toggleCommandEditor(for commandID: UserCustomCommand.ID) { 742 + if commandEditorCommandID == commandID { 743 + closePopoverAndRestoreCommandFocus(for: commandID) 744 + return 745 + } 746 + commandEditorCommandID = commandID 747 + iconPickerCommandID = nil 748 + endNameEditing() 749 + if recordingCustomCommandID == commandID { 750 + recordingCustomCommandID = nil 751 + } 752 + } 753 + 754 + private func beginNameEditing(for commandID: UserCustomCommand.ID) { 755 + editingNameCommandID = commandID 756 + iconPickerCommandID = nil 757 + commandEditorCommandID = nil 758 + if recordingCustomCommandID == commandID { 759 + recordingCustomCommandID = nil 760 + } 761 + focusedNameEditorCommandID = commandID 762 + } 763 + 764 + private func endNameEditing() { 765 + editingNameCommandID = nil 766 + focusedNameEditorCommandID = nil 767 + } 768 + 769 + private func closePopoverAndRestoreCommandFocus(for commandID: UserCustomCommand.ID) { 770 + popoverRefocusTask?.cancel() 771 + 772 + var transaction = Transaction() 773 + transaction.animation = nil 774 + withTransaction(transaction) { 775 + iconPickerCommandID = nil 776 + commandEditorCommandID = nil 777 + } 778 + focusCustomCommandsArea() 779 + scheduleCommandFocusRestore(for: commandID) 780 + } 781 + 782 + private func focusCustomCommandsArea() { 783 + guard let window = NSApp.keyWindow else { 784 + return 785 + } 786 + if let customCommandsFocusAnchor, 787 + customCommandsFocusAnchor.window === window 788 + { 789 + _ = window.makeFirstResponder(customCommandsFocusAnchor) 790 + return 791 + } 792 + _ = window.makeFirstResponder(nil) 793 + } 794 + 795 + private func scheduleCommandFocusRestore(for commandID: UserCustomCommand.ID) { 796 + popoverRefocusTask = Task { @MainActor in 797 + await Task.yield() 798 + guard !Task.isCancelled else { 799 + return 800 + } 801 + guard iconPickerCommandID == nil, commandEditorCommandID == nil else { 802 + return 803 + } 804 + guard store.userSettings.customCommands.contains(where: { $0.id == commandID }) else { 805 + return 806 + } 807 + 808 + var transaction = Transaction() 809 + transaction.animation = nil 810 + withTransaction(transaction) { 811 + selectCustomCommand(commandID) 812 + endNameEditing() 813 + } 814 + } 815 + } 816 + 817 + private func scriptPlaceholder(for execution: UserCustomCommandExecution) -> String { 818 + switch execution { 819 + case .shellScript: 820 + return "npm test && swift test" 821 + case .terminalInput: 822 + return "pnpm test --watch" 823 + } 824 + } 825 + 826 + private func scriptDescription(for execution: UserCustomCommandExecution) -> String { 827 + switch execution { 828 + case .shellScript: 829 + return "Runs in a new terminal tab." 830 + case .terminalInput: 831 + return "Sends input to the currently focused terminal." 832 + } 833 + } 834 + 835 + private var resolvedCustomCommandBindings: ResolvedKeybindingMap { 836 + let commands = store.userSettings.customCommands 837 + let migration = LegacyCustomCommandShortcutMigration.migrate(commands: commands) 838 + return KeybindingResolver.resolve( 839 + schema: .appResolverSchema(customCommands: commands), 840 + userOverrides: store.keybindingUserOverrides, 841 + migratedOverrides: migration.overrides 842 + ) 843 + } 844 + 845 + private func customCommandBindingID(for commandID: String) -> String { 846 + LegacyCustomCommandShortcutMigration.customCommandBindingID(for: commandID) 847 + } 848 + 849 + private func bindingForCustomCommand(id commandID: UserCustomCommand.ID) -> Binding<UserCustomCommand>? { 850 + guard store.userSettings.customCommands.contains(where: { $0.id == commandID }) else { 851 + return nil 852 + } 853 + 854 + return Binding( 855 + get: { 856 + store.userSettings.customCommands.first(where: { $0.id == commandID }) 857 + ?? UserCustomCommand( 858 + id: commandID, 859 + title: "", 860 + systemImage: "terminal", 861 + command: "", 862 + execution: .shellScript, 863 + shortcut: nil 864 + ) 865 + }, 866 + set: { updatedCommand in 867 + updateCustomCommand(id: commandID) { command in 868 + command.title = updatedCommand.title 869 + command.systemImage = updatedCommand.systemImage 870 + command.command = updatedCommand.command 871 + command.execution = updatedCommand.execution 872 + command.shortcut = updatedCommand.shortcut 873 + } 874 + } 875 + ) 876 + } 877 + 878 + private func syncSelectedCommandID(with commands: [UserCustomCommand]) { 879 + guard !commands.isEmpty else { 880 + selectedCustomCommandID = nil 881 + recordingCustomCommandID = nil 882 + iconPickerCommandID = nil 883 + commandEditorCommandID = nil 884 + editingNameCommandID = nil 885 + focusedNameEditorCommandID = nil 886 + return 887 + } 888 + 889 + if let selectedCustomCommandID, 890 + commands.contains(where: { $0.id == selectedCustomCommandID }) 891 + { 892 + return 893 + } 894 + 895 + selectedCustomCommandID = commands[0].id 896 + } 897 + 898 + private func clearRemovedCommandState(using commands: [UserCustomCommand]) { 899 + let validIDs = Set(commands.map(\.id)) 900 + 901 + invalidMessageByCommandID = invalidMessageByCommandID.filter { validIDs.contains($0.key) } 902 + 903 + if let recordingCustomCommandID, 904 + !validIDs.contains(recordingCustomCommandID) 905 + { 906 + self.recordingCustomCommandID = nil 907 + } 908 + 909 + if let iconPickerCommandID, 910 + !validIDs.contains(iconPickerCommandID) 911 + { 912 + self.iconPickerCommandID = nil 913 + } 914 + 915 + if let commandEditorCommandID, 916 + !validIDs.contains(commandEditorCommandID) 917 + { 918 + self.commandEditorCommandID = nil 919 + } 920 + 921 + if let editingNameCommandID, 922 + !validIDs.contains(editingNameCommandID) 923 + { 924 + self.editingNameCommandID = nil 925 + focusedNameEditorCommandID = nil 218 926 } 219 927 } 220 928 221 929 private func addCustomCommand() { 222 - let current = store.userSettings.customCommands 223 - let next = current + [.default(index: current.count)] 224 - store.userSettings.customCommands = UserRepositorySettings.normalizedCommands(next) 930 + let commandsBinding = $store.userSettings.customCommands 931 + let current = commandsBinding.wrappedValue 932 + let next = UserRepositorySettings.normalizedCommands(current + [.default(index: current.count)]) 933 + commandsBinding.wrappedValue = next 934 + guard let commandID = next.last?.id else { 935 + selectedCustomCommandID = nil 936 + editingNameCommandID = nil 937 + focusedNameEditorCommandID = nil 938 + return 939 + } 940 + selectedCustomCommandID = commandID 941 + editingNameCommandID = commandID 942 + focusedNameEditorCommandID = commandID 943 + iconPickerCommandID = nil 944 + commandEditorCommandID = nil 945 + recordingCustomCommandID = nil 946 + } 947 + 948 + private func removeSelectedCustomCommand() { 949 + guard let selectedCommandID = removableCommandID else { 950 + return 951 + } 952 + 953 + let commandsBinding = $store.userSettings.customCommands 954 + var commands = commandsBinding.wrappedValue 955 + let removalIndex: Int? 956 + if let index = commands.firstIndex(where: { $0.id == selectedCommandID }) { 957 + removalIndex = index 958 + commands.remove(at: index) 959 + } else if !commands.isEmpty { 960 + removalIndex = commands.count - 1 961 + commands.removeLast() 962 + } else { 963 + removalIndex = nil 964 + } 965 + 966 + guard let removalIndex else { 967 + return 968 + } 969 + 970 + let normalizedCommands = UserRepositorySettings.normalizedCommands(commands) 971 + commandsBinding.wrappedValue = normalizedCommands 972 + 973 + if normalizedCommands.isEmpty { 974 + selectedCustomCommandID = nil 975 + } else if removalIndex < normalizedCommands.count { 976 + selectedCustomCommandID = normalizedCommands[removalIndex].id 977 + } else { 978 + selectedCustomCommandID = normalizedCommands[normalizedCommands.count - 1].id 979 + } 980 + clearRemovedCommandState(using: normalizedCommands) 981 + } 982 + 983 + private func clearShortcut(for commandID: UserCustomCommand.ID) { 984 + invalidMessageByCommandID[commandID] = nil 985 + updateCustomCommand(id: commandID) { command in 986 + command.shortcut = nil 987 + } 988 + if recordingCustomCommandID == commandID { 989 + recordingCustomCommandID = nil 990 + } 991 + } 992 + 993 + private func updateCustomCommand( 994 + id: UserCustomCommand.ID, 995 + update: (inout UserCustomCommand) -> Void 996 + ) { 997 + let commandsBinding = $store.userSettings.customCommands 998 + var commands = commandsBinding.wrappedValue 999 + guard let index = commands.firstIndex(where: { $0.id == id }) else { 1000 + return 1001 + } 1002 + 1003 + update(&commands[index]) 1004 + commandsBinding.wrappedValue = UserRepositorySettings.normalizedCommands(commands) 1005 + } 1006 + 1007 + private func toggleRecording(for commandID: UserCustomCommand.ID) { 1008 + invalidMessageByCommandID[commandID] = nil 1009 + iconPickerCommandID = nil 1010 + commandEditorCommandID = nil 1011 + endNameEditing() 1012 + 1013 + if recordingCustomCommandID == commandID { 1014 + recordingCustomCommandID = nil 1015 + return 1016 + } 1017 + 1018 + recordingCustomCommandID = commandID 1019 + } 1020 + 1021 + private func startRecorderMonitor() { 1022 + stopRecorderMonitor() 1023 + recorderMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in 1024 + guard let commandID = recordingCustomCommandID else { 1025 + return event 1026 + } 1027 + handleRecorderEvent(event, commandID: commandID) 1028 + return nil 1029 + } 1030 + } 1031 + 1032 + private func stopRecorderMonitor() { 1033 + if let recorderMonitor { 1034 + NSEvent.removeMonitor(recorderMonitor) 1035 + self.recorderMonitor = nil 1036 + } 1037 + } 1038 + 1039 + private func handleRecorderEvent(_ event: NSEvent, commandID: UserCustomCommand.ID) { 1040 + if event.keyCode == 53 { // Escape 1041 + recordingCustomCommandID = nil 1042 + return 1043 + } 1044 + 1045 + guard 1046 + let keyToken = keyTokenResolver.resolveKeyToken( 1047 + keyCode: event.keyCode, 1048 + charactersIgnoringModifiers: event.charactersIgnoringModifiers 1049 + ) 1050 + else { 1051 + invalidMessageByCommandID[commandID] = "Unsupported key. Use letters, numbers, or punctuation." 1052 + return 1053 + } 1054 + 1055 + let modifiers = KeybindingModifiers( 1056 + command: event.modifierFlags.contains(.command), 1057 + shift: event.modifierFlags.contains(.shift), 1058 + option: event.modifierFlags.contains(.option), 1059 + control: event.modifierFlags.contains(.control) 1060 + ) 1061 + 1062 + guard !modifiers.isEmpty else { 1063 + invalidMessageByCommandID[commandID] = "Shortcut must include at least one modifier key." 1064 + return 1065 + } 1066 + 1067 + let binding = Keybinding(key: keyToken, modifiers: modifiers) 1068 + guard let shortcut = binding.userCustomShortcut else { 1069 + invalidMessageByCommandID[commandID] = 1070 + "Custom command shortcuts support letters, numbers, and punctuation only." 1071 + return 1072 + } 1073 + 1074 + applyRecordedShortcut(shortcut.normalized(), to: commandID) 1075 + } 1076 + 1077 + private func applyRecordedShortcut( 1078 + _ shortcut: UserCustomShortcut, 1079 + to commandID: UserCustomCommand.ID 1080 + ) { 1081 + invalidMessageByCommandID[commandID] = nil 1082 + 1083 + guard let existingCommand = firstConflictingCommand(for: commandID, shortcut: shortcut) else { 1084 + updateCustomCommand(id: commandID) { command in 1085 + command.shortcut = shortcut 1086 + } 1087 + recordingCustomCommandID = nil 1088 + return 1089 + } 1090 + 1091 + let newTitle = 1092 + store.userSettings.customCommands.first(where: { $0.id == commandID })?.resolvedTitle ?? "Command" 1093 + 1094 + pendingShortcutConflict = CustomCommandShortcutConflict( 1095 + newCommandID: commandID, 1096 + newCommandTitle: newTitle, 1097 + existingCommandID: existingCommand.id, 1098 + existingCommandTitle: existingCommand.resolvedTitle, 1099 + shortcutDisplay: shortcut.display 1100 + ) 1101 + pendingShortcut = PendingCustomShortcut(commandID: commandID, shortcut: shortcut) 1102 + recordingCustomCommandID = nil 1103 + } 1104 + 1105 + private func firstConflictingCommand( 1106 + for commandID: UserCustomCommand.ID, 1107 + shortcut: UserCustomShortcut 1108 + ) -> UserCustomCommand? { 1109 + store.userSettings.customCommands.first { command in 1110 + guard command.id != commandID else { return false } 1111 + guard let existingShortcut = command.shortcut?.normalized() else { return false } 1112 + return existingShortcut == shortcut 1113 + } 1114 + } 1115 + 1116 + private func applyPendingShortcut(replacingConflict: Bool) { 1117 + guard let pendingShortcut else { 1118 + clearPendingShortcutConflict() 1119 + return 1120 + } 1121 + 1122 + if replacingConflict, 1123 + let existingCommandID = pendingShortcutConflict?.existingCommandID 1124 + { 1125 + updateCustomCommand(id: existingCommandID) { command in 1126 + command.shortcut = nil 1127 + } 1128 + } 1129 + 1130 + updateCustomCommand(id: pendingShortcut.commandID) { command in 1131 + command.shortcut = pendingShortcut.shortcut 1132 + } 1133 + 1134 + clearPendingShortcutConflict() 1135 + } 1136 + 1137 + private func clearPendingShortcutConflict() { 1138 + pendingShortcutConflict = nil 1139 + pendingShortcut = nil 1140 + } 1141 + 1142 + private var isShortcutConflictAlertPresented: Binding<Bool> { 1143 + Binding( 1144 + get: { pendingShortcutConflict != nil }, 1145 + set: { shouldPresent in 1146 + if !shouldPresent { 1147 + clearPendingShortcutConflict() 1148 + } 1149 + } 1150 + ) 1151 + } 1152 + 1153 + private var customCommandsIconColumnWidth: CGFloat { 48 } 1154 + 1155 + private var customCommandsNameColumnWidth: CGFloat { 130 } 1156 + 1157 + private var customCommandsShortcutColumnWidth: CGFloat { 100 } 1158 + 1159 + private var customCommandsListHeight: CGFloat { 200 } 1160 + } 1161 + 1162 + private struct InlineEditableCellButton<Label: View>: View { 1163 + let isActive: Bool 1164 + let activeColor: Color 1165 + let contentAlignment: Alignment 1166 + let action: () -> Void 1167 + @ViewBuilder let label: () -> Label 1168 + 1169 + @State private var isHovering = false 1170 + 1171 + init( 1172 + isActive: Bool = false, 1173 + activeColor: Color = .accentColor, 1174 + contentAlignment: Alignment = .leading, 1175 + action: @escaping () -> Void, 1176 + @ViewBuilder label: @escaping () -> Label 1177 + ) { 1178 + self.isActive = isActive 1179 + self.activeColor = activeColor 1180 + self.contentAlignment = contentAlignment 1181 + self.action = action 1182 + self.label = label 1183 + } 1184 + 1185 + var body: some View { 1186 + Button(action: action) { 1187 + label() 1188 + .frame(maxWidth: .infinity, alignment: contentAlignment) 1189 + .padding(.horizontal, 8) 1190 + .padding(.vertical, 4) 1191 + .contentShape(Rectangle()) 1192 + } 1193 + .buttonStyle(.plain) 1194 + .frame(maxWidth: .infinity, alignment: contentAlignment) 1195 + .onHover { hovering in 1196 + isHovering = hovering 1197 + } 1198 + .overlay { 1199 + RoundedRectangle(cornerRadius: 6) 1200 + .strokeBorder(borderColor, lineWidth: borderWidth) 1201 + } 1202 + } 1203 + 1204 + private var borderColor: Color { 1205 + if isActive { 1206 + return activeColor 1207 + } 1208 + if isHovering { 1209 + return Color(nsColor: .tertiaryLabelColor) 1210 + } 1211 + return .clear 1212 + } 1213 + 1214 + private var borderWidth: CGFloat { 1215 + (isActive || isHovering) ? 1 : 0 1216 + } 1217 + } 1218 + 1219 + private struct InlineEditableFieldContainer<Content: View>: View { 1220 + let isActive: Bool 1221 + let activeColor: Color 1222 + @ViewBuilder let content: () -> Content 1223 + @State private var isHovering = false 1224 + 1225 + init( 1226 + isActive: Bool = false, 1227 + activeColor: Color = .accentColor, 1228 + @ViewBuilder content: @escaping () -> Content 1229 + ) { 1230 + self.isActive = isActive 1231 + self.activeColor = activeColor 1232 + self.content = content 1233 + } 1234 + 1235 + var body: some View { 1236 + content() 1237 + .frame(maxWidth: .infinity, alignment: .leading) 1238 + .padding(.horizontal, 8) 1239 + .padding(.vertical, 4) 1240 + .frame(maxWidth: .infinity, alignment: .leading) 1241 + .onHover { hovering in 1242 + isHovering = hovering 1243 + } 1244 + .overlay { 1245 + RoundedRectangle(cornerRadius: 6) 1246 + .strokeBorder(borderColor, lineWidth: borderWidth) 1247 + } 1248 + } 1249 + 1250 + private var borderColor: Color { 1251 + if isActive { 1252 + return activeColor 1253 + } 1254 + if isHovering { 1255 + return Color(nsColor: .tertiaryLabelColor) 1256 + } 1257 + return .clear 225 1258 } 226 1259 227 - private func removeCustomCommand(id: UserCustomCommand.ID) { 228 - store.userSettings.customCommands.removeAll { $0.id == id } 1260 + private var borderWidth: CGFloat { 1261 + (isActive || isHovering) ? 1 : 0 1262 + } 1263 + } 1264 + 1265 + private struct FirstResponderAnchorView: NSViewRepresentable { 1266 + let onResolve: (NSView) -> Void 1267 + 1268 + func makeNSView(context: Context) -> FirstResponderAnchorNSView { 1269 + let view = FirstResponderAnchorNSView() 1270 + onResolve(view) 1271 + return view 1272 + } 1273 + 1274 + func updateNSView(_ nsView: FirstResponderAnchorNSView, context: Context) { 1275 + onResolve(nsView) 1276 + } 1277 + } 1278 + 1279 + private final class FirstResponderAnchorNSView: NSView { 1280 + override var acceptsFirstResponder: Bool { 1281 + true 229 1282 } 230 1283 } 231 1284 ··· 292 1345 } 293 1346 } 294 1347 295 - private struct UserCustomCommandCard: View { 296 - @Binding var command: UserCustomCommand 297 - let onRemove: () -> Void 298 - 299 - var body: some View { 300 - VStack(alignment: .leading, spacing: 10) { 301 - HStack(alignment: .firstTextBaseline, spacing: 12) { 302 - TextField("Name", text: $command.title) 303 - .textFieldStyle(.roundedBorder) 304 - TextField("SF Symbol", text: $command.systemImage) 305 - .textFieldStyle(.roundedBorder) 306 - Picker("Type", selection: $command.execution) { 307 - ForEach(UserCustomCommandExecution.allCases) { execution in 308 - Text(execution.title) 309 - .tag(execution) 310 - } 311 - } 312 - .labelsHidden() 313 - .frame(maxWidth: 180) 314 - Button(role: .destructive) { 315 - onRemove() 316 - } label: { 317 - Image(systemName: "trash") 318 - .accessibilityLabel("Remove command") 319 - } 320 - .help("Remove this custom command") 321 - } 322 - .font(.caption) 323 - 324 - shortcutEditor 325 - 326 - ZStack(alignment: .topLeading) { 327 - PlainTextEditor( 328 - text: $command.command, 329 - isMonospaced: true 330 - ) 331 - .frame(minHeight: 100) 332 - if command.command.isEmpty { 333 - Text(placeholder) 334 - .foregroundStyle(.secondary) 335 - .padding(.leading, 6) 336 - .font(.body.monospaced()) 337 - .allowsHitTesting(false) 338 - } 339 - } 340 - } 341 - .padding(.vertical, 4) 342 - } 1348 + private struct CustomCommandShortcutConflict: Equatable { 1349 + let newCommandID: UserCustomCommand.ID 1350 + let newCommandTitle: String 1351 + let existingCommandID: UserCustomCommand.ID 1352 + let existingCommandTitle: String 1353 + let shortcutDisplay: String 1354 + } 343 1355 344 - @ViewBuilder 345 - private var shortcutEditor: some View { 346 - VStack(alignment: .leading, spacing: 6) { 347 - Toggle("Enable Shortcut", isOn: shortcutEnabled) 348 - if let shortcut = Binding($command.shortcut) { 349 - HStack(spacing: 12) { 350 - TextField("Key", text: shortcutKeyBinding(shortcut)) 351 - .textFieldStyle(.roundedBorder) 352 - .frame(width: 70) 353 - modifierToggle("⌘", isOn: shortcut.modifiers.command) 354 - modifierToggle("⇧", isOn: shortcut.modifiers.shift) 355 - modifierToggle("⌥", isOn: shortcut.modifiers.option) 356 - modifierToggle("⌃", isOn: shortcut.modifiers.control) 357 - Spacer(minLength: 0) 358 - Text(shortcut.wrappedValue.display) 359 - .font(.caption.monospaced()) 360 - .foregroundStyle(.secondary) 361 - } 362 - .font(.caption) 363 - } 364 - } 365 - } 366 - 367 - private var shortcutEnabled: Binding<Bool> { 368 - Binding( 369 - get: { command.shortcut != nil }, 370 - set: { enabled in 371 - if enabled { 372 - command.shortcut = 373 - command.shortcut 374 - ?? UserCustomShortcut( 375 - key: "", 376 - modifiers: UserCustomShortcutModifiers() 377 - ) 378 - } else { 379 - command.shortcut = nil 380 - } 381 - } 382 - ) 383 - } 384 - 385 - private func shortcutKeyBinding(_ shortcut: Binding<UserCustomShortcut>) -> Binding<String> { 386 - Binding( 387 - get: { shortcut.wrappedValue.key }, 388 - set: { value in 389 - let scalar = value.trimmingCharacters(in: .whitespacesAndNewlines).first 390 - shortcut.wrappedValue.key = scalar.map { String($0).lowercased() } ?? "" 391 - } 392 - ) 393 - } 394 - 395 - private func modifierToggle(_ symbol: String, isOn: Binding<Bool>) -> some View { 396 - HStack(spacing: 4) { 397 - Text(symbol) 398 - Toggle("", isOn: isOn) 399 - .labelsHidden() 400 - } 401 - .fixedSize() 402 - } 403 - 404 - private var placeholder: String { 405 - switch command.execution { 406 - case .shellScript: 407 - return "npm test && swift test" 408 - case .terminalInput: 409 - return "pnpm test --watch" 410 - } 411 - } 1356 + private struct PendingCustomShortcut: Equatable { 1357 + let commandID: UserCustomCommand.ID 1358 + let shortcut: UserCustomShortcut 412 1359 }
+1
supacode/Features/Settings/Views/SettingsSection.swift
··· 3 3 enum SettingsSection: Hashable { 4 4 case general 5 5 case notifications 6 + case shortcuts 6 7 case worktree 7 8 case updates 8 9 case advanced
+8
supacode/Features/Settings/Views/SettingsView.swift
··· 33 33 .tag(SettingsSection.general) 34 34 Label("Notifications", systemImage: "bell") 35 35 .tag(SettingsSection.notifications) 36 + Label("Shortcuts", systemImage: "keyboard") 37 + .tag(SettingsSection.shortcuts) 36 38 Label("Worktree", systemImage: "archivebox") 37 39 .tag(SettingsSection.worktree) 38 40 Label("Updates", systemImage: "arrow.down.circle") ··· 67 69 NotificationsSettingsView(store: settingsStore) 68 70 .navigationTitle("Notifications") 69 71 .navigationSubtitle("In-app alerts and delivery") 72 + } 73 + case .shortcuts: 74 + SettingsDetailView { 75 + ShortcutsSettingsView(store: settingsStore) 76 + .navigationTitle("Shortcuts") 77 + .navigationSubtitle("Global keybindings") 70 78 } 71 79 case .worktree: 72 80 SettingsDetailView {
+956
supacode/Features/Settings/Views/ShortcutsSettingsView.swift
··· 1 + import AppKit 2 + import ComposableArchitecture 3 + import SwiftUI 4 + 5 + struct ShortcutsSettingsView: View { 6 + private enum ShortcutTableLayout { 7 + static let commandColumnMinWidth: CGFloat = 180 8 + static let statusChipWidth: CGFloat = 108 9 + static let statusChipHeight: CGFloat = 24 10 + static let statusColumnWidth: CGFloat = statusChipWidth 11 + static let shortcutColumnMinWidth: CGFloat = 120 12 + static let shortcutColumnIdealWidth: CGFloat = 220 13 + static let actionColumnWidth: CGFloat = 16 14 + } 15 + 16 + @Bindable var store: StoreOf<SettingsFeature> 17 + 18 + @State private var searchText = "" 19 + @State private var recordingCommandID: String? 20 + @State private var recorderMonitor: Any? 21 + @State private var invalidMessageByCommandID: [String: String] = [:] 22 + @State private var pendingConflict: ShortcutConflict? 23 + @State private var pendingResetConflict: ResetConflict? 24 + @State private var pendingOverride: PendingOverride? 25 + @State private var focusedConflictCommandID: String? 26 + @State private var hoveredRecorderCommandID: String? 27 + private let keyTokenResolver = ShortcutKeyTokenResolver() 28 + 29 + private var schema: KeybindingSchemaDocument { 30 + .appDefaultsV1 31 + } 32 + 33 + private var editableCommands: [KeybindingCommandSchema] { 34 + schema.commands.filter(\.allowUserOverride) 35 + } 36 + 37 + private var resolvedBindings: ResolvedKeybindingMap { 38 + KeybindingResolver.resolve( 39 + schema: .appResolverSchema(), 40 + userOverrides: store.keybindingUserOverrides 41 + ) 42 + } 43 + 44 + private var visibleGroups: [ShortcutGroup] { 45 + ShortcutGroup.allCases.filter { !commands(for: $0).isEmpty } 46 + } 47 + 48 + var body: some View { 49 + VStack(alignment: .leading, spacing: 10) { 50 + HStack(spacing: 8) { 51 + TextField("Search actions or shortcuts", text: $searchText) 52 + .textFieldStyle(.roundedBorder) 53 + 54 + Button("Reset All") { 55 + resetAllOverrides() 56 + } 57 + .disabled(store.keybindingUserOverrides.overrides.isEmpty) 58 + } 59 + 60 + HStack(spacing: 12) { 61 + Text("Command") 62 + .frame(minWidth: ShortcutTableLayout.commandColumnMinWidth, maxWidth: .infinity, alignment: .leading) 63 + .layoutPriority(1) 64 + Text("Status") 65 + .frame(width: ShortcutTableLayout.statusColumnWidth, alignment: .leading) 66 + Text("Shortcut") 67 + .frame( 68 + minWidth: ShortcutTableLayout.shortcutColumnMinWidth, 69 + idealWidth: ShortcutTableLayout.shortcutColumnIdealWidth, 70 + maxWidth: ShortcutTableLayout.shortcutColumnIdealWidth, 71 + alignment: .leading 72 + ) 73 + Color.clear 74 + .frame(width: ShortcutTableLayout.actionColumnWidth, height: 1) 75 + } 76 + .font(.caption.weight(.semibold)) 77 + .foregroundStyle(.secondary) 78 + .padding(.horizontal, 12) 79 + 80 + List { 81 + ForEach(visibleGroups) { group in 82 + Section { 83 + ForEach(commands(for: group), id: \.id) { command in 84 + row(for: command) 85 + .listRowInsets(EdgeInsets(top: 4, leading: 10, bottom: 4, trailing: 10)) 86 + .listRowBackground(rowBackground(for: command.id)) 87 + } 88 + } header: { 89 + HStack(alignment: .center, spacing: 8) { 90 + Text(group.title) 91 + .font(.caption.weight(.semibold)) 92 + .foregroundStyle(.secondary) 93 + Spacer(minLength: 0) 94 + if hasOverrides(in: group) { 95 + Button("Reset Section") { 96 + resetOverrides(in: group) 97 + } 98 + .buttonStyle(.link) 99 + .font(.caption) 100 + } 101 + } 102 + } 103 + } 104 + 105 + if visibleGroups.isEmpty { 106 + Text("No shortcuts found.") 107 + .foregroundStyle(.secondary) 108 + } 109 + } 110 + .listStyle(.inset) 111 + .environment(\.defaultMinListRowHeight, 32) 112 + } 113 + .onChange(of: recordingCommandID) { _, commandID in 114 + if commandID == nil { 115 + stopRecorderMonitor() 116 + } else { 117 + startRecorderMonitor() 118 + } 119 + } 120 + .onDisappear { 121 + stopRecorderMonitor() 122 + } 123 + .alert( 124 + "Shortcut Conflict", 125 + isPresented: isConflictAlertPresented, 126 + presenting: pendingConflict 127 + ) { conflict in 128 + Button("Replace", role: .destructive) { 129 + applyPendingOverride(replacingConflict: true) 130 + } 131 + Button("Show Conflict") { 132 + focusConflictCommand(conflict) 133 + } 134 + Button("Cancel", role: .cancel) { 135 + clearPendingConflict() 136 + } 137 + } message: { conflict in 138 + Text( 139 + "“\(conflict.newCommandTitle)” and “\(conflict.existingCommandTitle)” both use \(conflict.binding.display)." 140 + + "\n\nChoose Replace to keep the new binding and disable the conflicting one." 141 + ) 142 + } 143 + .alert( 144 + "Reset Conflict", 145 + isPresented: isResetConflictAlertPresented, 146 + presenting: pendingResetConflict 147 + ) { _ in 148 + Button("Reset Related", role: .destructive) { 149 + applyPendingResetConflict() 150 + } 151 + Button("Cancel", role: .cancel) { 152 + clearPendingResetConflict() 153 + } 154 + } message: { conflict in 155 + Text(resetConflictMessage(for: conflict)) 156 + } 157 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 158 + } 159 + 160 + @ViewBuilder 161 + private func row(for command: KeybindingCommandSchema) -> some View { 162 + let isRecording = recordingCommandID == command.id 163 + let resolvedBinding = resolvedBindings.binding(for: command.id)?.binding 164 + let source = resolvedBindings.binding(for: command.id)?.source ?? .appDefault 165 + let hasOverride = store.keybindingUserOverrides.overrides[command.id] != nil 166 + let isHoveringRecorder = hoveredRecorderCommandID == command.id 167 + 168 + VStack(alignment: .leading, spacing: 6) { 169 + HStack(alignment: .center, spacing: 12) { 170 + Text(command.title) 171 + .lineLimit(1) 172 + .truncationMode(.tail) 173 + .frame(minWidth: ShortcutTableLayout.commandColumnMinWidth, maxWidth: .infinity, alignment: .leading) 174 + .layoutPriority(1) 175 + 176 + sourceChip(source) 177 + .frame(width: ShortcutTableLayout.statusColumnWidth, alignment: .leading) 178 + 179 + shortcutRecorderField( 180 + commandID: command.id, 181 + resolvedBinding: resolvedBinding, 182 + isRecording: isRecording, 183 + isHovering: isHoveringRecorder 184 + ) 185 + .frame( 186 + minWidth: ShortcutTableLayout.shortcutColumnMinWidth, 187 + idealWidth: ShortcutTableLayout.shortcutColumnIdealWidth, 188 + maxWidth: ShortcutTableLayout.shortcutColumnIdealWidth, 189 + alignment: .leading 190 + ) 191 + 192 + if hasOverride { 193 + Button { 194 + requestResetOverride(for: command.id) 195 + } label: { 196 + Image(systemName: "arrow.counterclockwise") 197 + .font(.caption.weight(.semibold)) 198 + .foregroundStyle(.secondary) 199 + .accessibilityHidden(true) 200 + } 201 + .buttonStyle(.plain) 202 + .help("Reset to default") 203 + .accessibilityLabel("Reset shortcut to default") 204 + .frame(width: ShortcutTableLayout.actionColumnWidth, height: ShortcutTableLayout.actionColumnWidth) 205 + } else { 206 + Color.clear 207 + .frame(width: ShortcutTableLayout.actionColumnWidth, height: ShortcutTableLayout.actionColumnWidth) 208 + } 209 + } 210 + 211 + if isRecording { 212 + HStack(spacing: 8) { 213 + Text( 214 + "Recording: press a key with modifiers (⌘ ⇧ ⌥ ⌃). Return and arrow keys are supported. Press Esc to cancel." 215 + ) 216 + Spacer(minLength: 0) 217 + Button("Cancel") { 218 + stopRecording() 219 + } 220 + .buttonStyle(.link) 221 + } 222 + .font(.caption) 223 + .foregroundStyle(.secondary) 224 + } 225 + 226 + if let invalid = invalidMessageByCommandID[command.id] { 227 + Text(invalid) 228 + .font(.caption) 229 + .foregroundStyle(.red) 230 + } 231 + } 232 + .padding(.vertical, 2) 233 + } 234 + 235 + private func rowBackground(for commandID: String) -> some View { 236 + let isFocused = focusedConflictCommandID == commandID 237 + return RoundedRectangle(cornerRadius: 6) 238 + .fill(isFocused ? Color.orange.opacity(0.15) : .clear) 239 + } 240 + 241 + private func shortcutRecorderField( 242 + commandID: String, 243 + resolvedBinding: Keybinding?, 244 + isRecording: Bool, 245 + isHovering: Bool 246 + ) -> some View { 247 + Button { 248 + toggleRecording(for: commandID) 249 + } label: { 250 + HStack(spacing: 6) { 251 + if isRecording { 252 + Image(systemName: "record.circle.fill") 253 + .font(.caption) 254 + .foregroundStyle(Color.accentColor) 255 + .accessibilityHidden(true) 256 + } 257 + 258 + Text(shortcutRecorderTitle(resolvedBinding: resolvedBinding, isRecording: isRecording)) 259 + .font(.body.monospaced()) 260 + .lineLimit(1) 261 + .truncationMode(.tail) 262 + .frame(maxWidth: .infinity, alignment: .leading) 263 + .foregroundStyle(shortcutRecorderForegroundColor(resolvedBinding: resolvedBinding, isRecording: isRecording)) 264 + } 265 + .padding(.horizontal, 10) 266 + .padding(.vertical, 6) 267 + .background( 268 + RoundedRectangle(cornerRadius: 6) 269 + .fill(Color(nsColor: .textBackgroundColor)) 270 + ) 271 + .overlay { 272 + RoundedRectangle(cornerRadius: 6) 273 + .strokeBorder(shortcutRecorderBorderColor(isRecording: isRecording, isHovering: isHovering), lineWidth: 1) 274 + } 275 + } 276 + .buttonStyle(.plain) 277 + .onHover { hovering in 278 + if hovering { 279 + hoveredRecorderCommandID = commandID 280 + } else if hoveredRecorderCommandID == commandID { 281 + hoveredRecorderCommandID = nil 282 + } 283 + } 284 + .help(isRecording ? "Recording shortcut. Press Esc to cancel." : "Click to record a shortcut.") 285 + } 286 + 287 + private func shortcutRecorderTitle(resolvedBinding: Keybinding?, isRecording: Bool) -> String { 288 + if isRecording { 289 + return "Recording…" 290 + } 291 + return resolvedBinding?.display ?? "Unassigned" 292 + } 293 + 294 + private func shortcutRecorderForegroundColor(resolvedBinding: Keybinding?, isRecording: Bool) -> Color { 295 + if isRecording { 296 + return .accentColor 297 + } 298 + return resolvedBinding == nil ? .secondary : .primary 299 + } 300 + 301 + private func shortcutRecorderBorderColor(isRecording: Bool, isHovering: Bool) -> Color { 302 + if isRecording { 303 + return .accentColor 304 + } 305 + if isHovering { 306 + return Color(nsColor: .tertiaryLabelColor) 307 + } 308 + return Color(nsColor: .separatorColor) 309 + } 310 + 311 + private func sourceChip(_ source: KeybindingSource) -> some View { 312 + let isDefault = source == .appDefault 313 + guard !isDefault else { 314 + return AnyView( 315 + Color.clear 316 + .frame(width: ShortcutTableLayout.statusChipWidth, height: ShortcutTableLayout.statusChipHeight) 317 + .accessibilityHidden(true) 318 + ) 319 + } 320 + 321 + return AnyView( 322 + Text("Defined") 323 + .font(.caption2.monospaced()) 324 + .lineLimit(1) 325 + .minimumScaleFactor(0.8) 326 + .frame(width: ShortcutTableLayout.statusChipWidth, height: ShortcutTableLayout.statusChipHeight) 327 + .foregroundStyle(AnyShapeStyle(Color.accentColor)) 328 + .background( 329 + Capsule() 330 + .fill(Color.accentColor.opacity(0.2)) 331 + ) 332 + .overlay( 333 + Capsule() 334 + .strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1) 335 + ) 336 + ) 337 + } 338 + 339 + private func commands(for group: ShortcutGroup) -> [KeybindingCommandSchema] { 340 + editableCommands 341 + .filter { ShortcutGroup.resolve(for: $0.id) == group } 342 + .filter(matchesSearch) 343 + .sorted { 344 + commandSortKey(for: $0, in: group) < commandSortKey(for: $1, in: group) 345 + } 346 + } 347 + 348 + private func commandSortKey( 349 + for command: KeybindingCommandSchema, 350 + in group: ShortcutGroup 351 + ) -> CommandSortKey { 352 + guard group == .terminal else { 353 + return CommandSortKey(category: 0, order: 0, title: command.title) 354 + } 355 + 356 + if let order = terminalTabOrder(for: command.id) { 357 + return CommandSortKey(category: 0, order: order, title: command.title) 358 + } 359 + 360 + if let order = terminalPaneOrder(for: command.id) { 361 + return CommandSortKey(category: 1, order: order, title: command.title) 362 + } 363 + 364 + return CommandSortKey(category: 2, order: Int.max, title: command.title) 365 + } 366 + 367 + private func terminalTabOrder(for commandID: String) -> Int? { 368 + switch commandID { 369 + case AppShortcuts.CommandID.selectPreviousTerminalTab: 370 + return 0 371 + case AppShortcuts.CommandID.selectNextTerminalTab: 372 + return 1 373 + case AppShortcuts.CommandID.selectTerminalTab1: 374 + return 2 375 + case AppShortcuts.CommandID.selectTerminalTab2: 376 + return 3 377 + case AppShortcuts.CommandID.selectTerminalTab3: 378 + return 4 379 + case AppShortcuts.CommandID.selectTerminalTab4: 380 + return 5 381 + case AppShortcuts.CommandID.selectTerminalTab5: 382 + return 6 383 + case AppShortcuts.CommandID.selectTerminalTab6: 384 + return 7 385 + case AppShortcuts.CommandID.selectTerminalTab7: 386 + return 8 387 + case AppShortcuts.CommandID.selectTerminalTab8: 388 + return 9 389 + case AppShortcuts.CommandID.selectTerminalTab9: 390 + return 10 391 + case AppShortcuts.CommandID.selectTerminalTab0: 392 + return 11 393 + default: 394 + return nil 395 + } 396 + } 397 + 398 + private func terminalPaneOrder(for commandID: String) -> Int? { 399 + switch commandID { 400 + case AppShortcuts.CommandID.selectPreviousTerminalPane: 401 + return 0 402 + case AppShortcuts.CommandID.selectNextTerminalPane: 403 + return 1 404 + case AppShortcuts.CommandID.selectTerminalPaneUp: 405 + return 2 406 + case AppShortcuts.CommandID.selectTerminalPaneDown: 407 + return 3 408 + case AppShortcuts.CommandID.selectTerminalPaneLeft: 409 + return 4 410 + case AppShortcuts.CommandID.selectTerminalPaneRight: 411 + return 5 412 + default: 413 + return nil 414 + } 415 + } 416 + 417 + private func matchesSearch(_ command: KeybindingCommandSchema) -> Bool { 418 + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) 419 + guard !query.isEmpty else { return true } 420 + 421 + if command.title.localizedCaseInsensitiveContains(query) { 422 + return true 423 + } 424 + if command.id.localizedCaseInsensitiveContains(query) { 425 + return true 426 + } 427 + if let display = resolvedBindings.binding(for: command.id)?.binding?.display, 428 + display.localizedCaseInsensitiveContains(query) 429 + { 430 + return true 431 + } 432 + return false 433 + } 434 + 435 + private func hasOverrides(in group: ShortcutGroup) -> Bool { 436 + let commandIDs = Set(commands(for: group).map(\.id)) 437 + return store.keybindingUserOverrides.overrides.keys.contains { commandIDs.contains($0) } 438 + } 439 + 440 + private func toggleRecording(for commandID: String) { 441 + invalidMessageByCommandID[commandID] = nil 442 + focusedConflictCommandID = nil 443 + if recordingCommandID == commandID { 444 + recordingCommandID = nil 445 + return 446 + } 447 + recordingCommandID = commandID 448 + } 449 + 450 + private func stopRecording() { 451 + recordingCommandID = nil 452 + } 453 + 454 + private func startRecorderMonitor() { 455 + stopRecorderMonitor() 456 + recorderMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in 457 + guard let commandID = recordingCommandID else { 458 + return event 459 + } 460 + handleRecorderEvent(event, commandID: commandID) 461 + return nil 462 + } 463 + } 464 + 465 + private func stopRecorderMonitor() { 466 + if let recorderMonitor { 467 + NSEvent.removeMonitor(recorderMonitor) 468 + self.recorderMonitor = nil 469 + } 470 + } 471 + 472 + private func handleRecorderEvent(_ event: NSEvent, commandID: String) { 473 + if event.keyCode == 53 { // Escape 474 + stopRecording() 475 + return 476 + } 477 + 478 + guard let keyToken = keyToken(for: event) else { 479 + invalidMessageByCommandID[commandID] = "Unsupported key. Use letters, numbers, punctuation, Return, or arrows." 480 + return 481 + } 482 + 483 + let modifiers = KeybindingModifiers( 484 + command: event.modifierFlags.contains(.command), 485 + shift: event.modifierFlags.contains(.shift), 486 + option: event.modifierFlags.contains(.option), 487 + control: event.modifierFlags.contains(.control) 488 + ) 489 + 490 + guard !modifiers.isEmpty else { 491 + invalidMessageByCommandID[commandID] = "Shortcut must include at least one modifier key." 492 + return 493 + } 494 + 495 + let binding = Keybinding(key: keyToken, modifiers: modifiers) 496 + applyRecordedBinding(binding, to: commandID) 497 + } 498 + 499 + private func keyToken(for event: NSEvent) -> String? { 500 + keyTokenResolver.resolveKeyToken( 501 + keyCode: event.keyCode, 502 + charactersIgnoringModifiers: event.charactersIgnoringModifiers 503 + ) 504 + } 505 + 506 + private func applyRecordedBinding(_ binding: Keybinding, to commandID: String) { 507 + invalidMessageByCommandID[commandID] = nil 508 + focusedConflictCommandID = nil 509 + 510 + guard let command = editableCommands.first(where: { $0.id == commandID }) else { 511 + stopRecording() 512 + return 513 + } 514 + 515 + let conflict = firstConflict( 516 + commandID: commandID, 517 + binding: binding, 518 + policy: command.conflictPolicy 519 + ) 520 + 521 + if let conflict { 522 + pendingConflict = conflict 523 + pendingOverride = PendingOverride(commandID: commandID, binding: binding) 524 + stopRecording() 525 + return 526 + } 527 + 528 + saveOverride( 529 + commandID: commandID, 530 + binding: binding, 531 + replaceConflictCommandID: nil 532 + ) 533 + stopRecording() 534 + } 535 + 536 + private func firstConflict( 537 + commandID: String, 538 + binding: Keybinding, 539 + policy: KeybindingConflictPolicy 540 + ) -> ShortcutConflict? { 541 + guard 542 + let existingCommandID = ShortcutConflictDetector.firstConflictCommandID( 543 + commandID: commandID, 544 + binding: binding, 545 + policy: policy, 546 + schema: .appResolverSchema(), 547 + userOverrides: store.keybindingUserOverrides 548 + ) 549 + else { 550 + return nil 551 + } 552 + 553 + guard let existingCommand = editableCommands.first(where: { $0.id == existingCommandID }) else { 554 + return nil 555 + } 556 + 557 + let newTitle = editableCommands.first(where: { $0.id == commandID })?.title ?? commandID 558 + return ShortcutConflict( 559 + newCommandID: commandID, 560 + newCommandTitle: newTitle, 561 + existingCommandID: existingCommand.id, 562 + existingCommandTitle: existingCommand.title, 563 + binding: binding 564 + ) 565 + } 566 + 567 + private func applyPendingOverride(replacingConflict: Bool) { 568 + guard let pendingOverride else { 569 + clearPendingConflict() 570 + return 571 + } 572 + 573 + let conflictCommandID = replacingConflict ? pendingConflict?.existingCommandID : nil 574 + saveOverride( 575 + commandID: pendingOverride.commandID, 576 + binding: pendingOverride.binding, 577 + replaceConflictCommandID: conflictCommandID 578 + ) 579 + clearPendingConflict() 580 + } 581 + 582 + private func clearPendingConflict() { 583 + pendingConflict = nil 584 + pendingOverride = nil 585 + } 586 + 587 + private func focusConflictCommand(_ conflict: ShortcutConflict) { 588 + focusedConflictCommandID = conflict.existingCommandID 589 + searchText = conflict.existingCommandTitle 590 + clearPendingConflict() 591 + } 592 + 593 + private func saveOverride( 594 + commandID: String, 595 + binding: Keybinding, 596 + replaceConflictCommandID: String? 597 + ) { 598 + var overrides = store.keybindingUserOverrides 599 + overrides.overrides[commandID] = KeybindingUserOverride(binding: binding) 600 + 601 + if let replaceConflictCommandID { 602 + overrides.overrides[replaceConflictCommandID] = KeybindingUserOverride(binding: nil, isEnabled: false) 603 + } 604 + 605 + $store.keybindingUserOverrides.wrappedValue = overrides 606 + } 607 + 608 + private func requestResetOverride(for commandID: String) { 609 + let plan = ShortcutResetPlanner.makePlan( 610 + commandID: commandID, 611 + schema: .appResolverSchema(), 612 + userOverrides: store.keybindingUserOverrides 613 + ) 614 + 615 + guard !plan.conflictingCommandIDs.isEmpty, let restoredBinding = plan.restoredBinding else { 616 + applyResetOverrides(for: plan.commandIDsToReset) 617 + return 618 + } 619 + 620 + let occupiedCommandTitle = commandTitle(for: plan.conflictingCommandIDs[0]) 621 + let cascadingTitles = plan.commandIDsToReset.dropFirst().map(commandTitle(for:)) 622 + pendingResetConflict = ResetConflict( 623 + commandID: commandID, 624 + commandTitle: commandTitle(for: commandID), 625 + restoredBinding: restoredBinding, 626 + occupiedCommandTitle: occupiedCommandTitle, 627 + cascadingTitles: cascadingTitles, 628 + commandIDsToReset: plan.commandIDsToReset 629 + ) 630 + } 631 + 632 + private func applyResetOverrides(for commandIDs: [String]) { 633 + var overrides = store.keybindingUserOverrides 634 + for commandID in commandIDs { 635 + overrides.overrides.removeValue(forKey: commandID) 636 + invalidMessageByCommandID.removeValue(forKey: commandID) 637 + } 638 + $store.keybindingUserOverrides.wrappedValue = overrides 639 + 640 + if let recordingCommandID, commandIDs.contains(recordingCommandID) { 641 + stopRecording() 642 + } 643 + if let focusedConflictCommandID, commandIDs.contains(focusedConflictCommandID) { 644 + self.focusedConflictCommandID = nil 645 + } 646 + clearPendingResetConflict() 647 + } 648 + 649 + private func applyPendingResetConflict() { 650 + guard let pendingResetConflict else { 651 + return 652 + } 653 + applyResetOverrides(for: pendingResetConflict.commandIDsToReset) 654 + } 655 + 656 + private func clearPendingResetConflict() { 657 + pendingResetConflict = nil 658 + } 659 + 660 + private func resetConflictMessage(for conflict: ResetConflict) -> String { 661 + var message = 662 + "Resetting “\(conflict.commandTitle)” restores \(conflict.restoredBinding.display), " 663 + + "which is currently used by “\(conflict.occupiedCommandTitle)”." 664 + 665 + if !conflict.cascadingTitles.isEmpty { 666 + let cascadingList = conflict.cascadingTitles.joined(separator: " → ") 667 + message += "\n\nReset Related will cascade reset: \(cascadingList)." 668 + } 669 + 670 + return message + "\n\nChoose Reset Related to continue, or Cancel." 671 + } 672 + 673 + private func commandTitle(for commandID: String) -> String { 674 + editableCommands.first(where: { $0.id == commandID })?.title ?? commandID 675 + } 676 + 677 + private func resetOverrides(in group: ShortcutGroup) { 678 + let overriddenCommandIDs = commands(for: group) 679 + .map(\.id) 680 + .filter { store.keybindingUserOverrides.overrides[$0] != nil } 681 + guard !overriddenCommandIDs.isEmpty else { return } 682 + 683 + let plan = ShortcutResetPlanner.makePlan( 684 + commandIDs: overriddenCommandIDs, 685 + schema: .appResolverSchema(), 686 + userOverrides: store.keybindingUserOverrides 687 + ) 688 + applyResetOverrides(for: plan.commandIDsToReset) 689 + } 690 + 691 + private func resetAllOverrides() { 692 + $store.keybindingUserOverrides.wrappedValue = .empty 693 + invalidMessageByCommandID.removeAll() 694 + stopRecording() 695 + } 696 + 697 + private var isConflictAlertPresented: Binding<Bool> { 698 + Binding( 699 + get: { pendingConflict != nil }, 700 + set: { shouldPresent in 701 + if !shouldPresent { 702 + clearPendingConflict() 703 + } 704 + } 705 + ) 706 + } 707 + 708 + private var isResetConflictAlertPresented: Binding<Bool> { 709 + Binding( 710 + get: { pendingResetConflict != nil }, 711 + set: { shouldPresent in 712 + if !shouldPresent { 713 + clearPendingResetConflict() 714 + } 715 + } 716 + ) 717 + } 718 + } 719 + 720 + private struct CommandSortKey: Comparable { 721 + let category: Int 722 + let order: Int 723 + let title: String 724 + 725 + static func < (lhs: CommandSortKey, rhs: CommandSortKey) -> Bool { 726 + if lhs.category != rhs.category { 727 + return lhs.category < rhs.category 728 + } 729 + if lhs.order != rhs.order { 730 + return lhs.order < rhs.order 731 + } 732 + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending 733 + } 734 + } 735 + 736 + struct ShortcutResetPlan: Equatable { 737 + let commandIDsToReset: [String] 738 + let restoredBinding: Keybinding? 739 + let conflictingCommandIDs: [String] 740 + } 741 + 742 + enum ShortcutResetPlanner { 743 + static func makePlan( 744 + commandID: String, 745 + schema: KeybindingSchemaDocument, 746 + userOverrides: KeybindingUserOverrideStore 747 + ) -> ShortcutResetPlan { 748 + let restoredResolved = resolvedMap( 749 + byResetting: commandID, 750 + in: schema, 751 + userOverrides: userOverrides 752 + ) 753 + let restoredBinding = restoredResolved.binding(for: commandID)?.binding 754 + 755 + let cascadePlan = makePlan( 756 + commandIDs: [commandID], 757 + schema: schema, 758 + userOverrides: userOverrides 759 + ) 760 + 761 + return ShortcutResetPlan( 762 + commandIDsToReset: cascadePlan.commandIDsToReset, 763 + restoredBinding: restoredBinding, 764 + conflictingCommandIDs: cascadePlan.conflictingCommandIDs 765 + ) 766 + } 767 + 768 + static func makePlan( 769 + commandIDs: [String], 770 + schema: KeybindingSchemaDocument, 771 + userOverrides: KeybindingUserOverrideStore 772 + ) -> ShortcutResetPlan { 773 + let editableCommandIDs = Set(schema.commands.filter(\.allowUserOverride).map(\.id)) 774 + let seedCommandIDs = commandIDs.filter { editableCommandIDs.contains($0) } 775 + guard !seedCommandIDs.isEmpty else { 776 + return ShortcutResetPlan( 777 + commandIDsToReset: commandIDs, 778 + restoredBinding: nil, 779 + conflictingCommandIDs: [] 780 + ) 781 + } 782 + 783 + let seedCommandIDSet = Set(seedCommandIDs) 784 + var tentative = userOverrides 785 + var pending = seedCommandIDs 786 + var processed: Set<String> = [] 787 + var commandIDsToReset: [String] = [] 788 + var initialConflicts: Set<String> = [] 789 + var index = 0 790 + 791 + while index < pending.count { 792 + let currentCommandID = pending[index] 793 + index += 1 794 + guard editableCommandIDs.contains(currentCommandID) else { continue } 795 + guard processed.insert(currentCommandID).inserted else { continue } 796 + 797 + tentative.overrides.removeValue(forKey: currentCommandID) 798 + commandIDsToReset.append(currentCommandID) 799 + 800 + let resolved = KeybindingResolver.resolve( 801 + schema: schema, 802 + userOverrides: tentative 803 + ) 804 + 805 + let conflicts = conflictingCommandIDs( 806 + for: currentCommandID, 807 + in: resolved, 808 + editableCommandIDs: editableCommandIDs, 809 + excluding: processed 810 + ) 811 + if seedCommandIDSet.contains(currentCommandID) { 812 + initialConflicts.formUnion(conflicts) 813 + } 814 + pending.append(contentsOf: conflicts) 815 + } 816 + 817 + if commandIDsToReset.isEmpty { 818 + commandIDsToReset = seedCommandIDs 819 + } 820 + 821 + return ShortcutResetPlan( 822 + commandIDsToReset: commandIDsToReset, 823 + restoredBinding: nil, 824 + conflictingCommandIDs: initialConflicts.sorted() 825 + ) 826 + } 827 + 828 + private static func resolvedMap( 829 + byResetting commandID: String, 830 + in schema: KeybindingSchemaDocument, 831 + userOverrides: KeybindingUserOverrideStore 832 + ) -> ResolvedKeybindingMap { 833 + var tentative = userOverrides 834 + tentative.overrides.removeValue(forKey: commandID) 835 + return KeybindingResolver.resolve( 836 + schema: schema, 837 + userOverrides: tentative 838 + ) 839 + } 840 + 841 + private static func conflictingCommandIDs( 842 + for commandID: String, 843 + in resolved: ResolvedKeybindingMap, 844 + editableCommandIDs: Set<String>, 845 + excluding excludedCommandIDs: Set<String> 846 + ) -> [String] { 847 + guard let currentBinding = resolved.binding(for: commandID)?.binding else { 848 + return [] 849 + } 850 + 851 + return 852 + editableCommandIDs 853 + .filter { 854 + $0 != commandID 855 + && !excludedCommandIDs.contains($0) 856 + && resolved.binding(for: $0)?.binding == currentBinding 857 + } 858 + .sorted() 859 + } 860 + } 861 + 862 + private struct ShortcutConflict: Equatable { 863 + let newCommandID: String 864 + let newCommandTitle: String 865 + let existingCommandID: String 866 + let existingCommandTitle: String 867 + let binding: Keybinding 868 + } 869 + 870 + private struct ResetConflict: Equatable { 871 + let commandID: String 872 + let commandTitle: String 873 + let restoredBinding: Keybinding 874 + let occupiedCommandTitle: String 875 + let cascadingTitles: [String] 876 + let commandIDsToReset: [String] 877 + } 878 + 879 + private struct PendingOverride: Equatable { 880 + let commandID: String 881 + let binding: Keybinding 882 + } 883 + 884 + private enum ShortcutGroup: String, CaseIterable, Identifiable { 885 + case general 886 + case navigation 887 + case terminal 888 + case scripts 889 + 890 + var id: String { 891 + rawValue 892 + } 893 + 894 + var title: String { 895 + switch self { 896 + case .general: 897 + "General" 898 + case .navigation: 899 + "Navigation" 900 + case .terminal: 901 + "Terminal Tabs & Panes" 902 + case .scripts: 903 + "Scripts & Panels" 904 + } 905 + } 906 + 907 + static func resolve(for commandID: String) -> ShortcutGroup { 908 + switch commandID { 909 + case AppShortcuts.CommandID.selectNextWorktree, 910 + AppShortcuts.CommandID.selectPreviousWorktree, 911 + AppShortcuts.CommandID.renameBranch, 912 + AppShortcuts.CommandID.selectWorktree1, 913 + AppShortcuts.CommandID.selectWorktree2, 914 + AppShortcuts.CommandID.selectWorktree3, 915 + AppShortcuts.CommandID.selectWorktree4, 916 + AppShortcuts.CommandID.selectWorktree5, 917 + AppShortcuts.CommandID.selectWorktree6, 918 + AppShortcuts.CommandID.selectWorktree7, 919 + AppShortcuts.CommandID.selectWorktree8, 920 + AppShortcuts.CommandID.selectWorktree9, 921 + AppShortcuts.CommandID.selectWorktree0: 922 + return .navigation 923 + 924 + case AppShortcuts.CommandID.runScript, 925 + AppShortcuts.CommandID.stopScript, 926 + AppShortcuts.CommandID.showDiff, 927 + AppShortcuts.CommandID.toggleCanvas, 928 + AppShortcuts.CommandID.selectAllCanvasCards, 929 + AppShortcuts.CommandID.archivedWorktrees: 930 + return .scripts 931 + 932 + case AppShortcuts.CommandID.selectTerminalTab1, 933 + AppShortcuts.CommandID.selectTerminalTab2, 934 + AppShortcuts.CommandID.selectTerminalTab3, 935 + AppShortcuts.CommandID.selectTerminalTab4, 936 + AppShortcuts.CommandID.selectTerminalTab5, 937 + AppShortcuts.CommandID.selectTerminalTab6, 938 + AppShortcuts.CommandID.selectTerminalTab7, 939 + AppShortcuts.CommandID.selectTerminalTab8, 940 + AppShortcuts.CommandID.selectTerminalTab9, 941 + AppShortcuts.CommandID.selectTerminalTab0, 942 + AppShortcuts.CommandID.selectPreviousTerminalTab, 943 + AppShortcuts.CommandID.selectNextTerminalTab, 944 + AppShortcuts.CommandID.selectPreviousTerminalPane, 945 + AppShortcuts.CommandID.selectNextTerminalPane, 946 + AppShortcuts.CommandID.selectTerminalPaneUp, 947 + AppShortcuts.CommandID.selectTerminalPaneDown, 948 + AppShortcuts.CommandID.selectTerminalPaneLeft, 949 + AppShortcuts.CommandID.selectTerminalPaneRight: 950 + return .terminal 951 + 952 + default: 953 + return .general 954 + } 955 + } 956 + }
+2 -3
supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift
··· 15 15 @State private var isHoveringClose = false 16 16 @State private var isPressing = false 17 17 @Environment(CommandKeyObserver.self) private var commandKeyObserver 18 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 18 19 19 20 var body: some View { 20 21 ZStack(alignment: .trailing) { ··· 73 74 } 74 75 75 76 private var shortcutHint: String? { 76 - let number = tabIndex + 1 77 - guard number > 0 && number <= 9 else { return nil } 78 - return "⌘\(number)" 77 + AppShortcuts.terminalTabSelectionDisplay(at: tabIndex, in: resolvedKeybindings) 79 78 } 80 79 81 80 private var showsShortcutHint: Bool {
+74
supacode/Infrastructure/Ghostty/GhosttyRuntime.swift
··· 3 3 import SwiftUI 4 4 import UniformTypeIdentifiers 5 5 6 + private let ghosttyLogger = SupaLogger("GhosttyRuntime") 7 + 6 8 final class GhosttyRuntime { 7 9 final class SurfaceReference { 8 10 let surface: ghostty_surface_t ··· 22 24 private var observers: [NSObjectProtocol] = [] 23 25 private var surfaceRefs: [SurfaceReference] = [] 24 26 private var lastColorScheme: ghostty_color_scheme_e? 27 + private var appKeybindOverrideContents = "" 28 + private var appKeybindOverrideEntries: [String] = [] 25 29 var onConfigChange: (() -> Void)? 26 30 var onQuit: (() -> Void)? 27 31 ··· 475 479 ghostty_config_free(existing) 476 480 } 477 481 self.config = config 482 + } 483 + 484 + func applyAppKeybindArguments(_ keybindArguments: [String]) { 485 + let entries = Self.keybindEntries(from: keybindArguments) 486 + let overrideEntries = Self.makeKeybindOverrideEntries( 487 + entries: entries, 488 + previousEntries: appKeybindOverrideEntries 489 + ) 490 + let contents = 491 + overrideEntries 492 + .map { "keybind = \($0)" } 493 + .joined(separator: "\n") 494 + guard contents != appKeybindOverrideContents else { return } 495 + appKeybindOverrideEntries = entries 496 + appKeybindOverrideContents = contents 497 + 498 + let overrideURL = URL(fileURLWithPath: NSTemporaryDirectory()) 499 + .appendingPathComponent("prowl-ghostty-keybind-overrides.conf") 500 + do { 501 + try contents.write(to: overrideURL, atomically: true, encoding: .utf8) 502 + } catch { 503 + ghosttyLogger.warning("Failed to write ghostty keybind override file: \(error.localizedDescription)") 504 + return 505 + } 506 + 507 + guard let app else { return } 508 + guard let updated = ghostty_config_new() else { return } 509 + ghostty_config_load_default_files(updated) 510 + ghostty_config_load_recursive_files(updated) 511 + ghostty_config_load_cli_args(updated) 512 + overrideURL.path.withCString { path in 513 + ghostty_config_load_file(updated, path) 514 + } 515 + ghostty_config_finalize(updated) 516 + ghostty_app_update_config(app, updated) 517 + if let clone = ghostty_config_clone(updated) { 518 + setConfig(clone) 519 + } 520 + ghostty_config_free(updated) 521 + onConfigChange?() 522 + NotificationCenter.default.post(name: .ghosttyRuntimeConfigDidChange, object: self) 523 + } 524 + 525 + private static func keybindEntries(from keybindArguments: [String]) -> [String] { 526 + let prefix = "--keybind=" 527 + return keybindArguments.compactMap { argument in 528 + guard argument.hasPrefix(prefix) else { return nil } 529 + return String(argument.dropFirst(prefix.count)) 530 + } 531 + } 532 + 533 + private static func makeKeybindOverrideEntries( 534 + entries: [String], 535 + previousEntries: [String] 536 + ) -> [String] { 537 + var unbindEntries: [String] = [] 538 + var seenTriggers = Set<String>() 539 + for entry in previousEntries + entries { 540 + guard let trigger = keybindTrigger(from: entry), seenTriggers.insert(trigger).inserted else { 541 + continue 542 + } 543 + unbindEntries.append("\(trigger)=unbind") 544 + } 545 + return unbindEntries + entries 546 + } 547 + 548 + private static func keybindTrigger(from entry: String) -> String? { 549 + guard let separator = entry.firstIndex(of: "=") else { return nil } 550 + let trigger = String(entry[..<separator]) 551 + return trigger.isEmpty ? nil : trigger 478 552 } 479 553 480 554 private static func loadConfig() -> ghostty_config_t? {
+4 -4
supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift
··· 30 30 /// Key codes allowed to pass through even with the Command modifier held. 31 31 private static let commandAllowedKeyCodes: Set<UInt16> = [ 32 32 51, // backspace 33 - 123, // arrowLeft 34 - 124, // arrowRight 35 - 125, // arrowDown 36 - 126, // arrowUp 33 + 123, // arrowLeft 34 + 124, // arrowRight 35 + 125, // arrowDown 36 + 126, // arrowUp 37 37 ] 38 38 39 39 init?(
+82 -3
supacode/Support/PlainTextEditor.swift
··· 4 4 struct PlainTextEditor: NSViewRepresentable { 5 5 @Binding var text: String 6 6 var isMonospaced: Bool = false 7 + var shouldFocus: Bool = false 8 + var placeholder: String? 9 + var hidesPlaceholderWhenFocused: Bool = false 7 10 8 11 func makeCoordinator() -> Coordinator { 9 12 Coordinator(text: $text) 10 13 } 11 14 12 15 func makeNSView(context: Context) -> NSScrollView { 13 - let textView = NSTextView(frame: .zero) 16 + let textView = PlaceholderTextView(frame: .zero) 14 17 textView.delegate = context.coordinator 15 18 textView.drawsBackground = false 16 19 textView.isRichText = false ··· 28 31 textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) 29 32 textView.textContainer?.widthTracksTextView = true 30 33 textView.string = text 34 + textView.placeholder = placeholder 35 + textView.hidesPlaceholderWhenFocused = hidesPlaceholderWhenFocused 31 36 32 37 let scrollView = NSScrollView(frame: .zero) 33 38 scrollView.drawsBackground = false ··· 40 45 } 41 46 42 47 func updateNSView(_ nsView: NSScrollView, context: Context) { 43 - guard let textView = nsView.documentView as? NSTextView else { return } 48 + guard let textView = nsView.documentView as? PlaceholderTextView else { return } 44 49 if textView.string != text { 45 50 textView.string = text 51 + textView.needsDisplay = true 46 52 } 47 53 let updatedFont = editorFont 48 54 if textView.font != updatedFont { 49 55 textView.font = updatedFont 56 + textView.needsDisplay = true 57 + } 58 + textView.placeholder = placeholder 59 + textView.hidesPlaceholderWhenFocused = hidesPlaceholderWhenFocused 60 + if shouldFocus, 61 + textView.window?.firstResponder !== textView 62 + { 63 + textView.window?.makeFirstResponder(textView) 50 64 } 51 65 } 52 66 ··· 65 79 } 66 80 67 81 func textDidChange(_ notification: Notification) { 68 - guard let textView = notification.object as? NSTextView else { return } 82 + guard let textView = notification.object as? PlaceholderTextView else { return } 69 83 text = textView.string 84 + textView.needsDisplay = true 85 + } 86 + } 87 + 88 + final class PlaceholderTextView: NSTextView { 89 + var placeholder: String? { 90 + didSet { 91 + needsDisplay = true 92 + } 93 + } 94 + var hidesPlaceholderWhenFocused: Bool = true { 95 + didSet { 96 + needsDisplay = true 97 + } 98 + } 99 + 100 + override func becomeFirstResponder() -> Bool { 101 + let didBecomeFirstResponder = super.becomeFirstResponder() 102 + if didBecomeFirstResponder { 103 + needsDisplay = true 104 + } 105 + return didBecomeFirstResponder 106 + } 107 + 108 + override func resignFirstResponder() -> Bool { 109 + let didResignFirstResponder = super.resignFirstResponder() 110 + if didResignFirstResponder { 111 + needsDisplay = true 112 + } 113 + return didResignFirstResponder 114 + } 115 + 116 + override func draw(_ dirtyRect: NSRect) { 117 + super.draw(dirtyRect) 118 + drawPlaceholderIfNeeded() 119 + } 120 + 121 + private func drawPlaceholderIfNeeded() { 122 + guard let placeholder, 123 + !placeholder.isEmpty, 124 + string.isEmpty 125 + else { 126 + return 127 + } 128 + 129 + if hidesPlaceholderWhenFocused, 130 + window?.firstResponder === self 131 + { 132 + return 133 + } 134 + 135 + let lineFragmentPadding = textContainer?.lineFragmentPadding ?? 0 136 + let horizontalInset = textContainerInset.width + lineFragmentPadding 137 + let verticalInset = textContainerInset.height - 2 138 + let placeholderRect = NSRect( 139 + x: bounds.minX + horizontalInset, 140 + y: bounds.minY + verticalInset, 141 + width: max(0, bounds.width - horizontalInset - textContainerInset.width), 142 + height: max(0, bounds.height - verticalInset) 143 + ) 144 + let placeholderAttributes: [NSAttributedString.Key: Any] = [ 145 + .foregroundColor: NSColor.placeholderTextColor, 146 + .font: font ?? NSFont.preferredFont(forTextStyle: .body), 147 + ] 148 + (placeholder as NSString).draw(in: placeholderRect, withAttributes: placeholderAttributes) 70 149 } 71 150 } 72 151 }
+104
supacodeTests/AppFeatureCustomCommandTests.swift
··· 99 99 #expect(sent.value.isEmpty) 100 100 } 101 101 102 + @Test(.dependencies) func supportsCustomCommandBeyondLegacyThreeItemLimit() async { 103 + let worktree = makeWorktree() 104 + let sent = LockIsolated<[TerminalClient.Command]>([]) 105 + var state = AppFeature.State( 106 + repositories: makeRepositoriesState(worktree: worktree), 107 + settings: SettingsFeature.State() 108 + ) 109 + state.selectedCustomCommands = [ 110 + UserCustomCommand( 111 + title: "One", 112 + systemImage: "1.circle", 113 + command: "echo one", 114 + execution: .shellScript, 115 + shortcut: nil, 116 + ), 117 + UserCustomCommand( 118 + title: "Two", 119 + systemImage: "2.circle", 120 + command: "echo two", 121 + execution: .shellScript, 122 + shortcut: nil, 123 + ), 124 + UserCustomCommand( 125 + title: "Three", 126 + systemImage: "3.circle", 127 + command: "echo three", 128 + execution: .shellScript, 129 + shortcut: nil, 130 + ), 131 + UserCustomCommand( 132 + title: "Four", 133 + systemImage: "4.circle", 134 + command: "echo four", 135 + execution: .shellScript, 136 + shortcut: nil, 137 + ), 138 + UserCustomCommand( 139 + title: "Five", 140 + systemImage: "5.circle", 141 + command: "echo five", 142 + execution: .shellScript, 143 + shortcut: nil, 144 + ), 145 + ] 146 + 147 + let store = TestStore(initialState: state) { 148 + AppFeature() 149 + } withDependencies: { 150 + $0.terminalClient.send = { command in 151 + sent.withValue { $0.append(command) } 152 + } 153 + } 154 + 155 + await store.send(.runCustomCommand(4)) 156 + await store.finish() 157 + 158 + #expect( 159 + sent.value == [ 160 + .createTabWithInput(worktree, input: "echo five", runSetupScriptIfNew: false) 161 + ], 162 + ) 163 + } 164 + 165 + @Test(.dependencies) func loadingUserSettingsKeepsCustomCommandsWithoutScript() async { 166 + let worktree = makeWorktree() 167 + let settings = UserRepositorySettings( 168 + customCommands: [ 169 + UserCustomCommand( 170 + title: "Empty", 171 + systemImage: "sparkles", 172 + command: "", 173 + execution: .shellScript, 174 + shortcut: nil 175 + ), 176 + UserCustomCommand( 177 + title: "Runnable", 178 + systemImage: "terminal", 179 + command: "echo hello", 180 + execution: .shellScript, 181 + shortcut: nil 182 + ), 183 + ] 184 + ) 185 + 186 + let store = TestStore( 187 + initialState: AppFeature.State( 188 + repositories: makeRepositoriesState(worktree: worktree), 189 + settings: SettingsFeature.State() 190 + ) 191 + ) { 192 + AppFeature() 193 + } 194 + 195 + await store.send(.worktreeUserSettingsLoaded(settings, worktreeID: worktree.id)) { 196 + $0.selectedCustomCommands = settings.customCommands 197 + $0.resolvedKeybindings = KeybindingResolver.resolve( 198 + schema: .appResolverSchema(customCommands: settings.customCommands), 199 + migratedOverrides: LegacyCustomCommandShortcutMigration 200 + .migrate(commands: settings.customCommands) 201 + .overrides 202 + ) 203 + } 204 + } 205 + 102 206 private func makeWorktree() -> Worktree { 103 207 Worktree( 104 208 id: "/tmp/repo/wt-1",
+27 -7
supacodeTests/AppFeaturePlainFolderTerminalTests.swift
··· 73 73 } 74 74 await store.receive(\.worktreeUserSettingsLoaded) { 75 75 $0.selectedCustomCommands = userSettings.customCommands 76 + $0.resolvedKeybindings = KeybindingResolver.resolve( 77 + schema: .appResolverSchema(customCommands: userSettings.customCommands) 78 + ) 76 79 } 77 80 await store.finish() 78 81 ··· 117 120 ) 118 121 } 119 122 120 - @Test(.dependencies) func loadingConflictingShortcutKeepsRegistration() async { 123 + @Test(.dependencies) func conflictingCustomShortcutOverridesAppShortcutOnlyForSelectedRepository() async { 121 124 let repository = makePlainRepository() 122 125 let registeredShortcuts = LockIsolated<[UserCustomShortcut]>([]) 123 126 var state = AppFeature.State( ··· 133 136 registeredShortcuts.setValue(shortcuts) 134 137 } 135 138 } 139 + store.exhaustivity = .off 140 + 141 + #expect( 142 + store.state.resolvedKeybindings.display(for: AppShortcuts.CommandID.showDiff) == AppShortcuts.showDiff.display 143 + ) 136 144 137 145 let conflicted = UserRepositorySettings( 138 146 customCommands: [ ··· 142 150 command: "swift build", 143 151 execution: .shellScript, 144 152 shortcut: UserCustomShortcut( 145 - key: "b", 146 - modifiers: UserCustomShortcutModifiers(command: true) 153 + key: "y", 154 + modifiers: UserCustomShortcutModifiers(command: true, shift: true) 147 155 ) 148 156 ), 149 157 ] 150 158 ) 151 159 152 - await store.send(.worktreeUserSettingsLoaded(conflicted, worktreeID: repository.id)) { 153 - $0.selectedCustomCommands = conflicted.customCommands 154 - } 155 - await store.finish() 160 + await store.send(.worktreeUserSettingsLoaded(conflicted, worktreeID: repository.id)) 156 161 157 162 let expectedShortcut = conflicted.customCommands[0].shortcut?.normalized() 163 + #expect(store.state.selectedCustomCommands == conflicted.customCommands) 158 164 #expect(registeredShortcuts.value == [expectedShortcut].compactMap { $0 }) 165 + let customCommandID = LegacyCustomCommandShortcutMigration.customCommandBindingID( 166 + for: conflicted.customCommands[0].id 167 + ) 168 + #expect(store.state.resolvedKeybindings.display(for: customCommandID) == expectedShortcut?.display) 169 + #expect(store.state.resolvedKeybindings.display(for: AppShortcuts.CommandID.showDiff) == nil) 170 + 171 + await store.send(.worktreeUserSettingsLoaded(.default, worktreeID: repository.id)) 172 + await store.finish() 173 + 174 + #expect(store.state.selectedCustomCommands.isEmpty) 175 + #expect(registeredShortcuts.value.isEmpty) 176 + #expect( 177 + store.state.resolvedKeybindings.display(for: AppShortcuts.CommandID.showDiff) == AppShortcuts.showDiff.display 178 + ) 159 179 } 160 180 161 181 @Test(.dependencies) func customCommandUsesPlainRepositoryTerminalTarget() async {
+45
supacodeTests/AppFeatureSettingsChangedTests.swift
··· 1 1 import ComposableArchitecture 2 + import CustomDump 2 3 import DependenciesTestSupport 3 4 import Testing 4 5 ··· 54 55 55 56 #expect(sentTerminalCommands.value.isEmpty) 56 57 #expect(watcherCommands.value.isEmpty) 58 + } 59 + 60 + @Test(.dependencies) func settingsChangedRecomputesResolvedKeybindings() async { 61 + var settings = GlobalSettings.default 62 + settings.keybindingUserOverrides = KeybindingUserOverrideStore( 63 + overrides: [ 64 + AppShortcuts.CommandID.openSettings: KeybindingUserOverride( 65 + binding: Keybinding(key: ";", modifiers: .init(command: true)) 66 + ), 67 + ] 68 + ) 69 + 70 + let expectedResolved = KeybindingResolver.resolve( 71 + schema: .appResolverSchema(), 72 + userOverrides: settings.keybindingUserOverrides 73 + ) 74 + 75 + let store = TestStore(initialState: AppFeature.State()) { 76 + AppFeature() 77 + } 78 + 79 + await store.send(.settings(.delegate(.settingsChanged(settings)))) { 80 + $0.settings.keybindingUserOverrides = settings.keybindingUserOverrides 81 + $0.resolvedKeybindings = expectedResolved 82 + } 83 + await store.receive(\.repositories.setGithubIntegrationEnabled) 84 + await store.receive(\.repositories.setAutomaticallyArchiveMergedWorktrees) 85 + await store.receive(\.repositories.setMoveNotifiedWorktreeToTop) 86 + await store.receive(\.updates.applySettings) { 87 + $0.updates.didConfigureUpdates = true 88 + } 89 + await store.receive(\.repositories.refreshGithubIntegrationAvailability) { 90 + $0.repositories.githubIntegrationAvailability = .checking 91 + } 92 + await store.receive(\.repositories.githubIntegrationAvailabilityUpdated) { 93 + $0.repositories.githubIntegrationAvailability = .available 94 + $0.repositories.queuedPullRequestRefreshByRepositoryID = [:] 95 + $0.repositories.inFlightPullRequestRefreshRepositoryIDs = [] 96 + } 97 + 98 + expectNoDifference( 99 + store.state.resolvedKeybindings.display(for: AppShortcuts.CommandID.openSettings), 100 + "⌘;" 101 + ) 57 102 } 58 103 59 104 @Test(.dependencies) func clearTerminalLayoutSnapshotShowsSuccessToast() async {
+312 -34
supacodeTests/AppShortcutsTests.swift
··· 29 29 } 30 30 } 31 31 32 + @Test func terminalTabSelectionUsesCommandNumberShortcuts() { 33 + expectNoDifference( 34 + AppShortcuts.terminalTabSelection.map(\.display), 35 + ["⌘1", "⌘2", "⌘3", "⌘4", "⌘5", "⌘6", "⌘7", "⌘8", "⌘9", "⌘0"] 36 + ) 37 + 38 + for shortcut in AppShortcuts.terminalTabSelection { 39 + #expect(shortcut.modifiers == .command) 40 + } 41 + } 42 + 43 + @Test func selectionDisplayUsesResolvedOverrides() { 44 + let overrides = KeybindingUserOverrideStore( 45 + overrides: [ 46 + AppShortcuts.CommandID.selectWorktree1: KeybindingUserOverride( 47 + binding: Keybinding(key: "m", modifiers: .init(control: true)) 48 + ), 49 + AppShortcuts.CommandID.selectTerminalTab1: KeybindingUserOverride( 50 + binding: Keybinding(key: "j", modifiers: .init(command: true)) 51 + ), 52 + ] 53 + ) 54 + let resolved = KeybindingResolver.resolve( 55 + schema: .appResolverSchema(), 56 + userOverrides: overrides 57 + ) 58 + 59 + #expect(AppShortcuts.worktreeSelectionDisplay(at: 0, in: resolved) == "⌃M") 60 + #expect(AppShortcuts.terminalTabSelectionDisplay(at: 0, in: resolved) == "⌘J") 61 + #expect(AppShortcuts.worktreeSelectionDisplay(at: 10, in: resolved) == nil) 62 + #expect(AppShortcuts.terminalTabSelectionDisplay(at: 10, in: resolved) == nil) 63 + } 64 + 65 + @Test func helpTextUsesResolvedShortcutAndHandlesDisabledBinding() { 66 + let defaultHelpText = AppShortcuts.helpText( 67 + title: "Run Script", 68 + commandID: AppShortcuts.CommandID.runScript, 69 + in: .appDefaults 70 + ) 71 + #expect(defaultHelpText == "Run Script (⌘R)") 72 + 73 + let disabledOverrides = KeybindingUserOverrideStore( 74 + overrides: [ 75 + AppShortcuts.CommandID.runScript: KeybindingUserOverride( 76 + binding: nil, 77 + isEnabled: false 78 + ), 79 + ] 80 + ) 81 + let resolvedDisabled = KeybindingResolver.resolve( 82 + schema: .appResolverSchema(), 83 + userOverrides: disabledOverrides 84 + ) 85 + 86 + let disabledHelpText = AppShortcuts.helpText( 87 + title: "Run Script", 88 + commandID: AppShortcuts.CommandID.runScript, 89 + in: resolvedDisabled 90 + ) 91 + #expect(disabledHelpText == "Run Script") 92 + } 93 + 32 94 @Test func defaultGlobalShortcutTableMatchesPlan() { 33 95 expectNoDifference( 34 96 [ ··· 40 102 "showDiff=\(AppShortcuts.showDiff.display)", 41 103 "openFinder=\(AppShortcuts.openFinder.display)", 42 104 "openRepository=\(AppShortcuts.openRepository.display)", 105 + "selectTerminalTab1=\(AppShortcuts.selectTerminalTab1.display)", 106 + "selectTerminalTab2=\(AppShortcuts.selectTerminalTab2.display)", 107 + "selectTerminalTab3=\(AppShortcuts.selectTerminalTab3.display)", 108 + "selectTerminalTab4=\(AppShortcuts.selectTerminalTab4.display)", 109 + "selectTerminalTab5=\(AppShortcuts.selectTerminalTab5.display)", 110 + "selectTerminalTab6=\(AppShortcuts.selectTerminalTab6.display)", 111 + "selectTerminalTab7=\(AppShortcuts.selectTerminalTab7.display)", 112 + "selectTerminalTab8=\(AppShortcuts.selectTerminalTab8.display)", 113 + "selectTerminalTab9=\(AppShortcuts.selectTerminalTab9.display)", 114 + "selectTerminalTab0=\(AppShortcuts.selectTerminalTab0.display)", 115 + "selectPreviousTerminalTab=\(AppShortcuts.selectPreviousTerminalTab.display)", 116 + "selectNextTerminalTab=\(AppShortcuts.selectNextTerminalTab.display)", 117 + "selectPreviousTerminalPane=\(AppShortcuts.selectPreviousTerminalPane.display)", 118 + "selectNextTerminalPane=\(AppShortcuts.selectNextTerminalPane.display)", 119 + "selectTerminalPaneUp=\(AppShortcuts.selectTerminalPaneUp.display)", 120 + "selectTerminalPaneDown=\(AppShortcuts.selectTerminalPaneDown.display)", 121 + "selectTerminalPaneLeft=\(AppShortcuts.selectTerminalPaneLeft.display)", 122 + "selectTerminalPaneRight=\(AppShortcuts.selectTerminalPaneRight.display)", 43 123 ], 44 124 [ 45 125 "openSettings=⌘,", ··· 50 130 "showDiff=⌘⇧Y", 51 131 "openFinder=⌘O", 52 132 "openRepository=⌘⇧O", 133 + "selectTerminalTab1=⌘1", 134 + "selectTerminalTab2=⌘2", 135 + "selectTerminalTab3=⌘3", 136 + "selectTerminalTab4=⌘4", 137 + "selectTerminalTab5=⌘5", 138 + "selectTerminalTab6=⌘6", 139 + "selectTerminalTab7=⌘7", 140 + "selectTerminalTab8=⌘8", 141 + "selectTerminalTab9=⌘9", 142 + "selectTerminalTab0=⌘0", 143 + "selectPreviousTerminalTab=⌘⇧[", 144 + "selectNextTerminalTab=⌘⇧]", 145 + "selectPreviousTerminalPane=⌘[", 146 + "selectNextTerminalPane=⌘]", 147 + "selectTerminalPaneUp=⌘⌥↑", 148 + "selectTerminalPaneDown=⌘⌥↓", 149 + "selectTerminalPaneLeft=⌘⌥←", 150 + "selectTerminalPaneRight=⌘⌥→", 53 151 ] 54 152 ) 55 153 } 56 154 57 - @Test func systemFixedAndLocalInteractionShortcutsAreDefinedInRegistry() { 155 + @Test func configurableSystemFixedAndLocalInteractionShortcutsAreDefinedInRegistry() { 58 156 let idToDisplay = Dictionary(uniqueKeysWithValues: AppShortcuts.bindings.map { ($0.id, $0.shortcut.display) }) 59 157 let idToScope = Dictionary(uniqueKeysWithValues: AppShortcuts.bindings.map { ($0.id, $0.scope) }) 60 158 ··· 75 173 AppShortcuts.selectAllCanvasCards.display 76 174 ) 77 175 78 - #expect(idToScope["command_palette"] == .systemFixedAppAction) 176 + #expect(idToScope["command_palette"] == .configurableAppAction) 79 177 #expect(idToScope["quit_application"] == .systemFixedAppAction) 80 178 #expect(idToScope["rename_branch"] == .localInteraction) 81 179 #expect(idToScope["select_all_canvas_cards"] == .localInteraction) 82 - } 83 - 84 - @Test func tabSelectionGhosttyKeybindArgumentsMatchExpected() { 85 - expectNoDifference( 86 - AppShortcuts.tabSelectionGhosttyKeybindArguments, 87 - [ 88 - "--keybind=ctrl+1=goto_tab:1", 89 - "--keybind=ctrl+digit_1=goto_tab:1", 90 - "--keybind=ctrl+2=goto_tab:2", 91 - "--keybind=ctrl+digit_2=goto_tab:2", 92 - "--keybind=ctrl+3=goto_tab:3", 93 - "--keybind=ctrl+digit_3=goto_tab:3", 94 - "--keybind=ctrl+4=goto_tab:4", 95 - "--keybind=ctrl+digit_4=goto_tab:4", 96 - "--keybind=ctrl+5=goto_tab:5", 97 - "--keybind=ctrl+digit_5=goto_tab:5", 98 - "--keybind=ctrl+6=goto_tab:6", 99 - "--keybind=ctrl+digit_6=goto_tab:6", 100 - "--keybind=ctrl+7=goto_tab:7", 101 - "--keybind=ctrl+digit_7=goto_tab:7", 102 - "--keybind=ctrl+8=goto_tab:8", 103 - "--keybind=ctrl+digit_8=goto_tab:8", 104 - "--keybind=ctrl+9=goto_tab:9", 105 - "--keybind=ctrl+digit_9=goto_tab:9", 106 - "--keybind=ctrl+0=goto_tab:10", 107 - "--keybind=ctrl+digit_0=goto_tab:10", 108 - ] 109 - ) 110 180 } 111 181 112 182 @Test func userOverrideConflictsDetectsReservedAppShortcuts() { ··· 148 218 149 219 for shortcut in AppShortcuts.worktreeSelection { 150 220 #expect(arguments.contains(shortcut.ghosttyUnbindArgument)) 221 + let tabIndex = shortcut.keyToken == "0" ? 10 : Int(shortcut.keyToken) ?? 0 222 + for argument in shortcut.ghosttyBindArguments(action: "goto_tab:\(tabIndex)") { 223 + #expect(arguments.contains(argument) == false) 224 + } 151 225 } 152 226 153 - for argument in AppShortcuts.tabSelectionGhosttyKeybindArguments { 154 - #expect(arguments.contains(argument)) 227 + for (index, shortcut) in AppShortcuts.terminalTabSelection.enumerated() { 228 + let tabIndex = index == 9 ? 10 : index + 1 229 + for argument in shortcut.ghosttyBindArguments(action: "goto_tab:\(tabIndex)") { 230 + #expect(arguments.contains(argument)) 231 + } 155 232 } 156 233 157 234 for argument in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].map({ "--keybind=ctrl+digit_\($0)=unbind" }) { ··· 161 238 for argument in [ 162 239 "--keybind=super+[=unbind", 163 240 "--keybind=super+]=unbind", 164 - "--keybind=super+shift+[=unbind", 165 - "--keybind=super+shift+]=unbind", 241 + "--keybind=shift+super+[=unbind", 242 + "--keybind=shift+super+]=unbind", 243 + ] { 244 + #expect(arguments.contains(argument)) 245 + } 246 + 247 + for argument in [ 166 248 "--keybind=super+d=unbind", 167 249 "--keybind=super+shift+d=unbind", 168 250 ] { 169 251 #expect(arguments.contains(argument) == false) 170 252 } 253 + } 254 + 255 + @Test func ghosttyCLIArgumentsIncludeTerminalNavigationBindings() { 256 + let arguments = AppShortcuts.ghosttyCLIKeybindArguments 257 + 258 + for argument in [ 259 + "--keybind=shift+super+[=previous_tab", 260 + "--keybind=shift+super+]=next_tab", 261 + "--keybind=super+[=goto_split:previous", 262 + "--keybind=super+]=goto_split:next", 263 + "--keybind=alt+super+arrow_up=goto_split:up", 264 + "--keybind=alt+super+arrow_down=goto_split:down", 265 + "--keybind=alt+super+arrow_left=goto_split:left", 266 + "--keybind=alt+super+arrow_right=goto_split:right", 267 + ] { 268 + #expect(arguments.contains(argument)) 269 + } 270 + } 271 + 272 + @Test func managedGhosttyActionOverrideRebindsAndUnbindsDefaults() { 273 + let overrides = KeybindingUserOverrideStore( 274 + overrides: [ 275 + AppShortcuts.CommandID.selectNextTerminalTab: KeybindingUserOverride( 276 + binding: Keybinding(key: "t", modifiers: .init(command: true, shift: true)) 277 + ), 278 + ] 279 + ) 280 + let resolved = KeybindingResolver.resolve( 281 + schema: .appResolverSchema(), 282 + userOverrides: overrides 283 + ) 284 + 285 + let arguments = AppShortcuts.ghosttyCLIKeybindArguments(from: resolved) 286 + #expect(arguments.contains("--keybind=shift+super+t=unbind")) 287 + #expect(arguments.contains("--keybind=shift+super+]=unbind")) 288 + #expect(arguments.contains("--keybind=shift+super+t=next_tab")) 289 + #expect(arguments.contains("--keybind=shift+super+]=next_tab") == false) 290 + } 291 + 292 + @Test func worktreeSelectionOverrideDoesNotAffectTerminalTabBindings() { 293 + let overrides = KeybindingUserOverrideStore( 294 + overrides: [ 295 + AppShortcuts.CommandID.selectWorktree1: KeybindingUserOverride( 296 + binding: Keybinding(key: "m", modifiers: .init(control: true)) 297 + ), 298 + ] 299 + ) 300 + let resolved = KeybindingResolver.resolve( 301 + schema: .appResolverSchema(), 302 + userOverrides: overrides 303 + ) 304 + 305 + let arguments = AppShortcuts.ghosttyCLIKeybindArguments(from: resolved) 306 + #expect(arguments.contains("--keybind=super+1=goto_tab:1")) 307 + #expect(arguments.contains("--keybind=super+digit_1=goto_tab:1")) 308 + #expect(arguments.contains("--keybind=ctrl+m=goto_tab:1") == false) 309 + } 310 + 311 + @Test func disabledManagedGhosttyActionKeepsDefaultUnboundWithoutBindingAction() { 312 + let overrides = KeybindingUserOverrideStore( 313 + overrides: [ 314 + AppShortcuts.CommandID.selectNextTerminalPane: KeybindingUserOverride( 315 + binding: Keybinding(key: "k", modifiers: .init(command: true)), 316 + isEnabled: false 317 + ), 318 + ] 319 + ) 320 + let resolved = KeybindingResolver.resolve( 321 + schema: .appResolverSchema(), 322 + userOverrides: overrides 323 + ) 324 + 325 + let arguments = AppShortcuts.ghosttyCLIKeybindArguments(from: resolved) 326 + #expect(arguments.contains("--keybind=super+]=unbind")) 327 + #expect(arguments.contains("--keybind=super+]=goto_split:next") == false) 328 + #expect(arguments.contains("--keybind=super+k=goto_split:next") == false) 329 + } 330 + 331 + @Test func resolverOverridePropagatesToMenuPaletteAndGhosttyArgs() { 332 + let overrides = KeybindingUserOverrideStore( 333 + overrides: [ 334 + AppShortcuts.CommandID.openSettings: KeybindingUserOverride( 335 + binding: Keybinding(key: ";", modifiers: .init(command: true)) 336 + ), 337 + ] 338 + ) 339 + let resolved = KeybindingResolver.resolve( 340 + schema: .appResolverSchema(), 341 + userOverrides: overrides 342 + ) 343 + 344 + expectNoDifference( 345 + AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.openSettings, in: resolved)?.display, 346 + "⌘;" 347 + ) 348 + 349 + let paletteItem = CommandPaletteItem( 350 + id: "settings", 351 + title: "Open Settings", 352 + subtitle: nil, 353 + kind: .openSettings 354 + ) 355 + expectNoDifference(paletteItem.appShortcutLabel(in: resolved), "⌘;") 356 + 357 + let arguments = AppShortcuts.ghosttyCLIKeybindArguments(from: resolved) 358 + #expect(arguments.contains("--keybind=super+;=unbind")) 359 + #expect(arguments.contains("--keybind=super+,=unbind") == false) 360 + } 361 + 362 + @Test func disabledOverrideRemovesShortcutFromMenuPaletteAndGhosttyArgs() { 363 + let overrides = KeybindingUserOverrideStore( 364 + overrides: [ 365 + AppShortcuts.CommandID.openSettings: KeybindingUserOverride( 366 + binding: Keybinding(key: ";", modifiers: .init(command: true)), 367 + isEnabled: false 368 + ), 369 + ] 370 + ) 371 + let resolved = KeybindingResolver.resolve( 372 + schema: .appResolverSchema(), 373 + userOverrides: overrides 374 + ) 375 + 376 + #expect(AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.openSettings, in: resolved) == nil) 377 + #expect(resolved.display(for: AppShortcuts.CommandID.openSettings) == nil) 378 + 379 + let paletteItem = CommandPaletteItem( 380 + id: "settings", 381 + title: "Open Settings", 382 + subtitle: nil, 383 + kind: .openSettings 384 + ) 385 + #expect(paletteItem.appShortcutLabel(in: resolved) == nil) 386 + 387 + let arguments = AppShortcuts.ghosttyCLIKeybindArguments(from: resolved) 388 + #expect(arguments.contains("--keybind=super+,=unbind") == false) 389 + #expect(arguments.contains("--keybind=super+;=unbind") == false) 390 + } 391 + 392 + @Test func resolvedShortcutFallsBackToDefaultWhenCommandMissingInResolvedMap() { 393 + let resolved = ResolvedKeybindingMap(bindingsByCommandID: [:]) 394 + 395 + expectNoDifference( 396 + AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.openSettings, in: resolved)?.display, 397 + AppShortcuts.openSettings.display 398 + ) 399 + } 400 + 401 + @Test func unsupportedResolvedBindingDoesNotFallbackToDefaultShortcut() { 402 + let overrides = KeybindingUserOverrideStore( 403 + overrides: [ 404 + AppShortcuts.CommandID.openSettings: KeybindingUserOverride( 405 + binding: Keybinding(key: "space", modifiers: .init(command: true)) 406 + ), 407 + ] 408 + ) 409 + let resolved = KeybindingResolver.resolve( 410 + schema: .appResolverSchema(), 411 + userOverrides: overrides 412 + ) 413 + 414 + #expect(AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.openSettings, in: resolved) == nil) 415 + 416 + let arguments = AppShortcuts.ghosttyCLIKeybindArguments(from: resolved) 417 + #expect(arguments.contains("--keybind=super+,=unbind") == false) 418 + } 419 + 420 + @Test func physicalDigitOverrideBehavesLikeNumberShortcut() { 421 + let overrides = KeybindingUserOverrideStore( 422 + overrides: [ 423 + AppShortcuts.CommandID.openSettings: KeybindingUserOverride( 424 + binding: Keybinding(key: "digit_1", modifiers: .init(command: true)) 425 + ), 426 + ] 427 + ) 428 + let resolved = KeybindingResolver.resolve( 429 + schema: .appResolverSchema(), 430 + userOverrides: overrides 431 + ) 432 + 433 + expectNoDifference( 434 + AppShortcuts.resolvedShortcut(for: AppShortcuts.CommandID.openSettings, in: resolved)?.display, 435 + "⌘1" 436 + ) 437 + 438 + let paletteItem = CommandPaletteItem( 439 + id: "settings", 440 + title: "Open Settings", 441 + subtitle: nil, 442 + kind: .openSettings 443 + ) 444 + expectNoDifference(paletteItem.appShortcutLabel(in: resolved), "⌘1") 445 + 446 + let arguments = AppShortcuts.ghosttyCLIKeybindArguments(from: resolved) 447 + #expect(arguments.contains("--keybind=super+1=unbind")) 448 + #expect(arguments.contains("--keybind=super+,=unbind") == false) 171 449 } 172 450 }
-2
supacodeTests/DetailToolbarTitleTests.swift
··· 21 21 22 22 #expect(title?.kind == .branch(name: "feature/title-bar")) 23 23 #expect(title?.systemImage == "arrow.trianglehead.branch") 24 - #expect(title?.helpText == "Rename branch (⌘⇧M)") 25 24 #expect(title?.supportsRename == true) 26 25 } 27 26 ··· 41 40 42 41 #expect(title?.kind == .folder(name: "folder")) 43 42 #expect(title?.systemImage == "folder") 44 - #expect(title?.helpText == nil) 45 43 #expect(title?.supportsRename == false) 46 44 } 47 45 }
+755
supacodeTests/KeybindingBehaviorMatrixTests.swift
··· 1 + import CustomDump 2 + import Foundation 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + // MARK: - Behavior matrix tests for the keybinding resolver pipeline. 8 + // Dimensions: scope × conflict-policy × state (default / override / disable / migrate / reset). 9 + 10 + struct KeybindingBehaviorMatrixTests { 11 + 12 + // MARK: - Defaults 13 + 14 + @Test func defaultsResolveToAppDefaultForAllScopes() { 15 + let schema = matrixSchema() 16 + let resolved = KeybindingResolver.resolve(schema: schema) 17 + 18 + for command in schema.commands { 19 + let result = resolved.binding(for: command.id) 20 + #expect(result?.binding == command.defaultBinding) 21 + #expect(result?.source == .appDefault) 22 + } 23 + } 24 + 25 + @Test func defaultsResolveToNilBindingWhenSchemaHasNoDefault() { 26 + let command = makeCommand( 27 + id: "cmd.no_default", 28 + scope: .customCommand, 29 + policy: .warnAndPreferUserOverride, 30 + allowOverride: true, 31 + defaultBinding: nil 32 + ) 33 + let schema = KeybindingSchemaDocument(commands: [command]) 34 + let resolved = KeybindingResolver.resolve(schema: schema) 35 + 36 + #expect(resolved.binding(for: "cmd.no_default")?.binding == nil) 37 + #expect(resolved.binding(for: "cmd.no_default")?.source == .appDefault) 38 + } 39 + 40 + // MARK: - Override enforcement by scope 41 + 42 + @Test func configurableAppActionAcceptsUserOverride() { 43 + let schema = KeybindingSchemaDocument(commands: [ 44 + makeCommand( 45 + id: "cmd.configurable", 46 + scope: .configurableAppAction, 47 + policy: .warnAndPreferUserOverride, 48 + allowOverride: true, 49 + defaultBinding: binding("a") 50 + ), 51 + ]) 52 + let overrides = overrideStore(["cmd.configurable": .init(binding: binding("z"))]) 53 + let resolved = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 54 + 55 + #expect(resolved.binding(for: "cmd.configurable")?.binding == binding("z")) 56 + #expect(resolved.binding(for: "cmd.configurable")?.source == .userOverride) 57 + } 58 + 59 + @Test func systemFixedAppActionIgnoresUserOverride() { 60 + let schema = KeybindingSchemaDocument(commands: [ 61 + makeCommand( 62 + id: "cmd.fixed", 63 + scope: .systemFixedAppAction, 64 + policy: .disallowUserOverride, 65 + allowOverride: false, 66 + defaultBinding: binding("q") 67 + ), 68 + ]) 69 + let overrides = overrideStore(["cmd.fixed": .init(binding: binding("z"))]) 70 + let resolved = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 71 + 72 + #expect(resolved.binding(for: "cmd.fixed")?.binding == binding("q")) 73 + #expect(resolved.binding(for: "cmd.fixed")?.source == .appDefault) 74 + } 75 + 76 + @Test func systemFixedAppActionIgnoresMigratedOverride() { 77 + let schema = KeybindingSchemaDocument(commands: [ 78 + makeCommand( 79 + id: "cmd.fixed", 80 + scope: .systemFixedAppAction, 81 + policy: .disallowUserOverride, 82 + allowOverride: false, 83 + defaultBinding: binding("q") 84 + ), 85 + ]) 86 + let migrated = ["cmd.fixed": KeybindingUserOverride(binding: binding("z"))] 87 + let resolved = KeybindingResolver.resolve(schema: schema, migratedOverrides: migrated) 88 + 89 + #expect(resolved.binding(for: "cmd.fixed")?.binding == binding("q")) 90 + #expect(resolved.binding(for: "cmd.fixed")?.source == .appDefault) 91 + } 92 + 93 + @Test func localInteractionAcceptsUserOverride() { 94 + let schema = KeybindingSchemaDocument(commands: [ 95 + makeCommand( 96 + id: "cmd.local", 97 + scope: .localInteraction, 98 + policy: .localOnly, 99 + allowOverride: true, 100 + defaultBinding: binding("r") 101 + ), 102 + ]) 103 + let overrides = overrideStore(["cmd.local": .init(binding: binding("t"))]) 104 + let resolved = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 105 + 106 + #expect(resolved.binding(for: "cmd.local")?.binding == binding("t")) 107 + #expect(resolved.binding(for: "cmd.local")?.source == .userOverride) 108 + } 109 + 110 + @Test func customCommandAcceptsUserOverride() { 111 + let schema = KeybindingSchemaDocument(commands: [ 112 + makeCommand( 113 + id: "custom_command.build", 114 + scope: .customCommand, 115 + policy: .warnAndPreferUserOverride, 116 + allowOverride: true, 117 + defaultBinding: nil 118 + ), 119 + ]) 120 + let overrides = overrideStore(["custom_command.build": .init(binding: binding("b", shift: true))]) 121 + let resolved = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 122 + 123 + #expect(resolved.binding(for: "custom_command.build")?.binding == binding("b", shift: true)) 124 + #expect(resolved.binding(for: "custom_command.build")?.source == .userOverride) 125 + } 126 + 127 + // MARK: - Disable behavior 128 + 129 + @Test func disableOverrideClearsBindingForConfigurableCommand() { 130 + let schema = KeybindingSchemaDocument(commands: [ 131 + makeCommand( 132 + id: "cmd.configurable", 133 + scope: .configurableAppAction, 134 + policy: .warnAndPreferUserOverride, 135 + allowOverride: true, 136 + defaultBinding: binding("a") 137 + ), 138 + ]) 139 + let overrides = overrideStore(["cmd.configurable": .init(binding: nil, isEnabled: false)]) 140 + let resolved = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 141 + 142 + #expect(resolved.binding(for: "cmd.configurable")?.binding == nil) 143 + #expect(resolved.binding(for: "cmd.configurable")?.source == .userOverride) 144 + } 145 + 146 + @Test func disableOverrideHasNoEffectOnFixedCommand() { 147 + let schema = KeybindingSchemaDocument(commands: [ 148 + makeCommand( 149 + id: "cmd.fixed", 150 + scope: .systemFixedAppAction, 151 + policy: .disallowUserOverride, 152 + allowOverride: false, 153 + defaultBinding: binding("q") 154 + ), 155 + ]) 156 + let overrides = overrideStore(["cmd.fixed": .init(binding: nil, isEnabled: false)]) 157 + let resolved = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 158 + 159 + #expect(resolved.binding(for: "cmd.fixed")?.binding == binding("q")) 160 + #expect(resolved.binding(for: "cmd.fixed")?.source == .appDefault) 161 + } 162 + 163 + @Test func disableOverrideClearsBindingForLocalInteraction() { 164 + let schema = KeybindingSchemaDocument(commands: [ 165 + makeCommand( 166 + id: "cmd.local", 167 + scope: .localInteraction, 168 + policy: .localOnly, 169 + allowOverride: true, 170 + defaultBinding: binding("r") 171 + ), 172 + ]) 173 + let overrides = overrideStore(["cmd.local": .init(binding: nil, isEnabled: false)]) 174 + let resolved = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 175 + 176 + #expect(resolved.binding(for: "cmd.local")?.binding == nil) 177 + #expect(resolved.binding(for: "cmd.local")?.source == .userOverride) 178 + } 179 + 180 + // MARK: - Migration precedence 181 + 182 + @Test func migratedOverrideAppliesWhenNoUserOverride() { 183 + let schema = KeybindingSchemaDocument(commands: [ 184 + makeCommand( 185 + id: "cmd.alpha", 186 + scope: .configurableAppAction, 187 + policy: .warnAndPreferUserOverride, 188 + allowOverride: true, 189 + defaultBinding: binding("a") 190 + ), 191 + ]) 192 + let migrated = ["cmd.alpha": KeybindingUserOverride(binding: binding("m"))] 193 + let resolved = KeybindingResolver.resolve(schema: schema, migratedOverrides: migrated) 194 + 195 + #expect(resolved.binding(for: "cmd.alpha")?.binding == binding("m")) 196 + #expect(resolved.binding(for: "cmd.alpha")?.source == .migratedLegacy) 197 + } 198 + 199 + @Test func userOverrideTakesPrecedenceOverMigratedOverride() { 200 + let schema = KeybindingSchemaDocument(commands: [ 201 + makeCommand( 202 + id: "cmd.alpha", 203 + scope: .configurableAppAction, 204 + policy: .warnAndPreferUserOverride, 205 + allowOverride: true, 206 + defaultBinding: binding("a") 207 + ), 208 + ]) 209 + let migrated = ["cmd.alpha": KeybindingUserOverride(binding: binding("m"))] 210 + let overrides = overrideStore(["cmd.alpha": .init(binding: binding("u"))]) 211 + let resolved = KeybindingResolver.resolve( 212 + schema: schema, 213 + userOverrides: overrides, 214 + migratedOverrides: migrated 215 + ) 216 + 217 + #expect(resolved.binding(for: "cmd.alpha")?.binding == binding("u")) 218 + #expect(resolved.binding(for: "cmd.alpha")?.source == .userOverride) 219 + } 220 + 221 + @Test func userDisableOverridesMigratedBinding() { 222 + let schema = KeybindingSchemaDocument(commands: [ 223 + makeCommand( 224 + id: "cmd.alpha", 225 + scope: .configurableAppAction, 226 + policy: .warnAndPreferUserOverride, 227 + allowOverride: true, 228 + defaultBinding: binding("a") 229 + ), 230 + ]) 231 + let migrated = ["cmd.alpha": KeybindingUserOverride(binding: binding("m"))] 232 + let overrides = overrideStore(["cmd.alpha": .init(binding: nil, isEnabled: false)]) 233 + let resolved = KeybindingResolver.resolve( 234 + schema: schema, 235 + userOverrides: overrides, 236 + migratedOverrides: migrated 237 + ) 238 + 239 + #expect(resolved.binding(for: "cmd.alpha")?.binding == nil) 240 + #expect(resolved.binding(for: "cmd.alpha")?.source == .userOverride) 241 + } 242 + 243 + @Test func migratedOverrideDoesNotApplyToFixedScope() { 244 + let schema = KeybindingSchemaDocument(commands: [ 245 + makeCommand( 246 + id: "cmd.fixed", 247 + scope: .systemFixedAppAction, 248 + policy: .disallowUserOverride, 249 + allowOverride: false, 250 + defaultBinding: binding("q") 251 + ), 252 + ]) 253 + let migrated = ["cmd.fixed": KeybindingUserOverride(binding: binding("m"))] 254 + let resolved = KeybindingResolver.resolve(schema: schema, migratedOverrides: migrated) 255 + 256 + #expect(resolved.binding(for: "cmd.fixed")?.binding == binding("q")) 257 + #expect(resolved.binding(for: "cmd.fixed")?.source == .appDefault) 258 + } 259 + 260 + @Test func migratedSourcePreservedWhenIdenticalToDefault() { 261 + let schema = KeybindingSchemaDocument(commands: [ 262 + makeCommand( 263 + id: "cmd.alpha", 264 + scope: .configurableAppAction, 265 + policy: .warnAndPreferUserOverride, 266 + allowOverride: true, 267 + defaultBinding: binding("a") 268 + ), 269 + ]) 270 + // Migrated binding is identical to default — source stays appDefault because didChange is false 271 + let migrated = ["cmd.alpha": KeybindingUserOverride(binding: binding("a"))] 272 + let resolved = KeybindingResolver.resolve(schema: schema, migratedOverrides: migrated) 273 + 274 + #expect(resolved.binding(for: "cmd.alpha")?.binding == binding("a")) 275 + #expect(resolved.binding(for: "cmd.alpha")?.source == .appDefault) 276 + } 277 + 278 + // MARK: - Conflict detection across policies 279 + 280 + @Test func warnPolicyDetectsConflictWithExistingBinding() { 281 + let schema = KeybindingSchemaDocument(commands: [ 282 + makeCommand( 283 + id: "cmd.one", 284 + scope: .configurableAppAction, 285 + policy: .warnAndPreferUserOverride, 286 + allowOverride: true, 287 + defaultBinding: binding("a") 288 + ), 289 + makeCommand( 290 + id: "cmd.two", 291 + scope: .configurableAppAction, 292 + policy: .warnAndPreferUserOverride, 293 + allowOverride: true, 294 + defaultBinding: binding("b") 295 + ), 296 + ]) 297 + 298 + let conflict = ShortcutConflictDetector.firstConflictCommandID( 299 + commandID: "cmd.two", 300 + binding: binding("a"), 301 + policy: .warnAndPreferUserOverride, 302 + schema: schema, 303 + userOverrides: .empty 304 + ) 305 + 306 + #expect(conflict == "cmd.one") 307 + } 308 + 309 + @Test func disallowPolicySkipsConflictDetection() { 310 + let schema = KeybindingSchemaDocument(commands: [ 311 + makeCommand( 312 + id: "cmd.one", 313 + scope: .configurableAppAction, 314 + policy: .warnAndPreferUserOverride, 315 + allowOverride: true, 316 + defaultBinding: binding("a") 317 + ), 318 + makeCommand( 319 + id: "cmd.fixed", 320 + scope: .systemFixedAppAction, 321 + policy: .disallowUserOverride, 322 + allowOverride: false, 323 + defaultBinding: binding("x") 324 + ), 325 + ]) 326 + 327 + let conflict = ShortcutConflictDetector.firstConflictCommandID( 328 + commandID: "cmd.fixed", 329 + binding: binding("a"), 330 + policy: .disallowUserOverride, 331 + schema: schema, 332 + userOverrides: .empty 333 + ) 334 + 335 + #expect(conflict == nil) 336 + } 337 + 338 + @Test func localOnlyPolicyDetectsConflictWithAppAction() { 339 + let schema = KeybindingSchemaDocument(commands: [ 340 + makeCommand( 341 + id: "cmd.global", 342 + scope: .configurableAppAction, 343 + policy: .warnAndPreferUserOverride, 344 + allowOverride: true, 345 + defaultBinding: binding("a") 346 + ), 347 + makeCommand( 348 + id: "cmd.local", 349 + scope: .localInteraction, 350 + policy: .localOnly, 351 + allowOverride: true, 352 + defaultBinding: binding("b") 353 + ), 354 + ]) 355 + 356 + let conflict = ShortcutConflictDetector.firstConflictCommandID( 357 + commandID: "cmd.local", 358 + binding: binding("a"), 359 + policy: .localOnly, 360 + schema: schema, 361 + userOverrides: .empty 362 + ) 363 + 364 + #expect(conflict == "cmd.global") 365 + } 366 + 367 + @Test func noConflictWhenBindingsAreDifferent() { 368 + let schema = KeybindingSchemaDocument(commands: [ 369 + makeCommand( 370 + id: "cmd.one", 371 + scope: .configurableAppAction, 372 + policy: .warnAndPreferUserOverride, 373 + allowOverride: true, 374 + defaultBinding: binding("a") 375 + ), 376 + makeCommand( 377 + id: "cmd.two", 378 + scope: .configurableAppAction, 379 + policy: .warnAndPreferUserOverride, 380 + allowOverride: true, 381 + defaultBinding: binding("b") 382 + ), 383 + ]) 384 + 385 + let conflict = ShortcutConflictDetector.firstConflictCommandID( 386 + commandID: "cmd.two", 387 + binding: binding("c"), 388 + policy: .warnAndPreferUserOverride, 389 + schema: schema, 390 + userOverrides: .empty 391 + ) 392 + 393 + #expect(conflict == nil) 394 + } 395 + 396 + @Test func conflictDetectionConsidersUserOverridesNotJustDefaults() { 397 + let schema = KeybindingSchemaDocument(commands: [ 398 + makeCommand( 399 + id: "cmd.one", 400 + scope: .configurableAppAction, 401 + policy: .warnAndPreferUserOverride, 402 + allowOverride: true, 403 + defaultBinding: binding("a") 404 + ), 405 + makeCommand( 406 + id: "cmd.two", 407 + scope: .configurableAppAction, 408 + policy: .warnAndPreferUserOverride, 409 + allowOverride: true, 410 + defaultBinding: binding("b") 411 + ), 412 + ]) 413 + // cmd.one was overridden to "z" — so assigning "z" to cmd.two should conflict 414 + let overrides = overrideStore(["cmd.one": .init(binding: binding("z"))]) 415 + 416 + let conflict = ShortcutConflictDetector.firstConflictCommandID( 417 + commandID: "cmd.two", 418 + binding: binding("z"), 419 + policy: .warnAndPreferUserOverride, 420 + schema: schema, 421 + userOverrides: overrides 422 + ) 423 + 424 + #expect(conflict == "cmd.one") 425 + } 426 + 427 + @Test func noConflictWithDisabledCommand() { 428 + let schema = KeybindingSchemaDocument(commands: [ 429 + makeCommand( 430 + id: "cmd.one", 431 + scope: .configurableAppAction, 432 + policy: .warnAndPreferUserOverride, 433 + allowOverride: true, 434 + defaultBinding: binding("a") 435 + ), 436 + makeCommand( 437 + id: "cmd.two", 438 + scope: .configurableAppAction, 439 + policy: .warnAndPreferUserOverride, 440 + allowOverride: true, 441 + defaultBinding: binding("b") 442 + ), 443 + ]) 444 + // cmd.one was disabled — its binding is gone, so "a" is now free 445 + let overrides = overrideStore(["cmd.one": .init(binding: nil, isEnabled: false)]) 446 + 447 + let conflict = ShortcutConflictDetector.firstConflictCommandID( 448 + commandID: "cmd.two", 449 + binding: binding("a"), 450 + policy: .warnAndPreferUserOverride, 451 + schema: schema, 452 + userOverrides: overrides 453 + ) 454 + 455 + #expect(conflict == nil) 456 + } 457 + 458 + // MARK: - Reset behavior 459 + 460 + @Test func resetSingleCommandRestoresDefault() { 461 + let schema = KeybindingSchemaDocument(commands: [ 462 + makeCommand( 463 + id: "cmd.one", 464 + scope: .configurableAppAction, 465 + policy: .warnAndPreferUserOverride, 466 + allowOverride: true, 467 + defaultBinding: binding("a") 468 + ), 469 + ]) 470 + let overrides = overrideStore(["cmd.one": .init(binding: binding("z"))]) 471 + 472 + let plan = ShortcutResetPlanner.makePlan( 473 + commandID: "cmd.one", 474 + schema: schema, 475 + userOverrides: overrides 476 + ) 477 + 478 + #expect(plan.commandIDsToReset == ["cmd.one"]) 479 + #expect(plan.conflictingCommandIDs.isEmpty) 480 + #expect(plan.restoredBinding == binding("a")) 481 + 482 + let resolved = resolvedAfterReset(plan: plan, overrides: overrides, schema: schema) 483 + #expect(resolved.binding(for: "cmd.one")?.binding == binding("a")) 484 + #expect(resolved.binding(for: "cmd.one")?.source == .appDefault) 485 + } 486 + 487 + @Test func resetCascadesWhenRestoredDefaultConflicts() { 488 + // cmd.one default=a, overridden to b 489 + // cmd.two default=b, disabled (because cmd.one took b) 490 + // Resetting cmd.two restores b, which conflicts with cmd.one's override → cascade 491 + let schema = KeybindingSchemaDocument(commands: [ 492 + makeCommand( 493 + id: "cmd.one", 494 + scope: .configurableAppAction, 495 + policy: .warnAndPreferUserOverride, 496 + allowOverride: true, 497 + defaultBinding: binding("a") 498 + ), 499 + makeCommand( 500 + id: "cmd.two", 501 + scope: .configurableAppAction, 502 + policy: .warnAndPreferUserOverride, 503 + allowOverride: true, 504 + defaultBinding: binding("b") 505 + ), 506 + ]) 507 + let overrides = overrideStore([ 508 + "cmd.one": .init(binding: binding("b")), 509 + "cmd.two": .init(binding: nil, isEnabled: false), 510 + ]) 511 + 512 + let plan = ShortcutResetPlanner.makePlan( 513 + commandID: "cmd.two", 514 + schema: schema, 515 + userOverrides: overrides 516 + ) 517 + 518 + #expect(plan.commandIDsToReset.contains("cmd.one")) 519 + #expect(plan.commandIDsToReset.contains("cmd.two")) 520 + #expect(plan.conflictingCommandIDs == ["cmd.one"]) 521 + 522 + let resolved = resolvedAfterReset(plan: plan, overrides: overrides, schema: schema) 523 + #expect(resolved.binding(for: "cmd.one")?.binding == binding("a")) 524 + #expect(resolved.binding(for: "cmd.two")?.binding == binding("b")) 525 + } 526 + 527 + @Test func resetAllCommandsInSectionCascadesOutside() { 528 + // Section contains cmd.two and cmd.three 529 + // cmd.one (outside section) overrides to cmd.two's default 530 + let schema = KeybindingSchemaDocument(commands: [ 531 + makeCommand( 532 + id: "cmd.one", 533 + scope: .configurableAppAction, 534 + policy: .warnAndPreferUserOverride, 535 + allowOverride: true, 536 + defaultBinding: binding("a") 537 + ), 538 + makeCommand( 539 + id: "cmd.two", 540 + scope: .configurableAppAction, 541 + policy: .warnAndPreferUserOverride, 542 + allowOverride: true, 543 + defaultBinding: binding("b") 544 + ), 545 + makeCommand( 546 + id: "cmd.three", 547 + scope: .configurableAppAction, 548 + policy: .warnAndPreferUserOverride, 549 + allowOverride: true, 550 + defaultBinding: binding("c") 551 + ), 552 + ]) 553 + let overrides = overrideStore([ 554 + "cmd.one": .init(binding: binding("b")), 555 + "cmd.two": .init(binding: nil, isEnabled: false), 556 + "cmd.three": .init(binding: binding("x")), 557 + ]) 558 + 559 + let plan = ShortcutResetPlanner.makePlan( 560 + commandIDs: ["cmd.two", "cmd.three"], 561 + schema: schema, 562 + userOverrides: overrides 563 + ) 564 + 565 + #expect(plan.commandIDsToReset.contains("cmd.one")) 566 + #expect(plan.conflictingCommandIDs == ["cmd.one"]) 567 + 568 + let resolved = resolvedAfterReset(plan: plan, overrides: overrides, schema: schema) 569 + #expect(resolved.binding(for: "cmd.one")?.binding == binding("a")) 570 + #expect(resolved.binding(for: "cmd.two")?.binding == binding("b")) 571 + #expect(resolved.binding(for: "cmd.three")?.binding == binding("c")) 572 + } 573 + 574 + // MARK: - Persistence round-trip 575 + 576 + @Test func userOverrideStoreEncodeDecodeRoundTrip() throws { 577 + let store = KeybindingUserOverrideStore( 578 + version: 1, 579 + overrides: [ 580 + "cmd.alpha": KeybindingUserOverride(binding: binding("x"), isEnabled: true), 581 + "cmd.beta": KeybindingUserOverride(binding: nil, isEnabled: false), 582 + "cmd.gamma": KeybindingUserOverride( 583 + binding: Keybinding( 584 + key: "arrow_up", 585 + modifiers: KeybindingModifiers(command: true, shift: true) 586 + ) 587 + ), 588 + ] 589 + ) 590 + 591 + let data = try JSONEncoder().encode(store) 592 + let decoded = try JSONDecoder().decode(KeybindingUserOverrideStore.self, from: data) 593 + 594 + expectNoDifference(decoded, store) 595 + } 596 + 597 + @Test func resolvedStateIsIdenticalAfterPersistenceRoundTrip() throws { 598 + let schema = matrixSchema() 599 + let overrides = KeybindingUserOverrideStore( 600 + version: 1, 601 + overrides: [ 602 + "cmd.configurable": KeybindingUserOverride(binding: binding("z")), 603 + "cmd.local": KeybindingUserOverride(binding: nil, isEnabled: false), 604 + ] 605 + ) 606 + 607 + let resolvedBefore = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 608 + 609 + let data = try JSONEncoder().encode(overrides) 610 + let restored = try JSONDecoder().decode(KeybindingUserOverrideStore.self, from: data) 611 + let resolvedAfter = KeybindingResolver.resolve(schema: schema, userOverrides: restored) 612 + 613 + expectNoDifference(resolvedAfter, resolvedBefore) 614 + } 615 + 616 + // MARK: - Edge cases 617 + 618 + @Test func overrideForUnknownCommandIDIsIgnored() { 619 + let schema = KeybindingSchemaDocument(commands: [ 620 + makeCommand( 621 + id: "cmd.one", 622 + scope: .configurableAppAction, 623 + policy: .warnAndPreferUserOverride, 624 + allowOverride: true, 625 + defaultBinding: binding("a") 626 + ), 627 + ]) 628 + let overrides = overrideStore(["cmd.nonexistent": .init(binding: binding("z"))]) 629 + let resolved = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 630 + 631 + #expect(resolved.binding(for: "cmd.one")?.binding == binding("a")) 632 + #expect(resolved.binding(for: "cmd.one")?.source == .appDefault) 633 + #expect(resolved.binding(for: "cmd.nonexistent") == nil) 634 + } 635 + 636 + @Test func enabledOverrideWithNilBindingPreservesDefault() { 637 + let schema = KeybindingSchemaDocument(commands: [ 638 + makeCommand( 639 + id: "cmd.one", 640 + scope: .configurableAppAction, 641 + policy: .warnAndPreferUserOverride, 642 + allowOverride: true, 643 + defaultBinding: binding("a") 644 + ), 645 + ]) 646 + // isEnabled=true but binding=nil → no change, preserves default 647 + let overrides = overrideStore(["cmd.one": .init(binding: nil, isEnabled: true)]) 648 + let resolved = KeybindingResolver.resolve(schema: schema, userOverrides: overrides) 649 + 650 + #expect(resolved.binding(for: "cmd.one")?.binding == binding("a")) 651 + #expect(resolved.binding(for: "cmd.one")?.source == .appDefault) 652 + } 653 + 654 + @Test func multipleCommandsWithSameDefaultBindingResolveIndependently() { 655 + let schema = KeybindingSchemaDocument(commands: [ 656 + makeCommand( 657 + id: "cmd.one", 658 + scope: .configurableAppAction, 659 + policy: .warnAndPreferUserOverride, 660 + allowOverride: true, 661 + defaultBinding: binding("a") 662 + ), 663 + makeCommand( 664 + id: "cmd.two", 665 + scope: .localInteraction, 666 + policy: .localOnly, 667 + allowOverride: true, 668 + defaultBinding: binding("a") 669 + ), 670 + ]) 671 + let resolved = KeybindingResolver.resolve(schema: schema) 672 + 673 + #expect(resolved.binding(for: "cmd.one")?.binding == binding("a")) 674 + #expect(resolved.binding(for: "cmd.two")?.binding == binding("a")) 675 + #expect(resolved.binding(for: "cmd.one")?.source == .appDefault) 676 + #expect(resolved.binding(for: "cmd.two")?.source == .appDefault) 677 + } 678 + } 679 + 680 + // MARK: - Helpers 681 + 682 + extension KeybindingBehaviorMatrixTests { 683 + 684 + /// A schema covering all four scopes with representative conflict policies. 685 + private func matrixSchema() -> KeybindingSchemaDocument { 686 + KeybindingSchemaDocument(commands: [ 687 + makeCommand( 688 + id: "cmd.configurable", 689 + scope: .configurableAppAction, 690 + policy: .warnAndPreferUserOverride, 691 + allowOverride: true, 692 + defaultBinding: binding("a") 693 + ), 694 + makeCommand( 695 + id: "cmd.fixed", 696 + scope: .systemFixedAppAction, 697 + policy: .disallowUserOverride, 698 + allowOverride: false, 699 + defaultBinding: binding("q") 700 + ), 701 + makeCommand( 702 + id: "cmd.local", 703 + scope: .localInteraction, 704 + policy: .localOnly, 705 + allowOverride: true, 706 + defaultBinding: binding("r") 707 + ), 708 + makeCommand( 709 + id: "custom_command.build", 710 + scope: .customCommand, 711 + policy: .warnAndPreferUserOverride, 712 + allowOverride: true, 713 + defaultBinding: nil 714 + ), 715 + ]) 716 + } 717 + 718 + private func makeCommand( 719 + id: String, 720 + scope: KeybindingScope, 721 + policy: KeybindingConflictPolicy, 722 + allowOverride: Bool, 723 + defaultBinding: Keybinding? 724 + ) -> KeybindingCommandSchema { 725 + KeybindingCommandSchema( 726 + id: id, 727 + title: id, 728 + scope: scope, 729 + platform: .macOS, 730 + allowUserOverride: allowOverride, 731 + conflictPolicy: policy, 732 + defaultBinding: defaultBinding 733 + ) 734 + } 735 + 736 + private func binding(_ key: String, shift: Bool = false) -> Keybinding { 737 + Keybinding(key: key, modifiers: KeybindingModifiers(command: true, shift: shift)) 738 + } 739 + 740 + private func overrideStore(_ overrides: [String: KeybindingUserOverride]) -> KeybindingUserOverrideStore { 741 + KeybindingUserOverrideStore(overrides: overrides) 742 + } 743 + 744 + private func resolvedAfterReset( 745 + plan: ShortcutResetPlan, 746 + overrides: KeybindingUserOverrideStore, 747 + schema: KeybindingSchemaDocument 748 + ) -> ResolvedKeybindingMap { 749 + var updated = overrides 750 + for commandID in plan.commandIDsToReset { 751 + updated.overrides.removeValue(forKey: commandID) 752 + } 753 + return KeybindingResolver.resolve(schema: schema, userOverrides: updated) 754 + } 755 + }
+174 -74
supacodeTests/KeybindingSchemaTests.swift
··· 43 43 #expect(commandIDs.contains("select_all_canvas_cards")) 44 44 45 45 let commandPalette = schema.commands.first(where: { $0.id == "command_palette" }) 46 - #expect(commandPalette?.allowUserOverride == false) 47 - #expect(commandPalette?.conflictPolicy == .disallowUserOverride) 46 + let renameBranch = schema.commands.first(where: { $0.id == "rename_branch" }) 47 + let selectAllCanvasCards = schema.commands.first(where: { $0.id == "select_all_canvas_cards" }) 48 + #expect(commandPalette?.allowUserOverride == true) 49 + #expect(commandPalette?.conflictPolicy == .warnAndPreferUserOverride) 50 + #expect(renameBranch?.allowUserOverride == true) 51 + #expect(renameBranch?.conflictPolicy == .localOnly) 52 + #expect(selectAllCanvasCards?.allowUserOverride == true) 53 + #expect(selectAllCanvasCards?.conflictPolicy == .localOnly) 48 54 } 49 55 50 56 @Test func resolverAppliesUserOverrideOverMigratedOverride() { ··· 125 131 #expect(resolved.binding(for: "command.gamma")?.source == .userOverride) 126 132 } 127 133 134 + @Test func physicalDigitBindingsResolveToNumberShortcuts() { 135 + let binding = Keybinding( 136 + key: "digit_1", 137 + modifiers: KeybindingModifiers(command: true, control: true) 138 + ) 139 + 140 + expectNoDifference(binding.display, "⌘⌃1") 141 + expectNoDifference(binding.keyboardShortcut?.display, "⌘⌃1") 142 + #expect(binding.userCustomShortcut?.key == "1") 143 + } 144 + 145 + @Test func resolverDisableOverrideUnassignsConflictingCommand() { 146 + let conflictBinding = Keybinding( 147 + key: "w", 148 + modifiers: KeybindingModifiers(command: true) 149 + ) 150 + let schema = KeybindingSchemaDocument( 151 + version: 1, 152 + commands: [ 153 + KeybindingCommandSchema( 154 + id: "command.first", 155 + title: "First", 156 + scope: .configurableAppAction, 157 + platform: .macOS, 158 + allowUserOverride: true, 159 + conflictPolicy: .warnAndPreferUserOverride, 160 + defaultBinding: Keybinding( 161 + key: "f", 162 + modifiers: KeybindingModifiers(command: true) 163 + ) 164 + ), 165 + KeybindingCommandSchema( 166 + id: "command.second", 167 + title: "Second", 168 + scope: .configurableAppAction, 169 + platform: .macOS, 170 + allowUserOverride: true, 171 + conflictPolicy: .warnAndPreferUserOverride, 172 + defaultBinding: conflictBinding 173 + ), 174 + ] 175 + ) 176 + let overrides = KeybindingUserOverrideStore( 177 + overrides: [ 178 + "command.first": KeybindingUserOverride(binding: conflictBinding), 179 + "command.second": KeybindingUserOverride(binding: nil, isEnabled: false), 180 + ] 181 + ) 182 + 183 + let resolved = KeybindingResolver.resolve( 184 + schema: schema, 185 + userOverrides: overrides 186 + ) 187 + 188 + #expect(resolved.binding(for: "command.first")?.binding == conflictBinding) 189 + #expect(resolved.binding(for: "command.first")?.source == .userOverride) 190 + #expect(resolved.binding(for: "command.second")?.binding == nil) 191 + #expect(resolved.binding(for: "command.second")?.source == .userOverride) 192 + } 193 + 194 + @Test func resolverNilEnabledOverrideDoesNotChangeDefaultBinding() { 195 + let defaultBinding = Keybinding( 196 + key: "n", 197 + modifiers: KeybindingModifiers(command: true, shift: true) 198 + ) 199 + let schema = KeybindingSchemaDocument( 200 + version: 1, 201 + commands: [ 202 + KeybindingCommandSchema( 203 + id: "command.nil-enabled", 204 + title: "Nil Enabled", 205 + scope: .configurableAppAction, 206 + platform: .macOS, 207 + allowUserOverride: true, 208 + conflictPolicy: .warnAndPreferUserOverride, 209 + defaultBinding: defaultBinding 210 + ), 211 + ] 212 + ) 213 + let overrides = KeybindingUserOverrideStore( 214 + overrides: [ 215 + "command.nil-enabled": KeybindingUserOverride(binding: nil, isEnabled: true) 216 + ] 217 + ) 218 + 219 + let resolved = KeybindingResolver.resolve( 220 + schema: schema, 221 + userOverrides: overrides 222 + ) 223 + 224 + #expect(resolved.binding(for: "command.nil-enabled")?.binding == defaultBinding) 225 + #expect(resolved.binding(for: "command.nil-enabled")?.source == .appDefault) 226 + } 227 + 128 228 @Test func migrationMigratesLegacyCustomShortcutsAndCollectsUnmappedIssues() throws { 129 229 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 230 + { 231 + "customCommands": [ 232 + { 233 + "id": "build", 234 + "title": "Build", 235 + "systemImage": "hammer", 236 + "command": "swift build", 237 + "execution": "shellScript", 238 + "shortcut": { 239 + "key": " B ", 240 + "modifiers": { 241 + "command": true, 242 + "shift": true, 243 + "option": false, 244 + "control": false 245 + } 145 246 } 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 247 + }, 248 + { 249 + "id": "deploy", 250 + "title": "Deploy", 251 + "systemImage": "rocket", 252 + "command": "make release", 253 + "execution": "shellScript", 254 + "shortcut": { 255 + "key": "d", 256 + "modifiers": { 257 + "command": true, 258 + "shift": false, 259 + "option": false, 260 + "control": false 261 + } 161 262 } 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 263 + }, 264 + { 265 + "id": "bad-shortcut", 266 + "title": "Bad", 267 + "systemImage": "xmark", 268 + "command": "echo bad", 269 + "execution": "shellScript", 270 + "shortcut": { 271 + "key": "two", 272 + "modifiers": { 273 + "command": true, 274 + "shift": false, 275 + "option": false, 276 + "control": false 277 + } 177 278 } 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 279 + }, 280 + { 281 + "id": "", 282 + "title": "No ID", 283 + "systemImage": "questionmark", 284 + "command": "echo noid", 285 + "execution": "shellScript", 286 + "shortcut": { 287 + "key": "n", 288 + "modifiers": { 289 + "command": true, 290 + "shift": false, 291 + "option": false, 292 + "control": false 293 + } 193 294 } 295 + }, 296 + { 297 + "id": "without-shortcut", 298 + "title": "No Shortcut", 299 + "systemImage": "ellipsis", 300 + "command": "echo none", 301 + "execution": "shellScript", 302 + "shortcut": null 194 303 } 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 - """# 304 + ] 305 + } 306 + """# 207 307 208 308 let legacySettings = try JSONDecoder().decode( 209 309 LegacyCustomCommandShortcutFixture.self,
+80
supacodeTests/RepositorySettingsFeatureTests.swift
··· 148 148 let decoded = try JSONDecoder().decode(UserRepositorySettings.self, from: savedData) 149 149 #expect(decoded.customCommands.first?.shortcut == conflicted.customCommands.first?.shortcut) 150 150 } 151 + 152 + @Test(.dependencies) func taskLoadsLatestUserSettingsAfterAsyncGitProbe() async throws { 153 + let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") 154 + let settingsStorage = SettingsTestStorage() 155 + let localStorage = RepositoryLocalSettingsTestStorage() 156 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 157 + let gitProbeGate = LockIsolated<CheckedContinuation<Void, Never>?>(nil) 158 + 159 + let initialUserSettings = UserRepositorySettings( 160 + customCommands: [.default(index: 0)] 161 + ) 162 + let updatedUserSettings = UserRepositorySettings( 163 + customCommands: [ 164 + UserCustomCommand( 165 + title: "Updated", 166 + systemImage: "terminal", 167 + command: "echo updated", 168 + execution: .shellScript, 169 + shortcut: nil 170 + ), 171 + ] 172 + ) 173 + 174 + let initialData = try #require(try? JSONEncoder().encode(initialUserSettings)) 175 + try #require( 176 + try? localStorage.save( 177 + initialData, 178 + at: SupacodePaths.userRepositorySettingsURL(for: rootURL) 179 + ) 180 + ) 181 + 182 + let store = TestStore( 183 + initialState: RepositorySettingsFeature.State( 184 + rootURL: rootURL, 185 + repositoryKind: .git, 186 + settings: .default, 187 + userSettings: .default 188 + ) 189 + ) { 190 + RepositorySettingsFeature() 191 + } withDependencies: { 192 + $0.settingsFileStorage = settingsStorage.storage 193 + $0.settingsFileURL = settingsFileURL 194 + $0.repositoryLocalSettingsStorage = localStorage.storage 195 + $0.gitClient.isBareRepository = { _ in 196 + await withCheckedContinuation { continuation in 197 + gitProbeGate.setValue(continuation) 198 + } 199 + return false 200 + } 201 + $0.gitClient.branchRefs = { _ in [] } 202 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 203 + } 204 + 205 + await store.send(.task) 206 + 207 + for _ in 0..<50 { 208 + if gitProbeGate.value != nil { 209 + break 210 + } 211 + await Task.yield() 212 + } 213 + #expect(gitProbeGate.value != nil) 214 + 215 + await store.send(.binding(.set(\.userSettings, updatedUserSettings))) { 216 + $0.userSettings = updatedUserSettings 217 + } 218 + await store.receive(\.delegate.settingsChanged) 219 + 220 + let continuation = try #require(gitProbeGate.value) 221 + continuation.resume() 222 + 223 + await store.receive(\.settingsLoaded, timeout: .seconds(5)) 224 + await store.receive(\.branchDataLoaded) { 225 + $0.defaultWorktreeBaseRef = "origin/main" 226 + $0.branchOptions = ["origin/main"] 227 + $0.isBranchDataLoaded = true 228 + } 229 + #expect(store.state.userSettings == updatedUserSettings) 230 + } 151 231 }
+26
supacodeTests/SettingsFeatureTests.swift
··· 297 297 #expect(capturedEvents.value.isEmpty) 298 298 } 299 299 300 + @Test(.dependencies) func keybindingOverridesPersistAndFanOut() async { 301 + var initialSettings = GlobalSettings.default 302 + initialSettings.keybindingUserOverrides = .empty 303 + @Shared(.settingsFile) var settingsFile 304 + $settingsFile.withLock { $0.global = initialSettings } 305 + 306 + let overrides = KeybindingUserOverrideStore( 307 + overrides: [ 308 + AppShortcuts.CommandID.openSettings: KeybindingUserOverride( 309 + binding: Keybinding(key: ";", modifiers: .init(command: true)) 310 + ), 311 + ] 312 + ) 313 + 314 + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { 315 + SettingsFeature() 316 + } 317 + 318 + await store.send(.binding(.set(\.keybindingUserOverrides, overrides))) { 319 + $0.keybindingUserOverrides = overrides 320 + } 321 + await store.receive(\.delegate.settingsChanged) 322 + 323 + #expect(settingsFile.global.keybindingUserOverrides == overrides) 324 + } 325 + 300 326 @Test(.dependencies) func clearTerminalLayoutSnapshotSendsDelegate() async { 301 327 let store = TestStore(initialState: SettingsFeature.State()) { 302 328 SettingsFeature()
+124
supacodeTests/ShortcutConflictDetectorTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + 5 + struct ShortcutConflictDetectorTests { 6 + @Test func localOnlyPolicyWarnsWhenBindingConflictsWithAppAction() { 7 + let globalID = "command.global" 8 + let localID = "command.local" 9 + let conflictBinding = binding("r", modifiers: .init(command: true, shift: true)) 10 + 11 + let schema = testSchema([ 12 + testCommand( 13 + id: globalID, 14 + title: "Refresh Worktrees", 15 + conflictPolicy: .warnAndPreferUserOverride, 16 + defaultBinding: conflictBinding 17 + ), 18 + testCommand( 19 + id: localID, 20 + title: "Rename Branch", 21 + conflictPolicy: .localOnly, 22 + defaultBinding: binding("m", modifiers: .init(command: true, shift: true)) 23 + ), 24 + ]) 25 + 26 + let conflictID = ShortcutConflictDetector.firstConflictCommandID( 27 + commandID: localID, 28 + binding: conflictBinding, 29 + policy: .localOnly, 30 + schema: schema, 31 + userOverrides: .empty 32 + ) 33 + 34 + #expect(conflictID == globalID) 35 + } 36 + 37 + @Test func disallowPolicyDoesNotWarn() { 38 + let schema = testSchema([ 39 + testCommand( 40 + id: "command.fixed", 41 + title: "Fixed", 42 + conflictPolicy: .disallowUserOverride, 43 + defaultBinding: binding("a", modifiers: .init(command: true)) 44 + ), 45 + testCommand( 46 + id: "command.other", 47 + title: "Other", 48 + conflictPolicy: .warnAndPreferUserOverride, 49 + defaultBinding: binding("a", modifiers: .init(command: true)) 50 + ), 51 + ]) 52 + 53 + let conflictID = ShortcutConflictDetector.firstConflictCommandID( 54 + commandID: "command.fixed", 55 + binding: binding("a", modifiers: .init(command: true)), 56 + policy: .disallowUserOverride, 57 + schema: schema, 58 + userOverrides: .empty 59 + ) 60 + 61 + #expect(conflictID == nil) 62 + } 63 + 64 + @Test func returnsNilWhenNoConflict() { 65 + let schema = testSchema([ 66 + testCommand( 67 + id: "command.one", 68 + title: "One", 69 + conflictPolicy: .warnAndPreferUserOverride, 70 + defaultBinding: binding("a", modifiers: .init(command: true)) 71 + ), 72 + testCommand( 73 + id: "command.two", 74 + title: "Two", 75 + conflictPolicy: .localOnly, 76 + defaultBinding: binding("b", modifiers: .init(command: true)) 77 + ), 78 + ]) 79 + 80 + let conflictID = ShortcutConflictDetector.firstConflictCommandID( 81 + commandID: "command.two", 82 + binding: binding("c", modifiers: .init(command: true)), 83 + policy: .localOnly, 84 + schema: schema, 85 + userOverrides: .empty 86 + ) 87 + 88 + #expect(conflictID == nil) 89 + } 90 + 91 + private func testSchema(_ commands: [KeybindingCommandSchema]) -> KeybindingSchemaDocument { 92 + KeybindingSchemaDocument( 93 + version: KeybindingSchemaDocument.currentVersion, 94 + commands: commands 95 + ) 96 + } 97 + 98 + private func testCommand( 99 + id: String, 100 + title: String, 101 + conflictPolicy: KeybindingConflictPolicy, 102 + defaultBinding: Keybinding 103 + ) -> KeybindingCommandSchema { 104 + KeybindingCommandSchema( 105 + id: id, 106 + title: title, 107 + scope: .configurableAppAction, 108 + platform: .macOS, 109 + allowUserOverride: true, 110 + conflictPolicy: conflictPolicy, 111 + defaultBinding: defaultBinding 112 + ) 113 + } 114 + 115 + private func binding( 116 + _ key: String, 117 + modifiers: KeybindingModifiers 118 + ) -> Keybinding { 119 + Keybinding( 120 + key: key, 121 + modifiers: modifiers 122 + ) 123 + } 124 + }
+68
supacodeTests/ShortcutKeyTokenResolverTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + 5 + @MainActor 6 + struct ShortcutKeyTokenResolverTests { 7 + @Test func prefersLayoutBaseKeyOverShiftedCharacter() { 8 + let resolver = ShortcutKeyTokenResolver( 9 + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in "[".unicodeScalars.first }) 10 + ) 11 + 12 + let token = resolver.resolveKeyToken( 13 + keyCode: 33, 14 + charactersIgnoringModifiers: "{" 15 + ) 16 + 17 + #expect(token == "[") 18 + } 19 + 20 + @Test func fallsBackToCharactersIgnoringModifiersWhenLayoutUnavailable() { 21 + let resolver = ShortcutKeyTokenResolver( 22 + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in nil }) 23 + ) 24 + 25 + let token = resolver.resolveKeyToken( 26 + keyCode: 33, 27 + charactersIgnoringModifiers: "{" 28 + ) 29 + 30 + #expect(token == "{") 31 + } 32 + 33 + @Test func specialArrowAndReturnKeysUseStableTokens() { 34 + let resolver = ShortcutKeyTokenResolver( 35 + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in "x".unicodeScalars.first }) 36 + ) 37 + 38 + #expect(resolver.resolveKeyToken(keyCode: 36, charactersIgnoringModifiers: nil) == "return") 39 + #expect(resolver.resolveKeyToken(keyCode: 123, charactersIgnoringModifiers: nil) == "arrow_left") 40 + #expect(resolver.resolveKeyToken(keyCode: 124, charactersIgnoringModifiers: nil) == "arrow_right") 41 + #expect(resolver.resolveKeyToken(keyCode: 125, charactersIgnoringModifiers: nil) == "arrow_down") 42 + #expect(resolver.resolveKeyToken(keyCode: 126, charactersIgnoringModifiers: nil) == "arrow_up") 43 + } 44 + 45 + @Test func numberRowAndNumpadUsePhysicalDigitTokens() { 46 + let resolver = ShortcutKeyTokenResolver( 47 + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in nil }) 48 + ) 49 + 50 + #expect(resolver.resolveKeyToken(keyCode: 18, charactersIgnoringModifiers: "!") == "digit_1") 51 + #expect(resolver.resolveKeyToken(keyCode: 19, charactersIgnoringModifiers: "@") == "digit_2") 52 + #expect(resolver.resolveKeyToken(keyCode: 82, charactersIgnoringModifiers: "0") == "digit_0") 53 + #expect(resolver.resolveKeyToken(keyCode: 92, charactersIgnoringModifiers: "9") == "digit_9") 54 + } 55 + 56 + @Test func lowercasesLetterTokens() { 57 + let resolver = ShortcutKeyTokenResolver( 58 + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in "A".unicodeScalars.first }) 59 + ) 60 + 61 + let token = resolver.resolveKeyToken( 62 + keyCode: 0, 63 + charactersIgnoringModifiers: "A" 64 + ) 65 + 66 + #expect(token == "a") 67 + } 68 + }
+194
supacodeTests/ShortcutResetPlannerTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + 5 + struct ShortcutResetPlannerTests { 6 + @Test func resetPlanCascadesForReplaceEdgeCase() { 7 + let commandOne = "command.one" 8 + let commandTwo = "command.two" 9 + 10 + let defaultOne = binding("u") 11 + let defaultTwo = binding("c") 12 + 13 + let schema = testSchema([ 14 + testCommand(id: commandOne, title: "Command One", defaultBinding: defaultOne), 15 + testCommand(id: commandTwo, title: "Command Two", defaultBinding: defaultTwo), 16 + ]) 17 + 18 + let overrides = KeybindingUserOverrideStore( 19 + overrides: [ 20 + commandOne: KeybindingUserOverride(binding: defaultTwo), 21 + commandTwo: KeybindingUserOverride(binding: nil, isEnabled: false), 22 + ] 23 + ) 24 + 25 + let plan = ShortcutResetPlanner.makePlan( 26 + commandID: commandTwo, 27 + schema: schema, 28 + userOverrides: overrides 29 + ) 30 + 31 + #expect(plan.conflictingCommandIDs == [commandOne]) 32 + #expect(plan.commandIDsToReset == [commandTwo, commandOne]) 33 + #expect(plan.restoredBinding == defaultTwo) 34 + 35 + let resolvedAfterReset = resolvedAfterApplying(plan: plan, to: overrides, schema: schema) 36 + #expect(resolvedAfterReset.binding(for: commandOne)?.binding == defaultOne) 37 + #expect(resolvedAfterReset.binding(for: commandTwo)?.binding == defaultTwo) 38 + let commandOneBinding = resolvedAfterReset.binding(for: commandOne)?.binding 39 + let commandTwoBinding = resolvedAfterReset.binding(for: commandTwo)?.binding 40 + #expect(commandOneBinding != commandTwoBinding) 41 + } 42 + 43 + @Test func resetPlanCascadesTransitively() { 44 + let commandOne = "command.one" 45 + let commandTwo = "command.two" 46 + let commandThree = "command.three" 47 + 48 + let defaultOne = binding("1") 49 + let defaultTwo = binding("2") 50 + let defaultThree = binding("3") 51 + 52 + let schema = testSchema([ 53 + testCommand(id: commandOne, title: "Command One", defaultBinding: defaultOne), 54 + testCommand(id: commandTwo, title: "Command Two", defaultBinding: defaultTwo), 55 + testCommand(id: commandThree, title: "Command Three", defaultBinding: defaultThree), 56 + ]) 57 + 58 + let overrides = KeybindingUserOverrideStore( 59 + overrides: [ 60 + commandOne: KeybindingUserOverride(binding: defaultTwo), 61 + commandTwo: KeybindingUserOverride(binding: defaultThree), 62 + commandThree: KeybindingUserOverride(binding: nil, isEnabled: false), 63 + ] 64 + ) 65 + 66 + let plan = ShortcutResetPlanner.makePlan( 67 + commandID: commandThree, 68 + schema: schema, 69 + userOverrides: overrides 70 + ) 71 + 72 + #expect(plan.conflictingCommandIDs == [commandTwo]) 73 + #expect(plan.commandIDsToReset == [commandThree, commandTwo, commandOne]) 74 + 75 + let resolvedAfterReset = resolvedAfterApplying(plan: plan, to: overrides, schema: schema) 76 + #expect(resolvedAfterReset.binding(for: commandOne)?.binding == defaultOne) 77 + #expect(resolvedAfterReset.binding(for: commandTwo)?.binding == defaultTwo) 78 + #expect(resolvedAfterReset.binding(for: commandThree)?.binding == defaultThree) 79 + } 80 + 81 + @Test func resetPlanDoesNotCascadeWhenNoConflict() { 82 + let commandOne = "command.one" 83 + let commandTwo = "command.two" 84 + 85 + let defaultOne = binding("a") 86 + let defaultTwo = binding("b") 87 + let custom = binding("z") 88 + 89 + let schema = testSchema([ 90 + testCommand(id: commandOne, title: "Command One", defaultBinding: defaultOne), 91 + testCommand(id: commandTwo, title: "Command Two", defaultBinding: defaultTwo), 92 + ]) 93 + 94 + let overrides = KeybindingUserOverrideStore( 95 + overrides: [ 96 + commandOne: KeybindingUserOverride(binding: custom) 97 + ] 98 + ) 99 + 100 + let plan = ShortcutResetPlanner.makePlan( 101 + commandID: commandOne, 102 + schema: schema, 103 + userOverrides: overrides 104 + ) 105 + 106 + #expect(plan.conflictingCommandIDs.isEmpty) 107 + #expect(plan.commandIDsToReset == [commandOne]) 108 + #expect(plan.restoredBinding == defaultOne) 109 + } 110 + 111 + @Test func resetPlanForSectionCascadesAcrossSeeds() { 112 + let commandOne = "command.one" 113 + let commandTwo = "command.two" 114 + let commandThree = "command.three" 115 + 116 + let defaultOne = binding("a") 117 + let defaultTwo = binding("b") 118 + let defaultThree = binding("c") 119 + 120 + let schema = testSchema([ 121 + testCommand(id: commandOne, title: "Command One", defaultBinding: defaultOne), 122 + testCommand(id: commandTwo, title: "Command Two", defaultBinding: defaultTwo), 123 + testCommand(id: commandThree, title: "Command Three", defaultBinding: defaultThree), 124 + ]) 125 + 126 + let overrides = KeybindingUserOverrideStore( 127 + overrides: [ 128 + commandOne: KeybindingUserOverride(binding: defaultTwo), 129 + commandTwo: KeybindingUserOverride(binding: defaultThree), 130 + commandThree: KeybindingUserOverride(binding: nil, isEnabled: false), 131 + ] 132 + ) 133 + 134 + let plan = ShortcutResetPlanner.makePlan( 135 + commandIDs: [commandTwo, commandThree], 136 + schema: schema, 137 + userOverrides: overrides 138 + ) 139 + 140 + #expect(plan.restoredBinding == nil) 141 + #expect(plan.conflictingCommandIDs == [commandOne]) 142 + #expect(plan.commandIDsToReset == [commandTwo, commandThree, commandOne]) 143 + 144 + let resolvedAfterReset = resolvedAfterApplying(plan: plan, to: overrides, schema: schema) 145 + #expect(resolvedAfterReset.binding(for: commandOne)?.binding == defaultOne) 146 + #expect(resolvedAfterReset.binding(for: commandTwo)?.binding == defaultTwo) 147 + #expect(resolvedAfterReset.binding(for: commandThree)?.binding == defaultThree) 148 + } 149 + 150 + private func resolvedAfterApplying( 151 + plan: ShortcutResetPlan, 152 + to overrides: KeybindingUserOverrideStore, 153 + schema: KeybindingSchemaDocument 154 + ) -> ResolvedKeybindingMap { 155 + var updated = overrides 156 + for commandID in plan.commandIDsToReset { 157 + updated.overrides.removeValue(forKey: commandID) 158 + } 159 + return KeybindingResolver.resolve( 160 + schema: schema, 161 + userOverrides: updated 162 + ) 163 + } 164 + 165 + private func testSchema(_ commands: [KeybindingCommandSchema]) -> KeybindingSchemaDocument { 166 + KeybindingSchemaDocument( 167 + version: KeybindingSchemaDocument.currentVersion, 168 + commands: commands 169 + ) 170 + } 171 + 172 + private func testCommand( 173 + id: String, 174 + title: String, 175 + defaultBinding: Keybinding 176 + ) -> KeybindingCommandSchema { 177 + KeybindingCommandSchema( 178 + id: id, 179 + title: title, 180 + scope: .configurableAppAction, 181 + platform: .macOS, 182 + allowUserOverride: true, 183 + conflictPolicy: .warnAndPreferUserOverride, 184 + defaultBinding: defaultBinding 185 + ) 186 + } 187 + 188 + private func binding(_ key: String) -> Keybinding { 189 + Keybinding( 190 + key: key, 191 + modifiers: KeybindingModifiers(command: true) 192 + ) 193 + } 194 + }
+31
supacodeTests/UserRepositorySettingsKeyTests.swift
··· 97 97 let decoded = try JSONDecoder().decode(UserRepositorySettings.self, from: localData) 98 98 #expect(decoded == customSettings) 99 99 } 100 + 101 + @Test(.dependencies) func savePersistsMoreThanThreeCustomCommands() throws { 102 + let localStorage = RepositoryLocalSettingsTestStorage() 103 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 104 + let localURL = SupacodePaths.userRepositorySettingsURL(for: rootURL) 105 + 106 + let commands = (0..<5).map { index in 107 + UserCustomCommand( 108 + title: "Command \(index + 1)", 109 + systemImage: "terminal", 110 + command: "echo \(index + 1)", 111 + execution: .shellScript, 112 + shortcut: nil 113 + ) 114 + } 115 + let customSettings = UserRepositorySettings(customCommands: commands) 116 + 117 + withDependencies { 118 + $0.repositoryLocalSettingsStorage = localStorage.storage 119 + } operation: { 120 + @Shared(.userRepositorySettings(rootURL)) var settings: UserRepositorySettings 121 + $settings.withLock { 122 + $0 = customSettings 123 + } 124 + } 125 + 126 + let localData = try #require(localStorage.data(at: localURL)) 127 + let decoded = try JSONDecoder().decode(UserRepositorySettings.self, from: localData) 128 + #expect(decoded.customCommands.count == 5) 129 + #expect(decoded == customSettings) 130 + } 100 131 }