diff --git a/docs-ai/002-custom-commands/005-global-custom-commands-action.md b/docs-ai/002-custom-commands/005-global-custom-commands-action.md index 8bc9fd6b..3b1e1f2f 100644 --- a/docs-ai/002-custom-commands/005-global-custom-commands-action.md +++ b/docs-ai/002-custom-commands/005-global-custom-commands-action.md @@ -5,8 +5,9 @@ | Date | Change | Ref | | --- | --- | --- | | 2026-07-13 | Added global command storage, resolution, settings UI, source-qualified routing, tests, and manual documentation. | `f481db26` | +| 2026-07-19 | Extracted the repository command table into the shared `CustomCommandsEditor` and adopted it for the global list (icon picker, inline editing, shortcut recording with Replace/Cancel conflicts, explicit +/− removal). Restored the unqualified palette ID for repository commands. | PR #582 review follow-up | -## Outcome & current state (as of 2026-07-13) +## Outcome & current state (as of 2026-07-19) - `UserGlobalSettings` persists `customCommands` in `~/.prowl/global.onevcat.json` through `UserGlobalSettingsKey`. @@ -18,12 +19,19 @@ the global binding. - Settings → Custom Commands edits the global list. The toolbar marks global entries and the overflow menu receives the source-qualified commands. +- Both settings surfaces embed `CustomCommandsEditor` + (`supacode/Features/Settings/Views/CustomCommandsEditor.swift`), parameterized by + `CustomCommandSource` so shortcut resolution targets the right binding namespace. The + editor owns its transient selection/popover/recording state; persistence stays with the + host reducers (`RepositorySettingsFeature.binding`, `GlobalCustomCommandsFeature.binding`). +- Palette IDs: repository commands keep the pre-global `custom-command.` form so + persisted palette recency survives; global commands use `custom-command.global.`, + mirroring the keybinding scheme. ## Deviations from plan -The global editor is intentionally compact rather than extracting the existing -repository editor into a shared view. It supports the same persisted command fields while -keeping the repository editor unchanged. +None. An initial compact global editor shipped first; the follow-up replaced it with the +shared editor extraction the plan originally called for. ## Open questions diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 65bd2f39..f5a7ae92 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -687,11 +687,11 @@ struct AppFeature { await terminalClient.send(.runScript(worktree, script: script)) } - case .runCustomCommand(let index): + case .runCustomCommand(let commandID): guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } - guard let effectiveCommand = state.selectedCustomCommands.first(where: { $0.id == index }) else { + guard let effectiveCommand = state.selectedCustomCommands.first(where: { $0.id == commandID }) else { return .none } let customCommand = effectiveCommand.command diff --git a/supacode/Features/Settings/Models/UserRepositorySettings.swift b/supacode/Features/Settings/Models/UserRepositorySettings.swift index 90a678ef..077782c3 100644 --- a/supacode/Features/Settings/Models/UserRepositorySettings.swift +++ b/supacode/Features/Settings/Models/UserRepositorySettings.swift @@ -158,8 +158,14 @@ nonisolated struct EffectiveCustomCommand: Equatable, Sendable, Identifiable { LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id, source: source) } + /// Repository commands keep the pre-global `custom-command.` form so + /// persisted palette recency survives; globals get their own namespace, + /// mirroring the keybinding ID scheme. var paletteID: String { - "custom-command.\(source.rawValue).\(command.id)" + switch source { + case .repository: "custom-command.\(command.id)" + case .global: "custom-command.global.\(command.id)" + } } static func resolve( diff --git a/supacode/Features/Settings/Reducer/GlobalCustomCommandsFeature.swift b/supacode/Features/Settings/Reducer/GlobalCustomCommandsFeature.swift index 316e008f..83990852 100644 --- a/supacode/Features/Settings/Reducer/GlobalCustomCommandsFeature.swift +++ b/supacode/Features/Settings/Reducer/GlobalCustomCommandsFeature.swift @@ -1,7 +1,6 @@ import ComposableArchitecture import Foundation import Sharing -import SwiftUI @Reducer struct GlobalCustomCommandsFeature { @@ -14,8 +13,6 @@ struct GlobalCustomCommandsFeature { enum Action: BindableAction { case task case settingsLoaded(UserGlobalSettings, KeybindingUserOverrideStore) - case addCommand - case removeCommands(IndexSet) case binding(BindingAction) case delegate(Delegate) } @@ -41,14 +38,6 @@ struct GlobalCustomCommandsFeature { state.keybindingUserOverrides = overrides return .none - case .addCommand: - state.settings.customCommands.append(.default(index: state.settings.customCommands.count)) - return persist(state.settings) - - case .removeCommands(let offsets): - state.settings.customCommands.remove(atOffsets: offsets) - return persist(state.settings) - case .binding: state.settings = state.settings.normalized() return persist(state.settings) diff --git a/supacode/Features/Settings/Views/RepositorySettingsCustomCommandsView.swift b/supacode/Features/Settings/Views/CustomCommandsEditor.swift similarity index 72% rename from supacode/Features/Settings/Views/RepositorySettingsCustomCommandsView.swift rename to supacode/Features/Settings/Views/CustomCommandsEditor.swift index 87bed00d..94eb51b5 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsCustomCommandsView.swift +++ b/supacode/Features/Settings/Views/CustomCommandsEditor.swift @@ -1,17 +1,71 @@ import AppKit -import ComposableArchitecture import SwiftUI -extension RepositorySettingsView { - @ViewBuilder - var customCommandsEditor: some View { +/// Shared inline table editor for custom commands. Hosts pass the command +/// list binding plus the command source so shortcut resolution and copy +/// stay scope-aware; the editor owns all transient selection, popover, and +/// shortcut-recording state itself. +struct CustomCommandsEditor: View { + @Binding var commands: [UserCustomCommand] + let source: CustomCommandSource + let keybindingUserOverrides: KeybindingUserOverrideStore + + @State private var selectedCustomCommandID: UserCustomCommand.ID? + @State private var recordingCustomCommandID: UserCustomCommand.ID? + @State private var recorderMonitor: Any? + @State private var invalidMessageByCommandID: [UserCustomCommand.ID: String] = [:] + @State private var pendingShortcutConflict: CustomCommandShortcutConflict? + @State private var pendingShortcut: PendingCustomShortcut? + @State private var iconPickerCommandID: UserCustomCommand.ID? + @State private var customCommandsFocusAnchor: NSView? + @State private var popoverRefocusTask: Task? + @State private var commandEditorCommandID: UserCustomCommand.ID? + @State private var editingNameCommandID: UserCustomCommand.ID? + @FocusState private var focusedNameEditorCommandID: UserCustomCommand.ID? + + private let keyTokenResolver = ShortcutKeyTokenResolver() + + static let symbolPresets = [ + "terminal", + "terminal.fill", + "play.fill", + "stop.fill", + "hammer.fill", + "shippingbox.fill", + "doc.text.fill", + "sparkles", + "bolt.fill", + "flame.fill", + "wand.and.stars", + "wrench.and.screwdriver.fill", + "checkmark.circle.fill", + "xmark.circle.fill", + "exclamationmark.triangle.fill", + "ladybug.fill", + "clock.fill", + "repeat", + "arrow.clockwise", + "folder.fill", + "archivebox.fill", + "paperplane.fill", + "cloud.fill", + "tray.and.arrow.down.fill", + "tray.and.arrow.up.fill", + "icloud.and.arrow.up.fill", + "square.and.arrow.up.fill", + "arrow.triangle.2.circlepath", + "folder.badge.plus", + "doc.badge.plus", + ] + + var body: some View { VStack(alignment: .leading, spacing: 10) { VStack(spacing: 0) { customCommandsHeaderRow Divider() ScrollView { LazyVStack(spacing: 4) { - ForEach(store.userSettings.customCommands) { command in + ForEach(commands) { command in customCommandRow(command) .id(command.id) } @@ -50,12 +104,12 @@ extension RepositorySettingsView { .accessibilityLabel("Remove selected command") } .buttonStyle(.plain) - .disabled(store.userSettings.customCommands.isEmpty) + .disabled(commands.isEmpty) .help("Remove selected command") Spacer(minLength: 0) - Text("\(store.userSettings.customCommands.count) commands") + Text("\(commands.count) commands") .font(.caption) .foregroundStyle(.secondary) } @@ -77,10 +131,62 @@ extension RepositorySettingsView { } .frame(width: 0, height: 0) } + .task { + syncSelectedCommandID(with: commands) + } + .onChange(of: commands) { _, commands in + syncSelectedCommandID(with: commands) + clearRemovedCommandState(using: commands) + } + .onChange(of: selectedCustomCommandID) { _, selectedID in + if editingNameCommandID != selectedID { + editingNameCommandID = nil + } + focusedNameEditorCommandID = nil + if let iconPickerCommandID, iconPickerCommandID != selectedID { + self.iconPickerCommandID = nil + } + if let commandEditorCommandID, commandEditorCommandID != selectedID { + self.commandEditorCommandID = nil + } + if let recordingCustomCommandID, recordingCustomCommandID != selectedID { + self.recordingCustomCommandID = nil + } + } + .onChange(of: recordingCustomCommandID) { _, commandID in + if commandID == nil { + stopRecorderMonitor() + } else { + startRecorderMonitor() + } + } + .onDisappear { + stopRecorderMonitor() + popoverRefocusTask?.cancel() + popoverRefocusTask = nil + focusedNameEditorCommandID = nil + } + .alert( + "Shortcut Conflict", + isPresented: isShortcutConflictAlertPresented, + presenting: pendingShortcutConflict + ) { _ in + Button("Replace", role: .destructive) { + applyPendingShortcut(replacingConflict: true) + } + Button("Cancel", role: .cancel) { + clearPendingShortcutConflict() + } + } message: { conflict in + Text( + "“\(conflict.newCommandTitle)” and “\(conflict.existingCommandTitle)” both use \(conflict.shortcutDisplay)." + + "\n\nChoose Replace to keep the new shortcut and clear the conflicting command." + ) + } } @ViewBuilder - func customCommandIconCell(_ command: UserCustomCommand) -> some View { + private func customCommandIconCell(_ command: UserCustomCommand) -> some View { if let binding = bindingForCustomCommand(id: command.id) { InlineEditableCellButton( isActive: iconPickerCommandID == command.id, @@ -122,7 +228,7 @@ extension RepositorySettingsView { } @ViewBuilder - func customCommandNameCell(_ command: UserCustomCommand) -> some View { + private func customCommandNameCell(_ command: UserCustomCommand) -> some View { let isSelected = selectedCustomCommandID == command.id if isSelected, editingNameCommandID == command.id, @@ -152,7 +258,7 @@ extension RepositorySettingsView { } @ViewBuilder - func customCommandCell(_ command: UserCustomCommand) -> some View { + private func customCommandCell(_ command: UserCustomCommand) -> some View { if let binding = bindingForCustomCommand(id: command.id) { InlineEditableCellButton( isActive: commandEditorCommandID == command.id @@ -198,7 +304,7 @@ extension RepositorySettingsView { } @ViewBuilder - func customCommandShortcutCell(_ command: UserCustomCommand) -> some View { + private func customCommandShortcutCell(_ command: UserCustomCommand) -> some View { let resolvedBinding = resolvedCustomCommandBindings.keybinding(for: customCommandBindingID(for: command.id)) let shortcutDisplay = resolvedBinding?.display ?? "Unassigned" let isRecording = recordingCustomCommandID == command.id @@ -225,13 +331,12 @@ extension RepositorySettingsView { .help(isRecording ? "Recording shortcut. Press Esc to cancel." : "Click to record a shortcut.") } - var effectiveSelectedCommandID: UserCustomCommand.ID? { + private var effectiveSelectedCommandID: UserCustomCommand.ID? { selectedCustomCommandID ?? editingNameCommandID ?? commandEditorCommandID ?? iconPickerCommandID ?? recordingCustomCommandID } - var removableCommandID: UserCustomCommand.ID? { - let commands = store.userSettings.customCommands + private var removableCommandID: UserCustomCommand.ID? { if let selectedCustomCommandID, commands.contains(where: { $0.id == selectedCustomCommandID }) { @@ -245,7 +350,7 @@ extension RepositorySettingsView { return commands.last?.id } - var customCommandsHeaderRow: some View { + private var customCommandsHeaderRow: some View { HStack(spacing: 8) { customCommandHeaderCell("", width: customCommandsIconColumnWidth, alignment: .center) customCommandHeaderCell("Name", width: customCommandsNameColumnWidth) @@ -259,7 +364,7 @@ extension RepositorySettingsView { } @ViewBuilder - func customCommandRow(_ command: UserCustomCommand) -> some View { + private func customCommandRow(_ command: UserCustomCommand) -> some View { let isSelected = selectedCustomCommandID == command.id HStack(spacing: 8) { customCommandRowCell(width: customCommandsIconColumnWidth, alignment: .center) { @@ -289,7 +394,7 @@ extension RepositorySettingsView { } @ViewBuilder - func customCommandHeaderCell( + private func customCommandHeaderCell( _ title: String, width: CGFloat? = nil, alignment: Alignment = .leading @@ -304,7 +409,7 @@ extension RepositorySettingsView { } @ViewBuilder - func customCommandRowCell( + private func customCommandRowCell( width: CGFloat? = nil, alignment: Alignment = .leading, @ViewBuilder content: () -> Content @@ -319,13 +424,13 @@ extension RepositorySettingsView { } } - func selectCustomCommand(_ commandID: UserCustomCommand.ID) { + private func selectCustomCommand(_ commandID: UserCustomCommand.ID) { if selectedCustomCommandID != commandID { selectedCustomCommandID = commandID } } - func inlineCommandTitle(for execution: UserCustomCommandExecution) -> String { + private func inlineCommandTitle(for execution: UserCustomCommandExecution) -> String { switch execution { case .shellScript: return "New Tab" @@ -336,7 +441,7 @@ extension RepositorySettingsView { } } - func inlineCommandScriptPreview(for script: String) -> String { + private func inlineCommandScriptPreview(for script: String) -> String { let firstLine = script .split(separator: "\n", omittingEmptySubsequences: false) @@ -346,7 +451,7 @@ extension RepositorySettingsView { return firstLine.isEmpty ? "Click to set command script" : firstLine } - func iconEditorPopover( + private func iconEditorPopover( for command: Binding, commandID: UserCustomCommand.ID ) -> some View { @@ -391,11 +496,11 @@ extension RepositorySettingsView { .frame(width: 360) } - func commandEditorPopover(for command: Binding) -> some View { + private func commandEditorPopover(for command: Binding) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Command") .font(.headline) - Text("Choose where this command runs and edit the script used by this repository custom command.") + Text(commandEditorDescription) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -443,14 +548,23 @@ extension RepositorySettingsView { .frame(width: 420) } - var selectedCommandInvalidMessage: String? { + private var commandEditorDescription: String { + switch source { + case .repository: + return "Choose where this command runs and edit the script used by this repository custom command." + case .global: + return "Choose where this command runs and edit the script used by this global custom command." + } + } + + private var selectedCommandInvalidMessage: String? { guard let selectedCustomCommandID else { return nil } return invalidMessageByCommandID[selectedCustomCommandID] } - func openSFSymbolsReference() { + private func openSFSymbolsReference() { if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.SFSymbols") { let configuration = NSWorkspace.OpenConfiguration() NSWorkspace.shared.openApplication(at: appURL, configuration: configuration) { _, _ in } @@ -462,7 +576,7 @@ extension RepositorySettingsView { NSWorkspace.shared.open(url) } - func toggleIconEditor(for commandID: UserCustomCommand.ID) { + private func toggleIconEditor(for commandID: UserCustomCommand.ID) { if iconPickerCommandID == commandID { closePopoverAndRestoreCommandFocus(for: commandID) return @@ -475,7 +589,7 @@ extension RepositorySettingsView { } } - func toggleCommandEditor(for commandID: UserCustomCommand.ID) { + private func toggleCommandEditor(for commandID: UserCustomCommand.ID) { if commandEditorCommandID == commandID { closePopoverAndRestoreCommandFocus(for: commandID) return @@ -488,7 +602,7 @@ extension RepositorySettingsView { } } - func beginNameEditing(for commandID: UserCustomCommand.ID) { + private func beginNameEditing(for commandID: UserCustomCommand.ID) { editingNameCommandID = commandID iconPickerCommandID = nil commandEditorCommandID = nil @@ -498,12 +612,12 @@ extension RepositorySettingsView { focusedNameEditorCommandID = commandID } - func endNameEditing() { + private func endNameEditing() { editingNameCommandID = nil focusedNameEditorCommandID = nil } - func closePopoverAndRestoreCommandFocus(for commandID: UserCustomCommand.ID) { + private func closePopoverAndRestoreCommandFocus(for commandID: UserCustomCommand.ID) { popoverRefocusTask?.cancel() var transaction = Transaction() @@ -516,7 +630,7 @@ extension RepositorySettingsView { scheduleCommandFocusRestore(for: commandID) } - func focusCustomCommandsArea() { + private func focusCustomCommandsArea() { guard let window = NSApp.keyWindow else { return } @@ -529,7 +643,7 @@ extension RepositorySettingsView { _ = window.makeFirstResponder(nil) } - func scheduleCommandFocusRestore(for commandID: UserCustomCommand.ID) { + private func scheduleCommandFocusRestore(for commandID: UserCustomCommand.ID) { popoverRefocusTask = Task { @MainActor in await Task.yield() guard !Task.isCancelled else { @@ -538,7 +652,7 @@ extension RepositorySettingsView { guard iconPickerCommandID == nil, commandEditorCommandID == nil else { return } - guard store.userSettings.customCommands.contains(where: { $0.id == commandID }) else { + guard commands.contains(where: { $0.id == commandID }) else { return } @@ -551,7 +665,7 @@ extension RepositorySettingsView { } } - func scriptPlaceholder(for execution: UserCustomCommandExecution) -> String { + private func scriptPlaceholder(for execution: UserCustomCommandExecution) -> String { switch execution { case .shellScript: return "npm test && swift test" @@ -562,7 +676,7 @@ extension RepositorySettingsView { } } - func scriptDescription(for execution: UserCustomCommandExecution) -> String { + private func scriptDescription(for execution: UserCustomCommandExecution) -> String { switch execution { case .shellScript: return "Runs in a new terminal tab." @@ -573,28 +687,28 @@ extension RepositorySettingsView { } } - var resolvedCustomCommandBindings: ResolvedKeybindingMap { - let commands = store.userSettings.customCommands - let migration = LegacyCustomCommandShortcutMigration.migrate(commands: commands) + private var resolvedCustomCommandBindings: ResolvedKeybindingMap { + let effectiveCommands = commands.map { EffectiveCustomCommand(source: source, command: $0) } + let migration = LegacyCustomCommandShortcutMigration.migrate(commands: effectiveCommands) return KeybindingResolver.resolve( - schema: .appResolverSchema(customCommands: commands), - userOverrides: store.keybindingUserOverrides, + schema: .appResolverSchema(effectiveCustomCommands: effectiveCommands), + userOverrides: keybindingUserOverrides, migratedOverrides: migration.overrides ) } - func customCommandBindingID(for commandID: String) -> String { - LegacyCustomCommandShortcutMigration.customCommandBindingID(for: commandID) + private func customCommandBindingID(for commandID: String) -> String { + LegacyCustomCommandShortcutMigration.customCommandBindingID(for: commandID, source: source) } - func bindingForCustomCommand(id commandID: UserCustomCommand.ID) -> Binding? { - guard store.userSettings.customCommands.contains(where: { $0.id == commandID }) else { + private func bindingForCustomCommand(id commandID: UserCustomCommand.ID) -> Binding? { + guard commands.contains(where: { $0.id == commandID }) else { return nil } return Binding( get: { - store.userSettings.customCommands.first(where: { $0.id == commandID }) + commands.first(where: { $0.id == commandID }) ?? UserCustomCommand( id: commandID, title: "", @@ -618,7 +732,7 @@ extension RepositorySettingsView { ) } - func syncSelectedCommandID(with commands: [UserCustomCommand]) { + private func syncSelectedCommandID(with commands: [UserCustomCommand]) { guard !commands.isEmpty else { selectedCustomCommandID = nil recordingCustomCommandID = nil @@ -638,7 +752,7 @@ extension RepositorySettingsView { selectedCustomCommandID = commands[0].id } - func clearRemovedCommandState(using commands: [UserCustomCommand]) { + private func clearRemovedCommandState(using commands: [UserCustomCommand]) { let validIDs = Set(commands.map(\.id)) invalidMessageByCommandID = invalidMessageByCommandID.filter { validIDs.contains($0.key) } @@ -669,11 +783,9 @@ extension RepositorySettingsView { } } - func addCustomCommand() { - let commandsBinding = $store.userSettings.customCommands - let current = commandsBinding.wrappedValue - let next = UserRepositorySettings.normalizedCommands(current + [.default(index: current.count)]) - commandsBinding.wrappedValue = next + private func addCustomCommand() { + let next = UserCustomCommand.normalizedCommands(commands + [.default(index: commands.count)]) + commands = next guard let commandID = next.last?.id else { selectedCustomCommandID = nil editingNameCommandID = nil @@ -688,20 +800,19 @@ extension RepositorySettingsView { recordingCustomCommandID = nil } - func removeSelectedCustomCommand() { + private func removeSelectedCustomCommand() { guard let selectedCommandID = removableCommandID else { return } - let commandsBinding = $store.userSettings.customCommands - var commands = commandsBinding.wrappedValue + var updatedCommands = commands let removalIndex: Int? - if let index = commands.firstIndex(where: { $0.id == selectedCommandID }) { + if let index = updatedCommands.firstIndex(where: { $0.id == selectedCommandID }) { removalIndex = index - commands.remove(at: index) - } else if !commands.isEmpty { - removalIndex = commands.count - 1 - commands.removeLast() + updatedCommands.remove(at: index) + } else if !updatedCommands.isEmpty { + removalIndex = updatedCommands.count - 1 + updatedCommands.removeLast() } else { removalIndex = nil } @@ -710,8 +821,8 @@ extension RepositorySettingsView { return } - let normalizedCommands = UserRepositorySettings.normalizedCommands(commands) - commandsBinding.wrappedValue = normalizedCommands + let normalizedCommands = UserCustomCommand.normalizedCommands(updatedCommands) + commands = normalizedCommands if normalizedCommands.isEmpty { selectedCustomCommandID = nil @@ -723,7 +834,7 @@ extension RepositorySettingsView { clearRemovedCommandState(using: normalizedCommands) } - func clearShortcut(for commandID: UserCustomCommand.ID) { + private func clearShortcut(for commandID: UserCustomCommand.ID) { invalidMessageByCommandID[commandID] = nil updateCustomCommand(id: commandID) { command in command.shortcut = nil @@ -733,21 +844,20 @@ extension RepositorySettingsView { } } - func updateCustomCommand( + private func updateCustomCommand( id: UserCustomCommand.ID, update: (inout UserCustomCommand) -> Void ) { - let commandsBinding = $store.userSettings.customCommands - var commands = commandsBinding.wrappedValue - guard let index = commands.firstIndex(where: { $0.id == id }) else { + var updatedCommands = commands + guard let index = updatedCommands.firstIndex(where: { $0.id == id }) else { return } - update(&commands[index]) - commandsBinding.wrappedValue = UserRepositorySettings.normalizedCommands(commands) + update(&updatedCommands[index]) + commands = UserCustomCommand.normalizedCommands(updatedCommands) } - func toggleRecording(for commandID: UserCustomCommand.ID) { + private func toggleRecording(for commandID: UserCustomCommand.ID) { invalidMessageByCommandID[commandID] = nil iconPickerCommandID = nil commandEditorCommandID = nil @@ -761,7 +871,7 @@ extension RepositorySettingsView { recordingCustomCommandID = commandID } - func startRecorderMonitor() { + private func startRecorderMonitor() { stopRecorderMonitor() recorderMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in guard let commandID = recordingCustomCommandID else { @@ -772,14 +882,14 @@ extension RepositorySettingsView { } } - func stopRecorderMonitor() { + private func stopRecorderMonitor() { if let recorderMonitor { NSEvent.removeMonitor(recorderMonitor) self.recorderMonitor = nil } } - func handleRecorderEvent(_ event: NSEvent, commandID: UserCustomCommand.ID) { + private func handleRecorderEvent(_ event: NSEvent, commandID: UserCustomCommand.ID) { if event.keyCode == 53 { // Escape recordingCustomCommandID = nil return @@ -817,7 +927,7 @@ extension RepositorySettingsView { applyRecordedShortcut(shortcut.normalized(), to: commandID) } - func applyRecordedShortcut( + private func applyRecordedShortcut( _ shortcut: UserCustomShortcut, to commandID: UserCustomCommand.ID ) { @@ -832,7 +942,7 @@ extension RepositorySettingsView { } let newTitle = - store.userSettings.customCommands.first(where: { $0.id == commandID })?.resolvedTitle ?? "Command" + commands.first(where: { $0.id == commandID })?.resolvedTitle ?? "Command" pendingShortcutConflict = CustomCommandShortcutConflict( newCommandID: commandID, @@ -845,18 +955,18 @@ extension RepositorySettingsView { recordingCustomCommandID = nil } - func firstConflictingCommand( + private func firstConflictingCommand( for commandID: UserCustomCommand.ID, shortcut: UserCustomShortcut ) -> UserCustomCommand? { - store.userSettings.customCommands.first { command in + commands.first { command in guard command.id != commandID else { return false } guard let existingShortcut = command.shortcut?.normalized() else { return false } return existingShortcut == shortcut } } - func applyPendingShortcut(replacingConflict: Bool) { + private func applyPendingShortcut(replacingConflict: Bool) { guard let pendingShortcut else { clearPendingShortcutConflict() return @@ -877,12 +987,12 @@ extension RepositorySettingsView { clearPendingShortcutConflict() } - func clearPendingShortcutConflict() { + private func clearPendingShortcutConflict() { pendingShortcutConflict = nil pendingShortcut = nil } - var isShortcutConflictAlertPresented: Binding { + private var isShortcutConflictAlertPresented: Binding { Binding( get: { pendingShortcutConflict != nil }, set: { shouldPresent in @@ -893,11 +1003,11 @@ extension RepositorySettingsView { ) } - var customCommandsIconColumnWidth: CGFloat { 48 } + private var customCommandsIconColumnWidth: CGFloat { 48 } - var customCommandsNameColumnWidth: CGFloat { 130 } + private var customCommandsNameColumnWidth: CGFloat { 130 } - var customCommandsShortcutColumnWidth: CGFloat { 100 } + private var customCommandsShortcutColumnWidth: CGFloat { 100 } - var customCommandsListHeight: CGFloat { 200 } + private var customCommandsListHeight: CGFloat { 200 } } diff --git a/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift b/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift index e5483741..1d524cf8 100644 --- a/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift +++ b/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift @@ -7,26 +7,20 @@ struct GlobalCustomCommandsView: View { var body: some View { Form { Section { - Text("Global commands are available in every repository. A local command with the same title takes precedence.") + CustomCommandsEditor( + commands: $store.settings.customCommands, + source: .global, + keybindingUserOverrides: store.keybindingUserOverrides + ) + } header: { + VStack(alignment: .leading, spacing: 4) { + Text("Custom Commands") + Text( + "Global terminal actions available in every repository. " + + "A local command with the same title takes precedence." + ) .foregroundStyle(.secondary) - } - - Section { - if store.settings.customCommands.isEmpty { - ContentUnavailableView("No Global Commands", systemImage: "globe") - .frame(maxWidth: .infinity) - } else { - ForEach($store.settings.customCommands) { $command in - GlobalCustomCommandRow(command: $command) - } - .onDelete { store.send(.removeCommands($0)) } - } - Button("Add Command", systemImage: "plus") { - store.send(.addCommand) } - .help("Add global custom command") - } header: { - Text("Commands") } } .formStyle(.grouped) @@ -34,74 +28,3 @@ struct GlobalCustomCommandsView: View { .task { store.send(.task) } } } - -private struct GlobalCustomCommandRow: View { - @Binding var command: UserCustomCommand - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - TextField("Title", text: $command.title) - TextField("Symbol", text: $command.systemImage) - .frame(width: 160) - } - TextField("Command", text: $command.command, axis: .vertical) - .lineLimit(2...5) - .font(.body.monospaced()) - Picker("Run", selection: $command.execution) { - ForEach(UserCustomCommandExecution.allCases) { execution in - Text(execution.title).tag(execution) - } - } - if command.execution == .split { - Picker("Split", selection: $command.splitDirection) { - ForEach(UserCustomSplitDirection.allCases) { direction in - Text(direction.title).tag(direction) - } - } - } - if command.execution.supportsCloseOnSuccess { - Toggle("Close terminal after success", isOn: $command.closeOnSuccess) - } - HStack { - TextField("Shortcut key", text: shortcutKey) - .frame(width: 120) - Toggle("⌘", isOn: shortcutModifier(\.command)) - Toggle("⇧", isOn: shortcutModifier(\.shift)) - Toggle("⌥", isOn: shortcutModifier(\.option)) - Toggle("⌃", isOn: shortcutModifier(\.control)) - } - .font(.caption) - } - .padding(.vertical, 4) - } - - private var shortcutKey: Binding { - Binding( - get: { command.shortcut?.key ?? "" }, - set: { key in - guard !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - command.shortcut = nil - return - } - command.shortcut = UserCustomShortcut( - key: key, - modifiers: command.shortcut?.modifiers ?? .init() - ) - } - ) - } - - private func shortcutModifier( - _ keyPath: WritableKeyPath - ) -> Binding { - Binding( - get: { command.shortcut?.modifiers[keyPath: keyPath] ?? (keyPath == \.command) }, - set: { value in - var modifiers = command.shortcut?.modifiers ?? .init() - modifiers[keyPath: keyPath] = value - command.shortcut = UserCustomShortcut(key: command.shortcut?.key ?? "", modifiers: modifiers) - } - ) - } -} diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index adad553a..0abe3ede 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -8,54 +8,6 @@ struct RepositorySettingsView: View { @State private var branchSearchText = "" @State private var githubIdentityViewModel = RepositoryGithubIdentityViewModel() - @State var selectedCustomCommandID: UserCustomCommand.ID? - @State var recordingCustomCommandID: UserCustomCommand.ID? - @State var recorderMonitor: Any? - @State var invalidMessageByCommandID: [UserCustomCommand.ID: String] = [:] - @State var pendingShortcutConflict: CustomCommandShortcutConflict? - @State var pendingShortcut: PendingCustomShortcut? - @State var iconPickerCommandID: UserCustomCommand.ID? - @State var customCommandsFocusAnchor: NSView? - @State var popoverRefocusTask: Task? - @State var commandEditorCommandID: UserCustomCommand.ID? - @State var editingNameCommandID: UserCustomCommand.ID? - @FocusState var focusedNameEditorCommandID: UserCustomCommand.ID? - - let keyTokenResolver = ShortcutKeyTokenResolver() - - static let symbolPresets = [ - "terminal", - "terminal.fill", - "play.fill", - "stop.fill", - "hammer.fill", - "shippingbox.fill", - "doc.text.fill", - "sparkles", - "bolt.fill", - "flame.fill", - "wand.and.stars", - "wrench.and.screwdriver.fill", - "checkmark.circle.fill", - "xmark.circle.fill", - "exclamationmark.triangle.fill", - "ladybug.fill", - "clock.fill", - "repeat", - "arrow.clockwise", - "folder.fill", - "archivebox.fill", - "paperplane.fill", - "cloud.fill", - "tray.and.arrow.down.fill", - "tray.and.arrow.up.fill", - "icloud.and.arrow.up.fill", - "square.and.arrow.up.fill", - "arrow.triangle.2.circlepath", - "folder.badge.plus", - "doc.badge.plus", - ] - var body: some View { let baseRefOptions = store.branchOptions.isEmpty ? [store.defaultWorktreeBaseRef] : store.branchOptions @@ -370,7 +322,11 @@ struct RepositorySettingsView: View { if store.showsCustomCommandsSettings { Section { - customCommandsEditor + CustomCommandsEditor( + commands: $store.userSettings.customCommands, + source: .repository, + keybindingUserOverrides: store.keybindingUserOverrides + ) } header: { VStack(alignment: .leading, spacing: 4) { Text("Custom Commands") @@ -387,56 +343,6 @@ struct RepositorySettingsView: View { .task { store.send(.task) await githubIdentityViewModel.load() - syncSelectedCommandID(with: store.userSettings.customCommands) - } - .onChange(of: store.userSettings.customCommands) { _, commands in - syncSelectedCommandID(with: commands) - clearRemovedCommandState(using: commands) - } - .onChange(of: selectedCustomCommandID) { _, selectedID in - if editingNameCommandID != selectedID { - editingNameCommandID = nil - } - focusedNameEditorCommandID = nil - if let iconPickerCommandID, iconPickerCommandID != selectedID { - self.iconPickerCommandID = nil - } - if let commandEditorCommandID, commandEditorCommandID != selectedID { - self.commandEditorCommandID = nil - } - if let recordingCustomCommandID, recordingCustomCommandID != selectedID { - self.recordingCustomCommandID = nil - } - } - .onChange(of: recordingCustomCommandID) { _, commandID in - if commandID == nil { - stopRecorderMonitor() - } else { - startRecorderMonitor() - } - } - .onDisappear { - stopRecorderMonitor() - popoverRefocusTask?.cancel() - popoverRefocusTask = nil - focusedNameEditorCommandID = nil - } - .alert( - "Shortcut Conflict", - isPresented: isShortcutConflictAlertPresented, - presenting: pendingShortcutConflict - ) { _ in - Button("Replace", role: .destructive) { - applyPendingShortcut(replacingConflict: true) - } - Button("Cancel", role: .cancel) { - clearPendingShortcutConflict() - } - } message: { conflict in - Text( - "“\(conflict.newCommandTitle)” and “\(conflict.existingCommandTitle)” both use \(conflict.shortcutDisplay)." - + "\n\nChoose Replace to keep the new shortcut and clear the conflicting command." - ) } } } diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index 0c0acaed..25879b72 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -241,11 +241,11 @@ struct CommandPaletteFeatureTests { ] ) let ids = Set(items.map(\.id)) - #expect(ids.contains("custom-command.repository.cmd-build")) + #expect(ids.contains("custom-command.cmd-build")) // Commands with empty body are filtered out. - #expect(!ids.contains("custom-command.repository.cmd-empty")) + #expect(!ids.contains("custom-command.cmd-empty")) - let buildItem = items.first { $0.id == "custom-command.repository.cmd-build" } + let buildItem = items.first { $0.id == "custom-command.cmd-build" } #expect(buildItem?.title == "Build") #expect(buildItem?.subtitle == "Local custom command · Opens in a new tab") #expect(buildItem?.defaultSuggestion == false) @@ -325,15 +325,15 @@ struct CommandPaletteFeatureTests { ) #expect( - items.first { $0.id == "custom-command.repository.cmd-shell" }?.subtitle + items.first { $0.id == "custom-command.cmd-shell" }?.subtitle == "Local custom command · Opens in a new tab" ) #expect( - items.first { $0.id == "custom-command.repository.cmd-inline" }?.subtitle + items.first { $0.id == "custom-command.cmd-inline" }?.subtitle == "Local custom command · Runs in the focused terminal" ) #expect( - items.first { $0.id == "custom-command.repository.cmd-split" }?.subtitle + items.first { $0.id == "custom-command.cmd-split" }?.subtitle == "Local custom command · Opens in a new split (down)" ) } diff --git a/supacodeTests/EffectiveCustomCommandTests.swift b/supacodeTests/EffectiveCustomCommandTests.swift index 9e2e4f32..b45f373c 100644 --- a/supacodeTests/EffectiveCustomCommandTests.swift +++ b/supacodeTests/EffectiveCustomCommandTests.swift @@ -29,7 +29,7 @@ struct EffectiveCustomCommandTests { #expect(local.id != global.id) #expect(local.keybindingID == "custom_command.same") #expect(global.keybindingID == "custom_command.global.same") - #expect(local.paletteID == "custom-command.repository.same") + #expect(local.paletteID == "custom-command.same") #expect(global.paletteID == "custom-command.global.same") } diff --git a/supacodeTests/GlobalCustomCommandsFeatureTests.swift b/supacodeTests/GlobalCustomCommandsFeatureTests.swift new file mode 100644 index 00000000..5d301b83 --- /dev/null +++ b/supacodeTests/GlobalCustomCommandsFeatureTests.swift @@ -0,0 +1,68 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct GlobalCustomCommandsFeatureTests { + @Test(.dependencies) func taskLoadsPersistedGlobalSettings() async throws { + let storage = UserGlobalSettingsTestStorage() + let url = URL(fileURLWithPath: "/tmp/prowl-global-settings-\(UUID().uuidString).json") + let persisted = UserGlobalSettings(customCommands: [makeCommand()]) + let encoder = JSONEncoder() + try storage.storage.save(try encoder.encode(persisted), url) + + let store = TestStore(initialState: GlobalCustomCommandsFeature.State()) { + GlobalCustomCommandsFeature() + } withDependencies: { + $0.settingsFileStorage = storage.storage + $0.userGlobalSettingsURL = url + } + + await store.send(.task) + await store.receive(\.settingsLoaded) { + $0.settings = persisted + } + } + + @Test(.dependencies) func bindingNormalizesPersistsAndNotifiesDelegate() async throws { + let storage = UserGlobalSettingsTestStorage() + let url = URL(fileURLWithPath: "/tmp/prowl-global-settings-\(UUID().uuidString).json") + let command = makeCommand( + shortcut: UserCustomShortcut(key: " B ", modifiers: .init(command: true, shift: true)) + ) + let expected = UserGlobalSettings(customCommands: [command]) + + let store = TestStore(initialState: GlobalCustomCommandsFeature.State()) { + GlobalCustomCommandsFeature() + } withDependencies: { + $0.settingsFileStorage = storage.storage + $0.userGlobalSettingsURL = url + } + + await store.send(.binding(.set(\.settings, UserGlobalSettings(customCommands: [command])))) { + $0.settings = expected + } + await store.receive(\.delegate.settingsChanged) + + let saved = try JSONDecoder().decode( + UserGlobalSettings.self, + from: #require(storage.data(at: url)) + ) + #expect(saved == expected) + #expect(saved.customCommands.first?.shortcut?.key == "b") + } + + private func makeCommand(shortcut: UserCustomShortcut? = nil) -> UserCustomCommand { + UserCustomCommand( + id: "global-build", + title: "Build Everywhere", + systemImage: "globe", + command: "make build", + execution: .shellScript, + shortcut: shortcut + ) + } +}