diff --git a/supacode/Features/Settings/BusinessLogic/ShortcutConflictDetector.swift b/supacode/Features/Settings/BusinessLogic/ShortcutConflictDetector.swift new file mode 100644 index 00000000..85a2c08a --- /dev/null +++ b/supacode/Features/Settings/BusinessLogic/ShortcutConflictDetector.swift @@ -0,0 +1,39 @@ +import Foundation + +enum ShortcutConflictDetector { + static func firstConflictCommandID( + commandID: String, + binding: Keybinding, + policy: KeybindingConflictPolicy, + schema: KeybindingSchemaDocument, + userOverrides: KeybindingUserOverrideStore + ) -> String? { + guard shouldWarnForConflict(policy: policy) else { + return nil + } + + var tentative = userOverrides + tentative.overrides[commandID] = KeybindingUserOverride(binding: binding) + + let resolved = KeybindingResolver.resolve( + schema: schema, + userOverrides: tentative + ) + + for command in schema.commands where command.allowUserOverride && command.id != commandID { + guard resolved.binding(for: command.id)?.binding == binding else { continue } + return command.id + } + + return nil + } + + private static func shouldWarnForConflict(policy: KeybindingConflictPolicy) -> Bool { + switch policy { + case .warnAndPreferUserOverride, .localOnly: + return true + case .disallowUserOverride: + return false + } + } +} diff --git a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift index 91246245..a7264254 100644 --- a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift +++ b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift @@ -538,29 +538,28 @@ struct ShortcutsSettingsView: View { 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( + guard let existingCommandID = ShortcutConflictDetector.firstConflictCommandID( + commandID: commandID, + binding: binding, + policy: policy, schema: .appResolverSchema(), - userOverrides: tentative - ) + userOverrides: store.keybindingUserOverrides + ) else { + return nil + } - 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 - ) + guard let existingCommand = editableCommands.first(where: { $0.id == existingCommandID }) else { + return nil } - return nil + let newTitle = editableCommands.first(where: { $0.id == commandID })?.title ?? commandID + return ShortcutConflict( + newCommandID: commandID, + newCommandTitle: newTitle, + existingCommandID: existingCommand.id, + existingCommandTitle: existingCommand.title, + binding: binding + ) } private func applyPendingOverride(replacingConflict: Bool) { diff --git a/supacodeTests/ShortcutConflictDetectorTests.swift b/supacodeTests/ShortcutConflictDetectorTests.swift new file mode 100644 index 00000000..9582a54f --- /dev/null +++ b/supacodeTests/ShortcutConflictDetectorTests.swift @@ -0,0 +1,124 @@ +import Testing + +@testable import supacode + +struct ShortcutConflictDetectorTests { + @Test func localOnlyPolicyWarnsWhenBindingConflictsWithAppAction() { + let globalID = "command.global" + let localID = "command.local" + let conflictBinding = binding("r", modifiers: .init(command: true, shift: true)) + + let schema = testSchema([ + testCommand( + id: globalID, + title: "Refresh Worktrees", + conflictPolicy: .warnAndPreferUserOverride, + defaultBinding: conflictBinding + ), + testCommand( + id: localID, + title: "Rename Branch", + conflictPolicy: .localOnly, + defaultBinding: binding("m", modifiers: .init(command: true, shift: true)) + ), + ]) + + let conflictID = ShortcutConflictDetector.firstConflictCommandID( + commandID: localID, + binding: conflictBinding, + policy: .localOnly, + schema: schema, + userOverrides: .empty + ) + + #expect(conflictID == globalID) + } + + @Test func disallowPolicyDoesNotWarn() { + let schema = testSchema([ + testCommand( + id: "command.fixed", + title: "Fixed", + conflictPolicy: .disallowUserOverride, + defaultBinding: binding("a", modifiers: .init(command: true)) + ), + testCommand( + id: "command.other", + title: "Other", + conflictPolicy: .warnAndPreferUserOverride, + defaultBinding: binding("a", modifiers: .init(command: true)) + ), + ]) + + let conflictID = ShortcutConflictDetector.firstConflictCommandID( + commandID: "command.fixed", + binding: binding("a", modifiers: .init(command: true)), + policy: .disallowUserOverride, + schema: schema, + userOverrides: .empty + ) + + #expect(conflictID == nil) + } + + @Test func returnsNilWhenNoConflict() { + let schema = testSchema([ + testCommand( + id: "command.one", + title: "One", + conflictPolicy: .warnAndPreferUserOverride, + defaultBinding: binding("a", modifiers: .init(command: true)) + ), + testCommand( + id: "command.two", + title: "Two", + conflictPolicy: .localOnly, + defaultBinding: binding("b", modifiers: .init(command: true)) + ), + ]) + + let conflictID = ShortcutConflictDetector.firstConflictCommandID( + commandID: "command.two", + binding: binding("c", modifiers: .init(command: true)), + policy: .localOnly, + schema: schema, + userOverrides: .empty + ) + + #expect(conflictID == nil) + } + + private func testSchema(_ commands: [KeybindingCommandSchema]) -> KeybindingSchemaDocument { + KeybindingSchemaDocument( + version: KeybindingSchemaDocument.currentVersion, + commands: commands + ) + } + + private func testCommand( + id: String, + title: String, + conflictPolicy: KeybindingConflictPolicy, + defaultBinding: Keybinding + ) -> KeybindingCommandSchema { + KeybindingCommandSchema( + id: id, + title: title, + scope: .configurableAppAction, + platform: .macOS, + allowUserOverride: true, + conflictPolicy: conflictPolicy, + defaultBinding: defaultBinding + ) + } + + private func binding( + _ key: String, + modifiers: KeybindingModifiers + ) -> Keybinding { + Keybinding( + key: key, + modifiers: modifiers + ) + } +}