diff --git a/supacode/Features/App/Reducer/AppFeature+Support.swift b/supacode/Features/App/Reducer/AppFeature+Support.swift index dc6c3269..259e6605 100644 --- a/supacode/Features/App/Reducer/AppFeature+Support.swift +++ b/supacode/Features/App/Reducer/AppFeature+Support.swift @@ -193,7 +193,8 @@ extension AppFeature { ) -> Effect { state.selectedCustomCommands = EffectiveCustomCommand.resolve( repositoryCommands: settings.customCommands, - globalCommands: globalSettings.customCommands + globalCommands: globalSettings.customCommands, + disabledGlobalCommandIDs: settings.disabledGlobalCommandIDs ) state.resolvedKeybindings = resolvedKeybindings( settings: state.settings, diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index 851ed26b..7a7ca10e 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -97,6 +97,7 @@ struct RepositorySettingsFeature { case userImageImportFailed(String) case dismissAppearanceImportError case resetAppearance + case setGlobalCommandEnabled(UserCustomCommand.ID, Bool) case branchDataLoaded([String], defaultBaseRef: String) case delegate(Delegate) case binding(BindingAction) @@ -203,6 +204,16 @@ struct RepositorySettingsFeature { $repositorySettings.withLock { $0 = updatedSettings } return .send(.delegate(.settingsChanged(rootURL))) + case .setGlobalCommandEnabled(let commandID, let isEnabled): + guard state.userSettings.isGlobalCommandEnabled(commandID) != isEnabled else { + return .none + } + state.userSettings.setGlobalCommandEnabled(isEnabled, id: commandID) + let rootURL = state.rootURL + @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings + $userRepositorySettings.withLock { $0 = state.userSettings } + return .send(.delegate(.settingsChanged(rootURL))) + case .appearanceLoaded(let appearance): state.appearance = appearance return .none diff --git a/supacode/Features/Settings/Views/CustomCommandsEditor.swift b/supacode/Features/Settings/Views/CustomCommandsEditor.swift index 94eb51b5..b44faa46 100644 --- a/supacode/Features/Settings/Views/CustomCommandsEditor.swift +++ b/supacode/Features/Settings/Views/CustomCommandsEditor.swift @@ -9,6 +9,22 @@ struct CustomCommandsEditor: View { @Binding var commands: [UserCustomCommand] let source: CustomCommandSource let keybindingUserOverrides: KeybindingUserOverrideStore + let globalCommands: [UserCustomCommand] + let globalCommandEnabled: ((UserCustomCommand.ID) -> Binding)? + + init( + commands: Binding<[UserCustomCommand]>, + source: CustomCommandSource, + keybindingUserOverrides: KeybindingUserOverrideStore, + globalCommands: [UserCustomCommand] = [], + globalCommandEnabled: ((UserCustomCommand.ID) -> Binding)? = nil + ) { + _commands = commands + self.source = source + self.keybindingUserOverrides = keybindingUserOverrides + self.globalCommands = globalCommands + self.globalCommandEnabled = globalCommandEnabled + } @State private var selectedCustomCommandID: UserCustomCommand.ID? @State private var recordingCustomCommandID: UserCustomCommand.ID? @@ -69,6 +85,13 @@ struct CustomCommandsEditor: View { customCommandRow(command) .id(command.id) } + if showsGlobalCommands { + localCommandDropTarget + ForEach(globalCommands) { command in + globalCustomCommandRow(command) + .id("global-\(command.id)") + } + } } .padding(.horizontal, 6) .padding(.vertical, 6) @@ -109,7 +132,7 @@ struct CustomCommandsEditor: View { Spacer(minLength: 0) - Text("\(commands.count) commands") + Text("\(displayedCommandCount) commands") .font(.caption) .foregroundStyle(.secondary) } @@ -118,9 +141,13 @@ struct CustomCommandsEditor: View { .font(.caption) .foregroundStyle(.red) } else { - Text("Click cells to edit icon, name, command, and shortcut inline.") - .font(.caption) - .foregroundStyle(.secondary) + Text( + showsGlobalCommands + ? "Global commands are managed in Settings → Commands." + : "Click cells to edit icon, name, command, and shortcut inline." + ) + .font(.caption) + .foregroundStyle(.secondary) } } .background { @@ -352,6 +379,8 @@ struct CustomCommandsEditor: View { private var customCommandsHeaderRow: some View { HStack(spacing: 8) { + customCommandHeaderCell("", width: customCommandsDragColumnWidth, alignment: .center) + customCommandHeaderCell("Enabled", width: customCommandsEnabledColumnWidth, alignment: .center) customCommandHeaderCell("", width: customCommandsIconColumnWidth, alignment: .center) customCommandHeaderCell("Name", width: customCommandsNameColumnWidth) customCommandHeaderCell("Command") @@ -367,6 +396,12 @@ struct CustomCommandsEditor: View { private func customCommandRow(_ command: UserCustomCommand) -> some View { let isSelected = selectedCustomCommandID == command.id HStack(spacing: 8) { + customCommandRowCell(width: customCommandsDragColumnWidth, alignment: .center) { + customCommandReorderHandle(command) + } + customCommandRowCell(width: customCommandsEnabledColumnWidth, alignment: .center) { + customCommandEnabledCell(command) + } customCommandRowCell(width: customCommandsIconColumnWidth, alignment: .center) { customCommandIconCell(command) } @@ -391,6 +426,105 @@ struct CustomCommandsEditor: View { .onTapGesture { selectCustomCommand(command.id) } + .dropDestination(for: String.self) { commandIDs, _ in + guard let commandID = commandIDs.first else { + return false + } + moveCustomCommand(commandID, before: command.id) + return true + } + } + + @ViewBuilder + private func globalCustomCommandRow(_ command: UserCustomCommand) -> some View { + HStack(spacing: 8) { + customCommandRowCell(width: customCommandsDragColumnWidth, alignment: .center) { + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + customCommandRowCell(width: customCommandsEnabledColumnWidth, alignment: .center) { + globalCommandEnabledCell(command) + } + customCommandRowCell(width: customCommandsIconColumnWidth, alignment: .center) { + Image(systemName: command.resolvedSystemImage) + .foregroundStyle(.secondary) + .frame(width: 16, alignment: .center) + .accessibilityHidden(true) + } + customCommandRowCell(width: customCommandsNameColumnWidth) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(command.resolvedTitle) + .lineLimit(1) + Text("Global") + .font(.caption2) + .foregroundStyle(.secondary) + } + if !command.isEnabled { + Text("Disabled globally") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + customCommandRowCell { + VStack(alignment: .leading, spacing: 2) { + Text(inlineCommandTitle(for: command.execution)) + .font(.caption) + .foregroundStyle(.secondary) + Text(inlineCommandScriptPreview(for: command.command)) + .lineLimit(1) + } + } + customCommandRowCell(width: customCommandsShortcutColumnWidth) { + let binding = resolvedCustomCommandBindings.keybinding( + for: customCommandBindingID(for: command.id, source: .global) + ) + Text(binding?.display ?? "Unassigned") + .font(.body.monospaced()) + .foregroundStyle(binding == nil ? .secondary : .primary) + .lineLimit(1) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 2) + .opacity(command.isEnabled ? 1 : 0.6) + .help("Global command. Edit it in Settings → Commands.") + } + + @ViewBuilder + private func customCommandReorderHandle(_ command: UserCustomCommand) -> some View { + Image(systemName: "line.3.horizontal") + .foregroundStyle(.tertiary) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) + .accessibilityLabel("Drag \(command.resolvedTitle) to reorder") + .help("Drag to reorder command") + .draggable(command.id) + } + + @ViewBuilder + private func customCommandEnabledCell(_ command: UserCustomCommand) -> some View { + if let binding = bindingForCustomCommand(id: command.id) { + Toggle("Enable \(command.resolvedTitle)", isOn: binding.isEnabled) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .help("Enable \(command.resolvedTitle)") + } + } + + @ViewBuilder + private func globalCommandEnabledCell(_ command: UserCustomCommand) -> some View { + if let binding = globalCommandEnabled?(command.id) { + Toggle("Enable \(command.resolvedTitle) in this repository", isOn: binding) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .help("Enable \(command.resolvedTitle) in this repository") + } } @ViewBuilder @@ -688,7 +822,9 @@ struct CustomCommandsEditor: View { } private var resolvedCustomCommandBindings: ResolvedKeybindingMap { - let effectiveCommands = commands.map { EffectiveCustomCommand(source: source, command: $0) } + let effectiveCommands = + commands.map { EffectiveCustomCommand(source: source, command: $0) } + + globalCommands.map { EffectiveCustomCommand(source: .global, command: $0) } let migration = LegacyCustomCommandShortcutMigration.migrate(commands: effectiveCommands) return KeybindingResolver.resolve( schema: .appResolverSchema(effectiveCustomCommands: effectiveCommands), @@ -697,8 +833,11 @@ struct CustomCommandsEditor: View { ) } - private func customCommandBindingID(for commandID: String) -> String { - LegacyCustomCommandShortcutMigration.customCommandBindingID(for: commandID, source: source) + private func customCommandBindingID( + for commandID: String, + source: CustomCommandSource? = nil + ) -> String { + LegacyCustomCommandShortcutMigration.customCommandBindingID(for: commandID, source: source ?? self.source) } private func bindingForCustomCommand(id commandID: UserCustomCommand.ID) -> Binding? { @@ -727,6 +866,7 @@ struct CustomCommandsEditor: View { command.splitDirection = updatedCommand.splitDirection command.closeOnSuccess = updatedCommand.closeOnSuccess command.shortcut = updatedCommand.shortcut + command.isEnabled = updatedCommand.isEnabled } } ) @@ -857,6 +997,27 @@ struct CustomCommandsEditor: View { commands = UserCustomCommand.normalizedCommands(updatedCommands) } + private func moveCustomCommand( + _ commandID: UserCustomCommand.ID, + before destinationID: UserCustomCommand.ID? = nil + ) { + guard commandID != destinationID else { + return + } + var updatedCommands = commands + guard let sourceIndex = updatedCommands.firstIndex(where: { $0.id == commandID }) else { + return + } + let command = updatedCommands.remove(at: sourceIndex) + let destinationIndex = + destinationID.flatMap { destinationID in + updatedCommands.firstIndex(where: { $0.id == destinationID }) + } ?? updatedCommands.endIndex + updatedCommands.insert(command, at: destinationIndex) + commands = UserCustomCommand.normalizedCommands(updatedCommands) + selectCustomCommand(commandID) + } + private func toggleRecording(for commandID: UserCustomCommand.ID) { invalidMessageByCommandID[commandID] = nil iconPickerCommandID = nil @@ -1003,7 +1164,32 @@ struct CustomCommandsEditor: View { ) } - private var customCommandsIconColumnWidth: CGFloat { 48 } + private var showsGlobalCommands: Bool { + source == .repository && !globalCommands.isEmpty + } + + private var displayedCommandCount: Int { + commands.count + (showsGlobalCommands ? globalCommands.count : 0) + } + + private var localCommandDropTarget: some View { + Color.clear + .frame(height: 8) + .contentShape(Rectangle()) + .dropDestination(for: String.self) { commandIDs, _ in + guard let commandID = commandIDs.first else { + return false + } + moveCustomCommand(commandID) + return true + } + } + + private var customCommandsDragColumnWidth: CGFloat { 24 } + + private var customCommandsEnabledColumnWidth: CGFloat { 56 } + + private var customCommandsIconColumnWidth: CGFloat { 32 } private var customCommandsNameColumnWidth: CGFloat { 130 } diff --git a/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift b/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift index 1d524cf8..d36fff81 100644 --- a/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift +++ b/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift @@ -14,10 +14,10 @@ struct GlobalCustomCommandsView: View { ) } header: { VStack(alignment: .leading, spacing: 4) { - Text("Custom Commands") + Text("Global Custom Commands") Text( "Global terminal actions available in every repository. " - + "A local command with the same title takes precedence." + + "Enabled commands appear in the top-right of the window toolbar." ) .foregroundStyle(.secondary) } diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index 0abe3ede..6ca0f010 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -1,5 +1,6 @@ import AppKit import ComposableArchitecture +import Sharing import SwiftUI struct RepositorySettingsView: View { @@ -7,6 +8,7 @@ struct RepositorySettingsView: View { @State private var isBranchPickerPresented = false @State private var branchSearchText = "" @State private var githubIdentityViewModel = RepositoryGithubIdentityViewModel() + @Shared(.userGlobalSettings) private var globalSettings var body: some View { let baseRefOptions = @@ -325,13 +327,23 @@ struct RepositorySettingsView: View { CustomCommandsEditor( commands: $store.userSettings.customCommands, source: .repository, - keybindingUserOverrides: store.keybindingUserOverrides + keybindingUserOverrides: store.keybindingUserOverrides, + globalCommands: globalSettings.customCommands, + globalCommandEnabled: { commandID in + Binding( + get: { store.userSettings.isGlobalCommandEnabled(commandID) }, + set: { isEnabled in + store.send(.setGlobalCommandEnabled(commandID, isEnabled)) + } + ) + } ) } header: { VStack(alignment: .leading, spacing: 4) { Text("Custom Commands") Text( - "Repository-local terminal actions. Custom command shortcuts take precedence in this repository." + "Repository and global terminal actions. Enabled commands appear in repository order, " + + "then global order. Edit global commands in Settings → Commands." ) .foregroundStyle(.secondary) } diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 868b6828..223aa0eb 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -113,8 +113,8 @@ struct SettingsView: View { action: \.globalCustomCommands ) { GlobalCustomCommandsView(store: globalCustomCommandsStore) - .navigationTitle("Commands") - .navigationSubtitle("Global terminal actions") + .navigationTitle("Global Commands") + .navigationSubtitle("Global terminal actions and toolbar buttons") } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/supacodeTests/AppFeatureCustomCommandTests.swift b/supacodeTests/AppFeatureCustomCommandTests.swift index 78025ae6..bad97e86 100644 --- a/supacodeTests/AppFeatureCustomCommandTests.swift +++ b/supacodeTests/AppFeatureCustomCommandTests.swift @@ -205,6 +205,7 @@ struct AppFeatureCustomCommandTests { #expect(decoded.splitDirection == .right) #expect(decoded.closeOnSuccess == false) #expect(decoded.execution == .shellScript) + #expect(decoded.isEnabled) } @Test(.dependencies) func invalidCommandIndexDoesNothing() async { @@ -473,6 +474,89 @@ struct AppFeatureCustomCommandTests { } } + @Test(.dependencies) func loadingUserSettingsExcludesDisabledCommandsFromDispatch() async { + let worktree = makeWorktree() + let localEnabled = UserCustomCommand( + id: "local-enabled", + title: "Build", + systemImage: "hammer", + command: "make build", + execution: .shellScript, + shortcut: nil + ) + let localDisabled = UserCustomCommand( + id: "local-disabled", + title: "Test", + systemImage: "checkmark", + command: "make test", + execution: .shellScript, + shortcut: nil, + isEnabled: false + ) + let globalEnabled = UserCustomCommand( + id: "global-enabled", + title: "Lint", + systemImage: "checkmark.circle", + command: "make lint", + execution: .shellScript, + shortcut: nil + ) + let globalDisabled = UserCustomCommand( + id: "global-disabled", + title: "Format", + systemImage: "paintbrush", + command: "make format", + execution: .shellScript, + shortcut: nil, + isEnabled: false + ) + let globalOptedOut = UserCustomCommand( + id: "global-opted-out", + title: "Deploy", + systemImage: "paperplane", + command: "make deploy", + execution: .shellScript, + shortcut: nil + ) + let settings = UserRepositorySettings( + customCommands: [localEnabled, localDisabled], + disabledGlobalCommandIDs: ["global-opted-out"] + ) + let expectedCommands = [ + EffectiveCustomCommand(source: .repository, command: localEnabled), + EffectiveCustomCommand(source: .global, command: globalEnabled), + ] + let store = withDependencies { + $0.defaultFileStorage = .inMemory + } operation: { + @Shared(.userGlobalSettings) var globalSettings + $globalSettings.withLock { + $0 = UserGlobalSettings(customCommands: [globalEnabled, globalDisabled, globalOptedOut]) + } + return TestStore( + initialState: AppFeature.State( + repositories: makeRepositoriesState(worktree: worktree), + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } + } + + await store.send(.worktreeUserSettingsLoaded(settings, worktreeID: worktree.id)) { + $0.selectedCustomCommands = expectedCommands + $0.resolvedKeybindings = KeybindingResolver.resolve( + schema: .appResolverSchema(effectiveCustomCommands: expectedCommands), + migratedOverrides: LegacyCustomCommandShortcutMigration.migrate(commands: expectedCommands).overrides + ) + } + + await store.send(.runCustomCommand(.init(source: .repository, commandID: "local-disabled"))) + await store.send(.runCustomCommand(.init(source: .global, commandID: "global-disabled"))) + await store.send(.runCustomCommand(.init(source: .global, commandID: "global-opted-out"))) + await store.finish() + } + @Test(.dependencies) func customCommandUsesCanvasFocusedWorktree() async { let worktree = makeWorktree() let sent = LockIsolated<[TerminalClient.Command]>([]) diff --git a/supacodeTests/RepositorySettingsFeatureTests.swift b/supacodeTests/RepositorySettingsFeatureTests.swift index 3a42c7bf..e6fac600 100644 --- a/supacodeTests/RepositorySettingsFeatureTests.swift +++ b/supacodeTests/RepositorySettingsFeatureTests.swift @@ -159,6 +159,39 @@ struct RepositorySettingsFeatureTests { #expect(decoded.customCommands.first?.shortcut == conflicted.customCommands.first?.shortcut) } + @Test(.dependencies) func globalCommandOptOutPersistsThroughDedicatedAction() async throws { + let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") + let localStorage = RepositoryLocalSettingsTestStorage() + let store = TestStore( + initialState: RepositorySettingsFeature.State( + rootURL: rootURL, + repositoryKind: .plain, + settings: .default, + userSettings: .default + ) + ) { + RepositorySettingsFeature() + } withDependencies: { + $0.repositoryLocalSettingsStorage = localStorage.storage + } + + await store.send(.setGlobalCommandEnabled("global-build", false)) { + $0.userSettings.disabledGlobalCommandIDs = ["global-build"] + } + await store.receive(\.delegate.settingsChanged) + + let persisted = try JSONDecoder().decode( + UserRepositorySettings.self, + from: #require(localStorage.data(at: SupacodePaths.userRepositorySettingsURL(for: rootURL))) + ) + #expect(!persisted.isGlobalCommandEnabled("global-build")) + + await store.send(.setGlobalCommandEnabled("global-build", true)) { + $0.userSettings.disabledGlobalCommandIDs = [] + } + await store.receive(\.delegate.settingsChanged) + } + @Test(.dependencies) func customTitleBindingPersistsToRepositoryFile() async throws { let rootURL = URL(fileURLWithPath: "/tmp/repo-\(UUID().uuidString)") let settingsStorage = SettingsTestStorage()