diff --git a/supacode/App/KeybindingSchema.swift b/supacode/App/KeybindingSchema.swift index 7c12f14e..24f6bf2a 100644 --- a/supacode/App/KeybindingSchema.swift +++ b/supacode/App/KeybindingSchema.swift @@ -37,6 +37,10 @@ nonisolated struct KeybindingModifiers: Codable, Equatable, Sendable { self.control = control } + var isEmpty: Bool { + !command && !shift && !option && !control + } + var eventModifiers: EventModifiers { var value: EventModifiers = [] if command { diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index ab788a3b..a61ff99d 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -34,7 +34,7 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // Disable press-and-hold accent menu so that key repeat works in the terminal. UserDefaults.standard.register(defaults: [ - "ApplePressAndHoldEnabled": false, + "ApplePressAndHoldEnabled": false ]) appStore?.send(.appLaunched) } diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index db36cdba..771980d3 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -176,12 +176,14 @@ struct WorktreeCommands: Commands { store.send(.repositories(.selectWorktree(row.id))) } .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: commandID))) - .help({ - if let shortcut = shortcutDisplay(for: commandID) { - return "Switch to \(title) (\(shortcut))" - } - return "Switch to \(title)" - }()) + .help( + { + if let shortcut = shortcutDisplay(for: commandID) { + return "Switch to \(title) (\(shortcut))" + } + return "Switch to \(title)" + }() + ) .disabled(row == nil) } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index e8af11cd..5a4422b4 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -85,6 +85,18 @@ struct AppFeature { @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher @Dependency(CustomShortcutRegistryClient.self) private var customShortcutRegistryClient + private func resolvedKeybindings( + settings: SettingsFeature.State, + customCommands: [UserCustomCommand] + ) -> ResolvedKeybindingMap { + let migration = LegacyCustomCommandShortcutMigration.migrate(commands: customCommands) + return KeybindingResolver.resolve( + schema: .appResolverSchema(customCommands: customCommands), + userOverrides: settings.keybindingUserOverrides, + migratedOverrides: migration.overrides + ) + } + var body: some Reducer { let core = Reduce { state, action in switch action { @@ -140,7 +152,10 @@ struct AppFeature { state.openActionSelection = .finder state.selectedRunScript = "" state.selectedCustomCommands = [] - state.resolvedKeybindings = .appDefaults + state.resolvedKeybindings = resolvedKeybindings( + settings: state.settings, + customCommands: state.selectedCustomCommands + ) state.runScriptDraft = "" state.isRunScriptPromptPresented = false var effects: [Effect] = [ @@ -169,7 +184,10 @@ struct AppFeature { let rootURL = worktree.repositoryRootURL let worktreeID = worktree.id state.selectedCustomCommands = [] - state.resolvedKeybindings = .appDefaults + state.resolvedKeybindings = resolvedKeybindings( + settings: state.settings, + customCommands: state.selectedCustomCommands + ) state.runScriptDraft = "" state.isRunScriptPromptPresented = false @Shared(.repositorySettings(rootURL)) var repositorySettings @@ -278,7 +296,7 @@ struct AppFeature { settings: repositorySettings, userSettings: userRepositorySettings ) - case .general, .notifications, .worktree, .updates, .advanced, .github: + case .general, .notifications, .shortcuts, .worktree, .updates, .advanced, .github: state.settings.repositorySettings = nil } return .none @@ -287,6 +305,7 @@ struct AppFeature { let shouldCheckSystemNotificationPermission = settings.systemNotificationsEnabled && !state.lastKnownSystemNotificationsEnabled state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled + state.settings.keybindingUserOverrides = settings.keybindingUserOverrides if let selectedWorktree = state.repositories.selectedTerminalWorktree { let rootURL = selectedWorktree.repositoryRootURL @Shared(.repositorySettings(rootURL)) var repositorySettings @@ -295,6 +314,10 @@ struct AppFeature { defaultEditorID: settings.defaultEditorID ) } + state.resolvedKeybindings = resolvedKeybindings( + settings: state.settings, + customCommands: state.selectedCustomCommands + ) return .merge( .send(.repositories(.setGithubIntegrationEnabled(settings.githubIntegrationEnabled))), .send( @@ -626,10 +649,9 @@ struct AppFeature { } state.selectedCustomCommands = UserRepositorySettings.normalizedCommands(settings.customCommands) .filter(\.hasRunnableCommand) - let migration = LegacyCustomCommandShortcutMigration.migrate(commands: state.selectedCustomCommands) - state.resolvedKeybindings = KeybindingResolver.resolve( - schema: .appResolverSchema(customCommands: state.selectedCustomCommands), - migratedOverrides: migration.overrides + state.resolvedKeybindings = resolvedKeybindings( + settings: state.settings, + customCommands: state.selectedCustomCommands ) let userOverrideConflicts = AppShortcuts.userOverrideConflicts(in: state.selectedCustomCommands) let shortcuts: [UserCustomShortcut] = state.selectedCustomCommands.compactMap { command in diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index d1a0c40c..e16b0fbf 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -409,10 +409,10 @@ struct CanvasView: View { "Broadcasting to \(selectionState.selectedTabIDs.count) cards", systemImage: "dot.radiowaves.left.and.right" ) - .font(.callout) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.bar, in: Capsule()) + .font(.callout) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.bar, in: Capsule()) } Button { diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 6af2e78a..39eb2e10 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -336,7 +336,7 @@ private func pullRequestItems( subtitle: pullRequest.title, kind: .openPullRequest(worktreeID), priorityTier: 2 - ), + ) ] if let readyItem = makeReadyItem() { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 554ffd8d..33abf16a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1967,7 +1967,7 @@ struct RepositoriesFeature { var effects: [Effect] = [ .run { _ in await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) - }, + } ] if didUpdateWorktreeOrder { let worktreeOrderByRepository = state.worktreeOrderByRepository @@ -1994,7 +1994,7 @@ struct RepositoriesFeature { var effects: [Effect] = [ .run { _ in await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) - }, + } ] if didUpdateWorktreeOrder { let worktreeOrderByRepository = state.worktreeOrderByRepository @@ -2772,7 +2772,7 @@ struct RepositoriesFeature { } private nonisolated static func isNotGitRepositoryError(_ error: any Error) -> Bool { - guard case let GitClientError.commandFailed(_, message) = error else { + guard case GitClientError.commandFailed(_, let message) = error else { return false } return message.localizedCaseInsensitiveContains("not a git repository") @@ -2780,7 +2780,7 @@ struct RepositoriesFeature { private nonisolated static func openRepositoryFailureMessage(path: String, error: any Error) -> String { let detail: String - if case let GitClientError.commandFailed(_, message) = error, + if case GitClientError.commandFailed(_, let message) = error, !message.isEmpty { detail = message @@ -2852,14 +2852,14 @@ struct RepositoriesFeature { return WorktreesFetchResult( entry: entry, repository: Repository( - id: rootURL.path(percentEncoded: false), - rootURL: rootURL, - name: Repository.name(for: rootURL), - kind: .plain, - worktrees: IdentifiedArray() - ), - errorMessage: nil - ) + id: rootURL.path(percentEncoded: false), + rootURL: rootURL, + name: Repository.name(for: rootURL), + kind: .plain, + worktrees: IdentifiedArray() + ), + errorMessage: nil + ) } } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 4ccc6773..f6a5f31d 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -121,10 +121,12 @@ struct WorktreeDetailView: View { } private func toolbarState(input: ToolbarStateInput) -> WorktreeToolbarState? { - guard let title = DetailToolbarTitle.forSelection( - worktree: input.selectedWorktree, - repository: input.repositories.selectedRepository - ) else { + guard + let title = DetailToolbarTitle.forSelection( + worktree: input.selectedWorktree, + repository: input.repositories.selectedRepository + ) + else { return nil } let pullRequest = input.selectedWorktree.flatMap { input.repositories.worktreeInfo(for: $0.id)?.pullRequest } @@ -189,9 +191,11 @@ struct WorktreeDetailView: View { selectedWorktreeSummaries: [MultiSelectedWorktreeSummary] ) -> some View { if repositories.isShowingCanvas { - CanvasView(terminalManager: terminalManager, onExitToTab: { - store.send(.repositories(.toggleCanvas)) - }) + CanvasView( + terminalManager: terminalManager, + onExitToTab: { + store.send(.repositories(.toggleCanvas)) + }) } else if repositories.isShowingArchivedWorktrees { ArchivedWorktreesDetailView( store: store.scope(state: \.repositories, action: \.repositories) @@ -778,7 +782,7 @@ private struct WorktreeToolbarPreview: View { key: "u", modifiers: UserCustomShortcutModifiers() ) - ), + ) ] ) let observer = CommandKeyObserver() diff --git a/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift b/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift index 1244daf4..997a6c53 100644 --- a/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift +++ b/supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift @@ -1,5 +1,5 @@ -import Foundation import Dependencies +import Foundation import Sharing nonisolated struct RepositoryEntriesKeyID: Hashable, Sendable {} diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index b29670b5..8be53e35 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -19,6 +19,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var promptForWorktreeCreation: Bool var defaultWorktreeBaseDirectoryPath: String? var terminalFontSize: Float32? + var keybindingUserOverrides: KeybindingUserOverrideStore static let `default` = GlobalSettings( appearanceMode: .dark, @@ -40,7 +41,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { automaticallyArchiveMergedWorktrees: false, promptForWorktreeCreation: true, defaultWorktreeBaseDirectoryPath: nil, - terminalFontSize: nil + terminalFontSize: nil, + keybindingUserOverrides: .empty ) init( @@ -63,7 +65,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { automaticallyArchiveMergedWorktrees: Bool, promptForWorktreeCreation: Bool, defaultWorktreeBaseDirectoryPath: String? = nil, - terminalFontSize: Float32? = nil + terminalFontSize: Float32? = nil, + keybindingUserOverrides: KeybindingUserOverrideStore = .empty ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -85,6 +88,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.promptForWorktreeCreation = promptForWorktreeCreation self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath self.terminalFontSize = terminalFontSize + self.keybindingUserOverrides = keybindingUserOverrides } init(from decoder: any Decoder) throws { @@ -143,5 +147,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { terminalFontSize = try container.decodeIfPresent(Float32.self, forKey: .terminalFontSize) ?? Self.default.terminalFontSize + keybindingUserOverrides = + try container.decodeIfPresent(KeybindingUserOverrideStore.self, forKey: .keybindingUserOverrides) + ?? Self.default.keybindingUserOverrides } } diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 6b69115e..1d428a91 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -25,6 +25,7 @@ struct SettingsFeature { var promptForWorktreeCreation: Bool var defaultWorktreeBaseDirectoryPath: String var terminalFontSize: Float32? + var keybindingUserOverrides: KeybindingUserOverrideStore var selection: SettingsSection? = .general var repositorySettings: RepositorySettingsFeature.State? @Presents var alert: AlertState? @@ -52,6 +53,7 @@ struct SettingsFeature { defaultWorktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" terminalFontSize = settings.terminalFontSize + keybindingUserOverrides = settings.keybindingUserOverrides } var globalSettings: GlobalSettings { @@ -77,7 +79,8 @@ struct SettingsFeature { defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( defaultWorktreeBaseDirectoryPath ), - terminalFontSize: terminalFontSize + terminalFontSize: terminalFontSize, + keybindingUserOverrides: keybindingUserOverrides ) } } @@ -155,6 +158,7 @@ struct SettingsFeature { state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" state.terminalFontSize = normalizedSettings.terminalFontSize + state.keybindingUserOverrides = normalizedSettings.keybindingUserOverrides state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/SettingsSection.swift b/supacode/Features/Settings/Views/SettingsSection.swift index 50db17ed..6732c097 100644 --- a/supacode/Features/Settings/Views/SettingsSection.swift +++ b/supacode/Features/Settings/Views/SettingsSection.swift @@ -3,6 +3,7 @@ import Foundation enum SettingsSection: Hashable { case general case notifications + case shortcuts case worktree case updates case advanced diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 891fcdeb..26493d55 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -33,6 +33,8 @@ struct SettingsView: View { .tag(SettingsSection.general) Label("Notifications", systemImage: "bell") .tag(SettingsSection.notifications) + Label("Shortcuts", systemImage: "keyboard") + .tag(SettingsSection.shortcuts) Label("Worktree", systemImage: "archivebox") .tag(SettingsSection.worktree) Label("Updates", systemImage: "arrow.down.circle") @@ -68,6 +70,12 @@ struct SettingsView: View { .navigationTitle("Notifications") .navigationSubtitle("In-app alerts and delivery") } + case .shortcuts: + SettingsDetailView { + ShortcutsSettingsView(store: settingsStore) + .navigationTitle("Shortcuts") + .navigationSubtitle("Global keybindings") + } case .worktree: SettingsDetailView { WorktreeSettingsView(store: settingsStore) diff --git a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift new file mode 100644 index 00000000..605a31cb --- /dev/null +++ b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift @@ -0,0 +1,538 @@ +import AppKit +import ComposableArchitecture +import SwiftUI + +struct ShortcutsSettingsView: View { + @Bindable var store: StoreOf + + @State private var searchText = "" + @State private var recordingCommandID: String? + @State private var recorderMonitor: Any? + @State private var invalidMessageByCommandID: [String: String] = [:] + @State private var pendingConflict: ShortcutConflict? + @State private var pendingOverride: PendingOverride? + @State private var focusedConflictCommandID: String? + + private var schema: KeybindingSchemaDocument { + .appDefaultsV1 + } + + private var editableCommands: [KeybindingCommandSchema] { + schema.commands.filter(\.allowUserOverride) + } + + private var resolvedBindings: ResolvedKeybindingMap { + KeybindingResolver.resolve( + schema: .appResolverSchema(), + userOverrides: store.keybindingUserOverrides + ) + } + + private var visibleGroups: [ShortcutGroup] { + ShortcutGroup.allCases.filter { !commands(for: $0).isEmpty } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + TextField("Search actions or shortcuts", text: $searchText) + .textFieldStyle(.roundedBorder) + + HStack(spacing: 8) { + Button("Reset All") { + resetAllOverrides() + } + .disabled(store.keybindingUserOverrides.overrides.isEmpty) + + Spacer(minLength: 0) + + Text("Press Record, then type a shortcut. Esc cancels recording.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Form { + ForEach(visibleGroups) { group in + Section { + ForEach(commands(for: group), id: \.id) { command in + row(for: command) + } + } header: { + HStack { + Text(group.title) + Spacer(minLength: 0) + if hasOverrides(in: group) { + Button("Reset Group") { + resetOverrides(in: group) + } + .buttonStyle(.link) + } + } + } + } + + if visibleGroups.isEmpty { + Text("No shortcuts found.") + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + } + .onChange(of: recordingCommandID) { commandID in + if commandID == nil { + stopRecorderMonitor() + } else { + startRecorderMonitor() + } + } + .onDisappear { + stopRecorderMonitor() + } + .alert( + "Shortcut Conflict", + isPresented: isConflictAlertPresented, + presenting: pendingConflict + ) { conflict in + Button("Replace", role: .destructive) { + applyPendingOverride(replacingConflict: true) + } + Button("Show Conflict") { + focusConflictCommand(conflict) + } + Button("Cancel", role: .cancel) { + clearPendingConflict() + } + } message: { conflict in + Text( + "“\(conflict.newCommandTitle)” and “\(conflict.existingCommandTitle)” both use \(conflict.binding.display)." + + "\n\nChoose Replace to keep the new binding and disable the conflicting one." + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + @ViewBuilder + private func row(for command: KeybindingCommandSchema) -> some View { + let isRecording = recordingCommandID == command.id + let resolvedBinding = resolvedBindings.binding(for: command.id)?.binding + let source = resolvedBindings.binding(for: command.id)?.source ?? .appDefault + let hasOverride = store.keybindingUserOverrides.overrides[command.id] != nil + let isFocused = focusedConflictCommandID == command.id + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(command.title) + .font(.body) + Spacer(minLength: 0) + Text(resolvedBinding?.display ?? "Unassigned") + .font(.body.monospaced()) + .foregroundStyle(resolvedBinding == nil ? .secondary : .primary) + } + + HStack(spacing: 10) { + Button(isRecording ? "Recording…" : "Record") { + toggleRecording(for: command.id) + } + + if isRecording { + Button("Cancel") { + stopRecording() + } + } + + if hasOverride { + Button("Reset") { + resetOverride(for: command.id) + } + } + + sourceChip(source) + Spacer(minLength: 0) + } + + if isRecording { + Text("Type a key with at least one modifier (⌘ ⇧ ⌥ ⌃). Return and arrow keys are supported.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let invalid = invalidMessageByCommandID[command.id] { + Text(invalid) + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(isFocused ? Color.orange.opacity(0.15) : .clear) + } + } + + private func sourceChip(_ source: KeybindingSource) -> some View { + let label: String + switch source { + case .appDefault: + label = "Default" + case .migratedLegacy: + label = "Migrated" + case .userOverride: + label = "Override" + } + + return Text(label) + .font(.caption2.monospaced()) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + .foregroundStyle(.secondary) + } + + private func commands(for group: ShortcutGroup) -> [KeybindingCommandSchema] { + editableCommands + .filter { ShortcutGroup.resolve(for: $0.id) == group } + .filter(matchesSearch) + .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + + private func matchesSearch(_ command: KeybindingCommandSchema) -> Bool { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return true } + + if command.title.localizedCaseInsensitiveContains(query) { + return true + } + if command.id.localizedCaseInsensitiveContains(query) { + return true + } + if let display = resolvedBindings.binding(for: command.id)?.binding?.display, + display.localizedCaseInsensitiveContains(query) + { + return true + } + return false + } + + private func hasOverrides(in group: ShortcutGroup) -> Bool { + let commandIDs = Set(commands(for: group).map(\.id)) + return store.keybindingUserOverrides.overrides.keys.contains { commandIDs.contains($0) } + } + + private func toggleRecording(for commandID: String) { + invalidMessageByCommandID[commandID] = nil + focusedConflictCommandID = nil + if recordingCommandID == commandID { + recordingCommandID = nil + return + } + recordingCommandID = commandID + } + + private func stopRecording() { + recordingCommandID = nil + } + + private func startRecorderMonitor() { + stopRecorderMonitor() + recorderMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in + guard let commandID = recordingCommandID else { + return event + } + handleRecorderEvent(event, commandID: commandID) + return nil + } + } + + private func stopRecorderMonitor() { + if let recorderMonitor { + NSEvent.removeMonitor(recorderMonitor) + self.recorderMonitor = nil + } + } + + private func handleRecorderEvent(_ event: NSEvent, commandID: String) { + if event.keyCode == 53 { // Escape + stopRecording() + return + } + + guard let keyToken = keyToken(for: event) else { + invalidMessageByCommandID[commandID] = "Unsupported key. Use letters, numbers, punctuation, Return, or arrows." + return + } + + let modifiers = KeybindingModifiers( + command: event.modifierFlags.contains(.command), + shift: event.modifierFlags.contains(.shift), + option: event.modifierFlags.contains(.option), + control: event.modifierFlags.contains(.control) + ) + + guard !modifiers.isEmpty else { + invalidMessageByCommandID[commandID] = "Shortcut must include at least one modifier key." + return + } + + let binding = Keybinding(key: keyToken, modifiers: modifiers) + applyRecordedBinding(binding, to: commandID) + } + + private func keyToken(for event: NSEvent) -> String? { + switch event.keyCode { + case 36, 76: + return "return" + case 123: + return "arrow_left" + case 124: + return "arrow_right" + case 125: + return "arrow_down" + case 126: + return "arrow_up" + default: + break + } + + if let physicalDigit = physicalDigitToken(for: event.keyCode) { + return physicalDigit + } + + guard let scalar = event.charactersIgnoringModifiers?.trimmingCharacters(in: .whitespacesAndNewlines).first else { + return nil + } + + return String(scalar).lowercased() + } + + private func physicalDigitToken(for keyCode: UInt16) -> String? { + switch keyCode { + case 29, 82: + return "digit_0" + case 18, 83: + return "digit_1" + case 19, 84: + return "digit_2" + case 20, 85: + return "digit_3" + case 21, 86: + return "digit_4" + case 23, 87: + return "digit_5" + case 22, 88: + return "digit_6" + case 26, 89: + return "digit_7" + case 28, 91: + return "digit_8" + case 25, 92: + return "digit_9" + default: + return nil + } + } + + private func applyRecordedBinding(_ binding: Keybinding, to commandID: String) { + invalidMessageByCommandID[commandID] = nil + focusedConflictCommandID = nil + + guard let command = editableCommands.first(where: { $0.id == commandID }) else { + stopRecording() + return + } + + let conflict = firstConflict( + commandID: commandID, + binding: binding, + policy: command.conflictPolicy + ) + + if let conflict { + pendingConflict = conflict + pendingOverride = PendingOverride(commandID: commandID, binding: binding) + stopRecording() + return + } + + saveOverride( + commandID: commandID, + binding: binding, + replaceConflictCommandID: nil + ) + stopRecording() + } + + private func firstConflict( + commandID: String, + binding: Keybinding, + policy: KeybindingConflictPolicy + ) -> ShortcutConflict? { + guard policy == .warnAndPreferUserOverride else { return nil } + + var tentative = store.keybindingUserOverrides + tentative.overrides[commandID] = KeybindingUserOverride(binding: binding) + + let resolved = KeybindingResolver.resolve( + schema: .appResolverSchema(), + userOverrides: tentative + ) + + for command in editableCommands where command.id != commandID { + guard resolved.binding(for: command.id)?.binding == binding else { continue } + let newTitle = editableCommands.first(where: { $0.id == commandID })?.title ?? commandID + return ShortcutConflict( + newCommandID: commandID, + newCommandTitle: newTitle, + existingCommandID: command.id, + existingCommandTitle: command.title, + binding: binding + ) + } + + return nil + } + + private func applyPendingOverride(replacingConflict: Bool) { + guard let pendingOverride else { + clearPendingConflict() + return + } + + let conflictCommandID = replacingConflict ? pendingConflict?.existingCommandID : nil + saveOverride( + commandID: pendingOverride.commandID, + binding: pendingOverride.binding, + replaceConflictCommandID: conflictCommandID + ) + clearPendingConflict() + } + + private func clearPendingConflict() { + pendingConflict = nil + pendingOverride = nil + } + + private func focusConflictCommand(_ conflict: ShortcutConflict) { + focusedConflictCommandID = conflict.existingCommandID + searchText = conflict.existingCommandTitle + clearPendingConflict() + } + + private func saveOverride( + commandID: String, + binding: Keybinding, + replaceConflictCommandID: String? + ) { + var overrides = store.keybindingUserOverrides + overrides.overrides[commandID] = KeybindingUserOverride(binding: binding) + + if let replaceConflictCommandID { + overrides.overrides[replaceConflictCommandID] = KeybindingUserOverride(binding: nil, isEnabled: false) + } + + $store.keybindingUserOverrides.wrappedValue = overrides + } + + private func resetOverride(for commandID: String) { + var overrides = store.keybindingUserOverrides + overrides.overrides.removeValue(forKey: commandID) + $store.keybindingUserOverrides.wrappedValue = overrides + invalidMessageByCommandID[commandID] = nil + if recordingCommandID == commandID { + stopRecording() + } + } + + private func resetOverrides(in group: ShortcutGroup) { + let commandIDs = Set(commands(for: group).map(\.id)) + var overrides = store.keybindingUserOverrides + overrides.overrides = overrides.overrides.filter { !commandIDs.contains($0.key) } + $store.keybindingUserOverrides.wrappedValue = overrides + + for commandID in commandIDs { + invalidMessageByCommandID.removeValue(forKey: commandID) + } + + if let recordingCommandID, commandIDs.contains(recordingCommandID) { + stopRecording() + } + } + + private func resetAllOverrides() { + $store.keybindingUserOverrides.wrappedValue = .empty + invalidMessageByCommandID.removeAll() + stopRecording() + } + + private var isConflictAlertPresented: Binding { + Binding( + get: { pendingConflict != nil }, + set: { shouldPresent in + if !shouldPresent { + clearPendingConflict() + } + } + ) + } +} + +private struct ShortcutConflict: Equatable { + let newCommandID: String + let newCommandTitle: String + let existingCommandID: String + let existingCommandTitle: String + let binding: Keybinding +} + +private struct PendingOverride: Equatable { + let commandID: String + let binding: Keybinding +} + +private enum ShortcutGroup: String, CaseIterable, Identifiable { + case general + case navigation + case scripts + + var id: String { + rawValue + } + + var title: String { + switch self { + case .general: + "General" + case .navigation: + "Navigation" + case .scripts: + "Scripts & Panels" + } + } + + static func resolve(for commandID: String) -> ShortcutGroup { + switch commandID { + case AppShortcuts.CommandID.selectNextWorktree, + AppShortcuts.CommandID.selectPreviousWorktree, + AppShortcuts.CommandID.selectWorktree1, + AppShortcuts.CommandID.selectWorktree2, + AppShortcuts.CommandID.selectWorktree3, + AppShortcuts.CommandID.selectWorktree4, + AppShortcuts.CommandID.selectWorktree5, + AppShortcuts.CommandID.selectWorktree6, + AppShortcuts.CommandID.selectWorktree7, + AppShortcuts.CommandID.selectWorktree8, + AppShortcuts.CommandID.selectWorktree9, + AppShortcuts.CommandID.selectWorktree0: + return .navigation + + case AppShortcuts.CommandID.runScript, + AppShortcuts.CommandID.stopScript, + AppShortcuts.CommandID.showDiff, + AppShortcuts.CommandID.toggleCanvas, + AppShortcuts.CommandID.archivedWorktrees: + return .scripts + + default: + return .general + } + } +} diff --git a/supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift b/supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift index 05309e01..1675e7e4 100644 --- a/supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift +++ b/supacode/Infrastructure/Ghostty/MirroredTerminalKey.swift @@ -30,10 +30,10 @@ struct MirroredTerminalKey: Equatable, Sendable { /// Key codes allowed to pass through even with the Command modifier held. private static let commandAllowedKeyCodes: Set = [ 51, // backspace - 123, // arrowLeft - 124, // arrowRight - 125, // arrowDown - 126, // arrowUp + 123, // arrowLeft + 124, // arrowRight + 125, // arrowDown + 126, // arrowUp ] init?( diff --git a/supacodeTests/AppFeatureCustomCommandTests.swift b/supacodeTests/AppFeatureCustomCommandTests.swift index 5d2569d8..08653f57 100644 --- a/supacodeTests/AppFeatureCustomCommandTests.swift +++ b/supacodeTests/AppFeatureCustomCommandTests.swift @@ -21,7 +21,7 @@ struct AppFeatureCustomCommandTests { command: "swift test", execution: .shellScript, shortcut: nil, - ), + ) ] let store = TestStore(initialState: state) { @@ -56,7 +56,7 @@ struct AppFeatureCustomCommandTests { command: "pnpm test --watch", execution: .terminalInput, shortcut: nil, - ), + ) ] let store = TestStore(initialState: state) { diff --git a/supacodeTests/AppFeaturePlainFolderTerminalTests.swift b/supacodeTests/AppFeaturePlainFolderTerminalTests.swift index e681ae23..4c176f89 100644 --- a/supacodeTests/AppFeaturePlainFolderTerminalTests.swift +++ b/supacodeTests/AppFeaturePlainFolderTerminalTests.swift @@ -35,7 +35,7 @@ struct AppFeaturePlainFolderTerminalTests { command: "pnpm test --watch", execution: .terminalInput, shortcut: nil - ), + ) ] ) try localStorage.save( @@ -115,7 +115,7 @@ struct AppFeaturePlainFolderTerminalTests { .createTab( makePlainTerminalTarget(repository: repository), runSetupScriptIfNew: false - ), + ) ] ) } @@ -148,7 +148,7 @@ struct AppFeaturePlainFolderTerminalTests { key: "b", modifiers: UserCustomShortcutModifiers(command: true) ) - ), + ) ] ) @@ -180,7 +180,7 @@ struct AppFeaturePlainFolderTerminalTests { command: "pnpm test --watch", execution: .terminalInput, shortcut: nil - ), + ) ] let store = TestStore(initialState: state) { AppFeature() @@ -198,7 +198,7 @@ struct AppFeaturePlainFolderTerminalTests { .insertText( makePlainTerminalTarget(repository: repository), text: "pnpm test --watch" - ), + ) ] ) } diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 2d88ad0f..8e4d3ed3 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import CustomDump import DependenciesTestSupport import Testing @@ -55,4 +56,40 @@ struct AppFeatureSettingsChangedTests { #expect(sentTerminalCommands.value.isEmpty) #expect(watcherCommands.value.isEmpty) } + + @Test(.dependencies) func settingsChangedRecomputesResolvedKeybindings() async { + var settings = GlobalSettings.default + settings.keybindingUserOverrides = KeybindingUserOverrideStore( + overrides: [ + AppShortcuts.CommandID.openSettings: KeybindingUserOverride( + binding: Keybinding(key: ";", modifiers: .init(command: true)) + ) + ] + ) + + let expectedResolved = KeybindingResolver.resolve( + schema: .appResolverSchema(), + userOverrides: settings.keybindingUserOverrides + ) + + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + + await store.send(.settings(.delegate(.settingsChanged(settings)))) { + $0.settings.keybindingUserOverrides = settings.keybindingUserOverrides + $0.resolvedKeybindings = expectedResolved + } + await store.receive(\.repositories.setGithubIntegrationEnabled) + await store.receive(\.repositories.setAutomaticallyArchiveMergedWorktrees) + await store.receive(\.repositories.setMoveNotifiedWorktreeToTop) + await store.receive(\.updates.applySettings) { + $0.updates.didConfigureUpdates = true + } + + expectNoDifference( + store.state.resolvedKeybindings.display(for: AppShortcuts.CommandID.openSettings), + "⌘;" + ) + } } diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 399bf851..35859d57 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -175,7 +175,7 @@ struct AppShortcutsTests { overrides: [ AppShortcuts.CommandID.openSettings: KeybindingUserOverride( binding: Keybinding(key: ";", modifiers: .init(command: true)) - ), + ) ] ) let resolved = KeybindingResolver.resolve( @@ -207,7 +207,7 @@ struct AppShortcutsTests { AppShortcuts.CommandID.openSettings: KeybindingUserOverride( binding: Keybinding(key: ";", modifiers: .init(command: true)), isEnabled: false - ), + ) ] ) let resolved = KeybindingResolver.resolve( @@ -245,7 +245,7 @@ struct AppShortcutsTests { overrides: [ AppShortcuts.CommandID.openSettings: KeybindingUserOverride( binding: Keybinding(key: "space", modifiers: .init(command: true)) - ), + ) ] ) let resolved = KeybindingResolver.resolve( @@ -264,7 +264,7 @@ struct AppShortcutsTests { overrides: [ AppShortcuts.CommandID.openSettings: KeybindingUserOverride( binding: Keybinding(key: "digit_1", modifiers: .init(command: true)) - ), + ) ] ) let resolved = KeybindingResolver.resolve( @@ -290,4 +290,3 @@ struct AppShortcutsTests { #expect(arguments.contains("--keybind=super+,=unbind") == false) } } - diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 53ade695..1cc815d8 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -48,7 +48,7 @@ struct CommandPaletteFeatureTests { copyIgnored: false, copyUntracked: false ) - ), + ) ] let items = CommandPaletteFeature.commandPaletteItems(from: state) @@ -73,7 +73,7 @@ struct CommandPaletteFeatureTests { description: "Focus the split to the right.", action: "goto_split:right", actionKey: "goto_split" - ), + ) ] ) @@ -147,7 +147,7 @@ struct CommandPaletteFeatureTests { description: "", action: "goto_split:right", actionKey: "goto_split" - ), + ) ] ) diff --git a/supacodeTests/KeybindingSchemaTests.swift b/supacodeTests/KeybindingSchemaTests.swift index 96484938..d19eaa32 100644 --- a/supacodeTests/KeybindingSchemaTests.swift +++ b/supacodeTests/KeybindingSchemaTests.swift @@ -21,7 +21,7 @@ struct KeybindingSchemaTests { key: "s", modifiers: KeybindingModifiers(command: true, control: true) ) - ), + ) ] ) @@ -93,7 +93,7 @@ struct KeybindingSchemaTests { let migratedOverrides: [String: KeybindingUserOverride] = [ "command.alpha": KeybindingUserOverride( binding: Keybinding(key: "m", modifiers: KeybindingModifiers(command: true)) - ), + ) ] let userOverrides = KeybindingUserOverrideStore( @@ -138,83 +138,83 @@ struct KeybindingSchemaTests { @Test func migrationMigratesLegacyCustomShortcutsAndCollectsUnmappedIssues() throws { let fixture = #""" - { - "customCommands": [ - { - "id": "build", - "title": "Build", - "systemImage": "hammer", - "command": "swift build", - "execution": "shellScript", - "shortcut": { - "key": " B ", - "modifiers": { - "command": true, - "shift": true, - "option": false, - "control": false + { + "customCommands": [ + { + "id": "build", + "title": "Build", + "systemImage": "hammer", + "command": "swift build", + "execution": "shellScript", + "shortcut": { + "key": " B ", + "modifiers": { + "command": true, + "shift": true, + "option": false, + "control": false + } } - } - }, - { - "id": "deploy", - "title": "Deploy", - "systemImage": "rocket", - "command": "make release", - "execution": "shellScript", - "shortcut": { - "key": "d", - "modifiers": { - "command": true, - "shift": false, - "option": false, - "control": false + }, + { + "id": "deploy", + "title": "Deploy", + "systemImage": "rocket", + "command": "make release", + "execution": "shellScript", + "shortcut": { + "key": "d", + "modifiers": { + "command": true, + "shift": false, + "option": false, + "control": false + } } - } - }, - { - "id": "bad-shortcut", - "title": "Bad", - "systemImage": "xmark", - "command": "echo bad", - "execution": "shellScript", - "shortcut": { - "key": "two", - "modifiers": { - "command": true, - "shift": false, - "option": false, - "control": false + }, + { + "id": "bad-shortcut", + "title": "Bad", + "systemImage": "xmark", + "command": "echo bad", + "execution": "shellScript", + "shortcut": { + "key": "two", + "modifiers": { + "command": true, + "shift": false, + "option": false, + "control": false + } } - } - }, - { - "id": "", - "title": "No ID", - "systemImage": "questionmark", - "command": "echo noid", - "execution": "shellScript", - "shortcut": { - "key": "n", - "modifiers": { - "command": true, - "shift": false, - "option": false, - "control": false + }, + { + "id": "", + "title": "No ID", + "systemImage": "questionmark", + "command": "echo noid", + "execution": "shellScript", + "shortcut": { + "key": "n", + "modifiers": { + "command": true, + "shift": false, + "option": false, + "control": false + } } + }, + { + "id": "without-shortcut", + "title": "No Shortcut", + "systemImage": "ellipsis", + "command": "echo none", + "execution": "shellScript", + "shortcut": null } - }, - { - "id": "without-shortcut", - "title": "No Shortcut", - "systemImage": "ellipsis", - "command": "echo none", - "execution": "shellScript", - "shortcut": null - } - ] - } - """# + ] + } + """# let legacySettings = try JSONDecoder().decode( LegacyCustomCommandShortcutFixture.self, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 3c89487b..b5f2740a 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -228,7 +228,7 @@ struct RepositoriesFeatureTests { await store.finish() let expectedSavedEntries = [ - [PersistedRepositoryEntry(path: root, kind: .git)], + [PersistedRepositoryEntry(path: root, kind: .git)] ] #expect(savedEntries.value == expectedSavedEntries) } @@ -317,7 +317,7 @@ struct RepositoriesFeatureTests { await store.finish() let expectedSavedEntries = [ - [PersistedRepositoryEntry(path: root, kind: .plain)], + [PersistedRepositoryEntry(path: root, kind: .plain)] ] #expect(savedEntries.value == expectedSavedEntries) } @@ -420,7 +420,7 @@ struct RepositoriesFeatureTests { [ PersistedRepositoryEntry(path: repoRoot, kind: .git), PersistedRepositoryEntry(path: plainRoot, kind: .plain), - ], + ] ] #expect(savedEntries.value == expectedSavedEntries) } @@ -484,7 +484,7 @@ struct RepositoriesFeatureTests { await store.finish() let expectedSavedEntries = [ - [PersistedRepositoryEntry(path: repoRoot, kind: .git)], + [PersistedRepositoryEntry(path: repoRoot, kind: .git)] ] #expect(savedEntries.value == expectedSavedEntries) } @@ -1324,7 +1324,7 @@ struct RepositoriesFeatureTests { id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) - ), + ) ] let store = TestStore(initialState: state) { RepositoriesFeature() @@ -1361,7 +1361,7 @@ struct RepositoriesFeatureTests { stage: .checkingRepositoryMode, worktreeName: "swift-otter" ) - ), + ) ] let store = TestStore(initialState: state) { RepositoriesFeature() @@ -1552,7 +1552,7 @@ struct RepositoriesFeatureTests { addedLines: nil, removedLines: nil, pullRequest: makePullRequest(state: "MERGED") - ), + ) ] let store = TestStore(initialState: state) { RepositoriesFeature() @@ -2234,7 +2234,7 @@ struct RepositoriesFeatureTests { id: removedWorktree.id, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .choosingWorktreeName) - ), + ) ] initialState.pinnedWorktreeIDs = [removedWorktree.id] initialState.worktreeInfoByID = [ @@ -2317,7 +2317,7 @@ struct RepositoriesFeatureTests { id: pendingID, repositoryID: repository.id, progress: WorktreeCreationProgress(stage: .loadingLocalBranches) - ), + ) ] initialState.selection = .worktree(pendingID) initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID] diff --git a/supacodeTests/RepositorySettingsFeatureTests.swift b/supacodeTests/RepositorySettingsFeatureTests.swift index 93839d3f..67318251 100644 --- a/supacodeTests/RepositorySettingsFeatureTests.swift +++ b/supacodeTests/RepositorySettingsFeatureTests.swift @@ -135,7 +135,7 @@ struct RepositorySettingsFeatureTests { key: "b", modifiers: UserCustomShortcutModifiers(command: true) ) - ), + ) ] ) diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 3020f2b1..d80e9e8e 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -302,4 +302,30 @@ struct SettingsFeatureTests { #expect(settingsFile.global.terminalFontSize == 18) #expect(capturedEvents.value.isEmpty) } + + @Test(.dependencies) func keybindingOverridesPersistAndFanOut() async { + var initialSettings = GlobalSettings.default + initialSettings.keybindingUserOverrides = .empty + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let overrides = KeybindingUserOverrideStore( + overrides: [ + AppShortcuts.CommandID.openSettings: KeybindingUserOverride( + binding: Keybinding(key: ";", modifiers: .init(command: true)) + ) + ] + ) + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.binding(.set(\.keybindingUserOverrides, overrides))) { + $0.keybindingUserOverrides = overrides + } + await store.receive(\.delegate.settingsChanged) + + #expect(settingsFile.global.keybindingUserOverrides == overrides) + } } diff --git a/supacodeTests/UserRepositorySettingsKeyTests.swift b/supacodeTests/UserRepositorySettingsKeyTests.swift index 4eff5a09..16d41e02 100644 --- a/supacodeTests/UserRepositorySettingsKeyTests.swift +++ b/supacodeTests/UserRepositorySettingsKeyTests.swift @@ -42,7 +42,7 @@ struct UserRepositorySettingsKeyTests { key: "u", modifiers: UserCustomShortcutModifiers(command: true) ) - ), + ) ] ) @@ -77,7 +77,7 @@ struct UserRepositorySettingsKeyTests { key: "u", modifiers: UserCustomShortcutModifiers(command: true) ) - ), + ) ] ) let encoder = JSONEncoder() diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 29d1ad6c..4e880a68 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -172,7 +172,7 @@ struct WorktreeTerminalManagerTests { title: "Unread", body: "body", isRead: false - ), + ) ] state.onNotificationIndicatorChanged?() state.notifications = [ @@ -181,7 +181,7 @@ struct WorktreeTerminalManagerTests { title: "Read", body: "body", isRead: true - ), + ) ] let stream = manager.eventStream()