diff --git a/docs-ai/002-custom-commands/000-plan.md b/docs-ai/002-custom-commands/000-plan.md index 6d01221a..70fea563 100644 --- a/docs-ai/002-custom-commands/000-plan.md +++ b/docs-ai/002-custom-commands/000-plan.md @@ -74,3 +74,6 @@ integration — all of these arrived in later waves (see Amendments and the acti #85) — see [002-ui-revamp-and-keybinding-unification.md](002-ui-revamp-and-keybinding-unification.md) - Updated 2026-04-17: New Split execution target + per-command Close on success (#205) — see [003-split-target-and-close-on-success.md](003-split-target-and-close-on-success.md) +- Updated 2026-07-13: Global commands extend the repository-scoped model with local-title + precedence, source-qualified identities, and shared settings storage — see + [004-global-custom-commands.md](004-global-custom-commands.md) diff --git a/docs-ai/002-custom-commands/004-global-custom-commands.md b/docs-ai/002-custom-commands/004-global-custom-commands.md new file mode 100644 index 00000000..2fe04e99 --- /dev/null +++ b/docs-ai/002-custom-commands/004-global-custom-commands.md @@ -0,0 +1,30 @@ +# 002.004 — Global Custom Commands + +## Context + +Repository custom commands are unavailable when the same workflow should be run from +multiple repositories. Issue #282 requests a user-owned command list that is available +for every selected worktree without changing repository configuration. + +## Change + +- Persist global commands separately in `global.onevcat.json` through a dedicated + `UserGlobalSettings` shared key. +- Resolve repository and global commands once in `AppFeature`: repository commands retain + their order and hide global commands whose trimmed, case-insensitive title matches. +- Carry source-qualified identities through execution, command palette entries, menus, + toolbars, and keybindings. Local shortcuts win collisions with global shortcuts. +- Reuse the existing command editor for global settings and label global toolbar items + with the system globe symbol. + +## Decision + +The global settings are intentionally not added to upstream-owned `GlobalSettings`. +Keeping a fork-owned JSON file limits Codable and merge surface while preserving the +existing local settings isolation. + +## Verification + +Regression coverage will prove persistence, local precedence, source-qualified command +IDs, active-worktree updates, palette dispatch, and shortcut conflict handling. User +documentation will be updated with the resulting configuration and precedence rules. diff --git a/docs/components/command-palette.md b/docs/components/command-palette.md index 9334cb00..90103407 100644 --- a/docs/components/command-palette.md +++ b/docs/components/command-palette.md @@ -43,7 +43,8 @@ selected worktree has a pull request). the Ghostty-bridged commands; search-only). - **App:** Check for Updates, Open Settings, Open Repository, **Install Command Line Tool**, Repo Settings. -- **Custom commands:** your per-repo Custom Commands appear here too. +- **Custom commands:** local and global Custom Commands appear here with their source; + local title matches hide the corresponding global command. - **Debug** (Debug builds only): toast/update/dock simulations. ## Behavior notes diff --git a/docs/components/custom-actions.md b/docs/components/custom-actions.md index 271464ed..2954cae7 100644 --- a/docs/components/custom-actions.md +++ b/docs/components/custom-actions.md @@ -1,7 +1,7 @@ # Custom Actions, Scripts & Run Commands > Turn repeated commands into buttons and hotkeys: the Run Script, the automatic -> Setup/Archive scripts, and per-repo Custom Commands. +> Setup/Archive scripts, and global or per-repo Custom Commands. **Keywords:** custom command, custom action, run script, setup script, archive script, button, hotkey, PROWL_WORKTREE_PATH, PROWL_ROOT_PATH, close on success, split, terminal input, swift build, npm test, claude -p @@ -16,7 +16,7 @@ Prowl has four distinct mechanisms for "run my command": | **Run Script** | one per repo | on demand (`⌘R`) | Repo Settings → Run Script | | **Setup Script** | one per repo | automatically after a worktree is created | Repo Settings → Setup Script | | **Archive Script** | one per repo | automatically before a worktree is archived | Repo Settings → Archive Script | -| **Custom Commands** | many per repo | on demand (button / hotkey / palette) | Repo Settings → Custom Commands | +| **Custom Commands** | many, global or per repo | on demand (button / hotkey / palette) | Settings → Custom Commands or Repo Settings → Custom Commands | All scripts run with these **environment variables** injected: - `PROWL_WORKTREE_PATH` — the active worktree's directory. @@ -47,7 +47,7 @@ worktree stays active, with the error shown. ## Custom Commands (buttons + hotkeys) -The most flexible option: define **multiple** named actions per repository, each +The most flexible option: define **multiple** named actions globally or per repository, each with its own SF Symbol icon, shell command, execution mode, optional **close on success**, and optional **keyboard shortcut**. @@ -62,14 +62,17 @@ success**, and optional **keyboard shortcut**. **Close on success** auto-closes the tab/split shortly after the command exits 0 (a brief delay lets you see the final output). -**Hotkeys:** each Custom Command can carry a `⌘`/`⇧`/`⌥`/`⌃` shortcut. Within the -focused repository, a Custom Command's hotkey takes **precedence over app +**Precedence:** a local command whose title matches a global command after trimming +and case-folding hides the global command. Local commands also win a collision with a +global command's shortcut. Custom Command hotkeys take **precedence over app shortcuts**; conflicts (with reserved app actions or other custom commands) are detected when you record the key, and you choose Replace / Cancel. **Where they appear:** as buttons in the UI, in the Worktrees menu, and in the -[Command Palette](command-palette.md). Custom Commands are also stored per repo, -in `~/.prowl/repo//prowl.onevcat.json`. +[Command Palette](command-palette.md). The overflow menu groups Local and Global +entries, while global toolbar entries carry a globe marker. Global commands are stored +in `~/.prowl/global.onevcat.json`; local commands remain in +`~/.prowl/repo//prowl.onevcat.json`. ## Example uses @@ -81,14 +84,15 @@ in `~/.prowl/repo//prowl.onevcat.json`. ## Settings recap -- Per repo (Repo Settings): `runScript`, `setupScript`, `archiveScript`, and the - Custom Commands list. +- Per repo (Repo Settings): `runScript`, `setupScript`, `archiveScript`, and local + Custom Commands. +- Global (Settings → Custom Commands): global Custom Commands shared by every repo. - Global: `showRunButtonInToolbar`, `showDefaultEditorInToolbar`. ## Gotchas for agents - The three named scripts (`runScript`/`setupScript`/`archiveScript`) are - **one each per repo**; Custom Commands are the "many" option. + **one each per repo**; Custom Commands are the "many" option at either scope. - Scripts always have `PROWL_WORKTREE_PATH` and `PROWL_ROOT_PATH` available — use them instead of assuming a working directory. - "Terminal input" mode types into whatever pane is focused — be sure of the diff --git a/docs/components/settings.md b/docs/components/settings.md index 3ea4cb24..53f35e17 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -23,11 +23,13 @@ window is a sidebar of tabs plus a detail pane. | **Updates** | Auto-check toggle, "Check for Updates Now". → [updates](updates.md) | | **Advanced** | Analytics, crash reports, restore terminal layout on launch (experimental) + clear saved layout, and the **Install Command Line Tool** (`prowl` CLI) action. | | **GitHub** | Enable GitHub integration (uses the `gh` CLI). → [github-pull-requests](github-pull-requests.md) | +| **Custom Commands** | Global Custom Commands available in every repository. Local commands with the same title take precedence. → [custom-actions](custom-actions.md) | | **Repositories / Repo Settings** | Per-repository: setup/archive/run scripts, **Custom Commands**, default base ref & directory, copy-files overrides, open-with app, custom title, icon & color, PR merge strategy, line-diff & PR-state fetching. Reached from the sidebar context menu → "Repo Settings". → [custom-actions](custom-actions.md), [repositories-and-worktrees](repositories-and-worktrees.md) | ## Where settings live on disk - **Global:** `~/.prowl/settings.json` +- **Global custom commands:** `~/.prowl/global.onevcat.json` - **Per-repo:** `~/.prowl/repo//prowl.json` - **Per-repo custom commands:** `~/.prowl/repo//prowl.onevcat.json` diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index d2bb33e5..574a255e 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -121,7 +121,7 @@ your Ghostty config (`~/.config/ghostty/config`). Typical defaults in parenthese | Check for Updates | ⌘⇧U | `check_for_updates` | yes | | Quit Application | ⌘Q | `quit_application` | **no** (fixed) | -Plus any per-repository **Custom Commands**, which can each carry their own +Plus any global or per-repository **Custom Commands**, which can each carry their own hotkey — see [`components/custom-actions.md`](../components/custom-actions.md). ## Remapping & customization @@ -132,8 +132,9 @@ hotkey — see [`components/custom-actions.md`](../components/custom-actions.md) - **`quit_application`** is fixed (`systemFixedAppAction`) — it can't be remapped. - **Canvas/local actions** (`localInteraction`, e.g. Arrange/Organize/Expand, Rename Branch) are remappable and conflict-checked against all remappable actions. -- **Custom Command** hotkeys are repo-scoped and take precedence over app - shortcuts within the focused repository; conflicts are surfaced when recording. +- **Custom Command** hotkeys take precedence over app shortcuts within the focused + repository. Local commands win global-command collisions; global bindings use a + separate internal command ID namespace. Conflicts are surfaced when recording. - **Terminal engine keys** are owned by Ghostty. Prowl automatically *unbinds* any Ghostty key that collides with an app shortcut, and re-binds the tab/pane-navigation actions into Ghostty so they work inside the terminal. You diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index 277938ba..daa98a2e 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -13,6 +13,7 @@ For the UI grouping of these into tabs, see [`components/settings.md`](../compon | Scope | Path | |-------|------| | Global settings | `~/.prowl/settings.json` | +| Global custom commands | `~/.prowl/global.onevcat.json` | | Per-repository settings | `~/.prowl/repo//prowl.json` | | Per-repository custom commands | `~/.prowl/repo//prowl.onevcat.json` | @@ -93,6 +94,10 @@ Stored at `~/.prowl/repo//prowl.json` (schema v2). For the tri-state for their structure (title, icon, command, execution mode, close-on-success, shortcut). +**Global Custom Commands** use the same command structure in +`~/.prowl/global.onevcat.json`. A trimmed, case-insensitive title match in a +repository's local list hides the corresponding global command. + ## Notes for agents - Defaults here are the **factory** values; a human's file may differ. diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index e29996a7..717104d3 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -822,14 +822,21 @@ enum AppShortcuts { static func userOverrideConflicts( in commands: [UserCustomCommand] + ) -> [CustomCommandOverrideConflict] { + userOverrideConflicts(in: commands.map { EffectiveCustomCommand(source: .repository, command: $0) }) + } + + static func userOverrideConflicts( + in commands: [EffectiveCustomCommand] ) -> [CustomCommandOverrideConflict] { var seen = Set() - return commands.compactMap { command in + return commands.compactMap { effectiveCommand in + let command = effectiveCommand.command guard let shortcut = command.shortcut?.normalized(), shortcut.isValid else { return nil } guard let appBinding = matchingReservedBinding(for: shortcut) else { return nil } let signature = - "\(command.id)|\(shortcut.display)|\(appBinding.actionTitle)|\(appBinding.shortcut.display)" + "\(effectiveCommand.paletteID)|\(shortcut.display)|\(appBinding.actionTitle)|\(appBinding.shortcut.display)" guard seen.insert(signature).inserted else { return nil } return CustomCommandOverrideConflict( diff --git a/supacode/App/KeybindingSchema.swift b/supacode/App/KeybindingSchema.swift index 94704ea4..01eac92a 100644 --- a/supacode/App/KeybindingSchema.swift +++ b/supacode/App/KeybindingSchema.swift @@ -285,10 +285,15 @@ nonisolated enum LegacyCustomCommandShortcutMigration { private static let logger = SupaLogger("Shortcuts") static func migrate(commands: [UserCustomCommand]) -> KeybindingMigrationResult { + migrate(commands: commands.map { EffectiveCustomCommand(source: .repository, command: $0) }) + } + + static func migrate(commands: [EffectiveCustomCommand]) -> KeybindingMigrationResult { var overrides: [String: KeybindingUserOverride] = [:] var issues: [KeybindingMigrationIssue] = [] - for command in commands { + for effectiveCommand in commands { + let command = effectiveCommand.command let commandID = command.id.trimmingCharacters(in: .whitespacesAndNewlines) guard !commandID.isEmpty else { let issue = KeybindingMigrationIssue( @@ -325,14 +330,22 @@ nonisolated enum LegacyCustomCommandShortcutMigration { key: shortcut.key, modifiers: .init(shortcut.modifiers) ) - overrides[customCommandBindingID(for: commandID)] = KeybindingUserOverride(binding: binding) + overrides[customCommandBindingID(for: commandID, source: effectiveCommand.source)] = KeybindingUserOverride( + binding: binding + ) } return KeybindingMigrationResult(overrides: overrides, issues: issues) } - static func customCommandBindingID(for commandID: String) -> String { - "custom_command.\(commandID)" + static func customCommandBindingID( + for commandID: String, + source: CustomCommandSource = .repository + ) -> String { + switch source { + case .repository: "custom_command.\(commandID)" + case .global: "custom_command.global.\(commandID)" + } } } @@ -388,9 +401,17 @@ extension KeybindingSchemaDocument { } static func appResolverSchema(customCommands: [UserCustomCommand] = []) -> KeybindingSchemaDocument { + appResolverSchema( + effectiveCustomCommands: customCommands.map { EffectiveCustomCommand(source: .repository, command: $0) } + ) + } + + static func appResolverSchema( + effectiveCustomCommands: [EffectiveCustomCommand] + ) -> KeybindingSchemaDocument { KeybindingSchemaDocument( version: currentVersion, - commands: appDefaultsV1.commands + customCommands.map(\.keybindingCommandSchema) + commands: appDefaultsV1.commands + effectiveCustomCommands.map(\.keybindingCommandSchema) ) } } @@ -414,11 +435,11 @@ extension AppShortcut { } } -extension UserCustomCommand { +extension EffectiveCustomCommand { fileprivate var keybindingCommandSchema: KeybindingCommandSchema { KeybindingCommandSchema( - id: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: id), - title: resolvedTitle, + id: keybindingID, + title: command.resolvedTitle, scope: .customCommand, platform: .macOS, allowUserOverride: true, diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 6e27093f..477dbfae 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -77,9 +77,8 @@ struct WorktreeCommands: Commands { } CommandGroup(replacing: .newItem) { if !customCommands.isEmpty { - ForEach(Array(customCommands.enumerated()), id: \.element.id) { index, command in + ForEach(customCommands) { command in customCommandButton( - index: index, command: command, hasActiveWorktree: hasActiveWorktree ) @@ -185,16 +184,12 @@ struct WorktreeCommands: Commands { return title } - private func customCommandID(for command: UserCustomCommand) -> String { - LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + private func customCommandShortcut(for command: EffectiveCustomCommand) -> KeyboardShortcut? { + store.resolvedKeybindings.keyboardShortcut(for: command.keybindingID) } - private func customCommandShortcut(for command: UserCustomCommand) -> KeyboardShortcut? { - store.resolvedKeybindings.keyboardShortcut(for: customCommandID(for: command)) - } - - private func customCommandShortcutDisplay(for command: UserCustomCommand) -> String? { - store.resolvedKeybindings.display(for: customCommandID(for: command)) + private func customCommandShortcutDisplay(for command: EffectiveCustomCommand) -> String? { + store.resolvedKeybindings.display(for: command.keybindingID) } private func worktreeMenuEntries(orderedRows: [WorktreeRowModel]) -> [WorktreeMenuEntry] { @@ -263,19 +258,18 @@ struct WorktreeCommands: Commands { @ViewBuilder private func customCommandButton( - index: Int, - command: UserCustomCommand, + command: EffectiveCustomCommand, hasActiveWorktree: Bool ) -> some View { - let title = command.resolvedTitle + let title = command.command.resolvedTitle let helpText: String = if let shortcut = customCommandShortcutDisplay(for: command) { "\(title) (\(shortcut))" } else { title } - Button(title, systemImage: command.resolvedSystemImage) { - store.send(.runCustomCommand(index)) + Button(title, systemImage: command.command.resolvedSystemImage) { + store.send(.runCustomCommand(command.id)) } .modifier(KeyboardShortcutModifier(shortcut: customCommandShortcut(for: command))) .help(helpText) diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index e1907b33..7cb266d3 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -240,8 +240,8 @@ extension AppFeature { } return .send(.repositories(.worktreeOrdering(.pinWorktree(worktreeID)))) - case .runCustomCommand(let index): - return .send(.runCustomCommand(index)) + case .runCustomCommand(let id): + return .send(.runCustomCommand(id)) case .ghosttyCommand(let action): guard let worktree = actionTargetWorktree(repositories: state.repositories) else { diff --git a/supacode/Features/App/Reducer/AppFeature+Support.swift b/supacode/Features/App/Reducer/AppFeature+Support.swift index f59eddba..dc6c3269 100644 --- a/supacode/Features/App/Reducer/AppFeature+Support.swift +++ b/supacode/Features/App/Reducer/AppFeature+Support.swift @@ -118,17 +118,30 @@ extension AppFeature { func resolvedKeybindings( settings: SettingsFeature.State, - customCommands: [UserCustomCommand] + customCommands: [EffectiveCustomCommand] ) -> ResolvedKeybindingMap { let migration = LegacyCustomCommandShortcutMigration.migrate(commands: customCommands) var resolved = KeybindingResolver.resolve( - schema: .appResolverSchema(customCommands: customCommands), + schema: .appResolverSchema(effectiveCustomCommands: customCommands), userOverrides: settings.keybindingUserOverrides, migratedOverrides: migration.overrides ) - let customCommandIDs = customCommands.map { command in - LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + let localBindings: [Keybinding] = + customCommands + .filter { $0.source == .repository } + .compactMap { resolved.keybinding(for: $0.keybindingID) } + for command in customCommands where command.source == .global { + guard let binding = resolved.keybinding(for: command.keybindingID), localBindings.contains(binding) else { + continue + } + guard let resolvedBinding = resolved.binding(for: command.keybindingID) else { continue } + resolved.bindingsByCommandID[command.keybindingID] = ResolvedKeybinding( + command: resolvedBinding.command, + binding: nil, + source: resolvedBinding.source + ) } + let customCommandIDs = customCommands.map(\.keybindingID) let customCommandBindings = customCommandIDs.compactMap { resolved.keybinding(for: $0) } guard !customCommandBindings.isEmpty else { return resolved @@ -175,17 +188,20 @@ extension AppFeature { /// the normal `worktreeUserSettingsLoaded` action and the Canvas focus path. func applyWorktreeUserSettings( _ settings: UserRepositorySettings, + globalSettings: UserGlobalSettings, into state: inout State ) -> Effect { - state.selectedCustomCommands = UserRepositorySettings.normalizedCommands(settings.customCommands) + state.selectedCustomCommands = EffectiveCustomCommand.resolve( + repositoryCommands: settings.customCommands, + globalCommands: globalSettings.customCommands + ) state.resolvedKeybindings = resolvedKeybindings( settings: state.settings, customCommands: state.selectedCustomCommands ) let userOverrideConflicts = AppShortcuts.userOverrideConflicts(in: state.selectedCustomCommands) let shortcuts: [UserCustomShortcut] = state.selectedCustomCommands.compactMap { command in - let commandID = LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) - return state.resolvedKeybindings.keybinding(for: commandID)?.userCustomShortcut + state.resolvedKeybindings.keybinding(for: command.keybindingID)?.userCustomShortcut } return .run { _ in let logger = SupaLogger("Shortcuts") diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 95784dd6..65bd2f39 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -18,7 +18,7 @@ struct AppFeature { /// "Automatic" entry's checkmark in the toolbar's Open menu. var openActionIsAutomatic: Bool = true var selectedRunScript: String = "" - var selectedCustomCommands: [UserCustomCommand] = [] + var selectedCustomCommands: [EffectiveCustomCommand] = [] var resolvedKeybindings: ResolvedKeybindingMap = .appDefaults var runScriptDraft: String = "" var isRunScriptPromptPresented = false @@ -67,7 +67,7 @@ struct AppFeature { case showLeftSidebar case setLeftSidebarVisibility(NavigationSplitViewVisibility) case runScript - case runCustomCommand(Int) + case runCustomCommand(EffectiveCustomCommand.Identifier) case canvasFocusedWorktreeChanged(Worktree.ID?) case runScriptDraftChanged(String) case runScriptPromptPresented(Bool) @@ -367,6 +367,9 @@ struct AppFeature { case .settings(.setSelection(let selection)): let resolvedSelection = selection ?? .general switch resolvedSelection { + case .customCommands: + state.settings.repositorySettings = nil + state.settings.globalCustomCommands = .init() case .repository(let repositoryID): guard let repository = state.repositories.repositories[id: repositoryID] else { state.settings.repositorySettings = nil @@ -388,11 +391,24 @@ struct AppFeature { repoSettingsState.globalCopyUntrackedOnWorktreeCreate = state.settings.copyUntrackedOnWorktreeCreate repoSettingsState.globalPullRequestMergeStrategy = state.settings.pullRequestMergeStrategy state.settings.repositorySettings = repoSettingsState + state.settings.globalCustomCommands = nil case .general, .notifications, .shortcuts, .worktree, .updates, .advanced, .github: state.settings.repositorySettings = nil + state.settings.globalCustomCommands = nil } return .none + case .settings(.globalCustomCommands(.delegate(.settingsChanged(let globalSettings)))): + guard let worktree = actionTargetWorktree(repositories: state.repositories) else { + return .none + } + @Shared(.userRepositorySettings(worktree.repositoryRootURL)) var userRepositorySettings + return applyWorktreeUserSettings( + userRepositorySettings, + globalSettings: globalSettings, + into: &state + ) + case .settings(.delegate(.settingsChanged(let settings))): let shouldCheckSystemNotificationPermission = settings.systemNotificationsEnabled && !state.lastKnownSystemNotificationsEnabled @@ -675,10 +691,10 @@ struct AppFeature { guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } - guard state.selectedCustomCommands.indices.contains(index) else { + guard let effectiveCommand = state.selectedCustomCommands.first(where: { $0.id == index }) else { return .none } - let customCommand = state.selectedCustomCommands[index] + let customCommand = effectiveCommand.command guard customCommand.hasRunnableCommand else { return .none } @@ -750,6 +766,7 @@ struct AppFeature { let rootURL = worktree.repositoryRootURL @Shared(.repositorySettings(rootURL)) var repositorySettings @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings + @Shared(.userGlobalSettings) var userGlobalSettings // Apply both settings in this single reduce pass instead of dispatching // follow-up `.send`s. The Canvas focus ID (an `@Observable` on the // terminal manager) updates synchronously on card tap, so the toolbar's @@ -761,7 +778,11 @@ struct AppFeature { workingDirectory: worktree.workingDirectory, into: &state ) - return applyWorktreeUserSettings(userRepositorySettings, into: &state) + return applyWorktreeUserSettings( + userRepositorySettings, + globalSettings: userGlobalSettings, + into: &state + ) case .runScriptDraftChanged(let script): state.runScriptDraft = script @@ -898,7 +919,8 @@ struct AppFeature { guard actionTargetWorktree(repositories: state.repositories)?.id == worktreeID else { return .none } - return applyWorktreeUserSettings(settings, into: &state) + @Shared(.userGlobalSettings) var userGlobalSettings + return applyWorktreeUserSettings(settings, globalSettings: userGlobalSettings, into: &state) case .systemNotificationsPermissionFailed(let errorMessage): return .concatenate( diff --git a/supacode/Features/CommandPalette/CommandPaletteItem.swift b/supacode/Features/CommandPalette/CommandPaletteItem.swift index bcccf4f4..915933d1 100644 --- a/supacode/Features/CommandPalette/CommandPaletteItem.swift +++ b/supacode/Features/CommandPalette/CommandPaletteItem.swift @@ -81,7 +81,7 @@ struct CommandPaletteItem: Identifiable, Equatable { case deleteWorktree(Worktree.ID, Repository.ID) case renameBranch case openRepositorySettings(Repository.ID) - case runCustomCommand(index: Int, commandID: String, systemImage: String) + case runCustomCommand(EffectiveCustomCommand.Identifier, systemImage: String) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift index 8931a263..3617bce5 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift @@ -70,7 +70,7 @@ struct CommandPaletteFeature { case togglePinWorktree(Worktree.ID, isCurrentlyPinned: Bool) case renameBranch case openRepositorySettings(Repository.ID) - case runCustomCommand(Int) + case runCustomCommand(EffectiveCustomCommand.Identifier) #if DEBUG case debugTestToast(RepositoriesFeature.StatusToast) case debugSimulateUpdateFound @@ -221,7 +221,7 @@ struct CommandPaletteFeature { static func commandPaletteItems( from repositories: RepositoriesFeature.State, - customCommands: [UserCustomCommand] = [], + customCommands: [EffectiveCustomCommand] = [], runScriptStatusByWorktreeID: [Worktree.ID: Bool] = [:], actionTargetWorktreeID: Worktree.ID? = nil, ghosttyCommands: [GhosttyCommand] = [] @@ -323,10 +323,10 @@ struct CommandPaletteFeature { static func recencyRetentionIDs( from repositories: IdentifiedArrayOf, - customCommands: [UserCustomCommand] = [] + customCommands: [EffectiveCustomCommand] = [] ) -> [CommandPaletteItem.ID] { var ids = CommandPaletteItemID.globalIDs - ids.append(contentsOf: customCommands.map { CommandPaletteItemID.customCommand($0.id) }) + ids.append(contentsOf: customCommands.map(CommandPaletteItemID.customCommand)) for repository in repositories { ids.append(contentsOf: CommandPaletteItemID.pullRequestIDs(repositoryID: repository.id)) ids.append(CommandPaletteItemID.openRepositorySettings(repository.id)) @@ -516,16 +516,16 @@ private func activeRepository( return repositories.repositories[id: repositoryID] } -private func customCommandItems(_ commands: [UserCustomCommand]) -> [CommandPaletteItem] { - commands.enumerated().compactMap { index, command in +private func customCommandItems(_ commands: [EffectiveCustomCommand]) -> [CommandPaletteItem] { + commands.compactMap { effectiveCommand in + let command = effectiveCommand.command guard command.hasRunnableCommand else { return nil } return CommandPaletteItem( - id: CommandPaletteItemID.customCommand(command.id), + id: CommandPaletteItemID.customCommand(effectiveCommand), title: command.resolvedTitle, - subtitle: customCommandSubtitle(for: command), + subtitle: customCommandSubtitle(for: effectiveCommand), kind: .runCustomCommand( - index: index, - commandID: command.id, + effectiveCommand.id, systemImage: command.resolvedSystemImage ), category: .worktree, @@ -535,8 +535,9 @@ private func customCommandItems(_ commands: [UserCustomCommand]) -> [CommandPale } } -private func customCommandSubtitle(for command: UserCustomCommand) -> String { - "Custom command in this repo · \(customCommandExecutionDescription(for: command))" +private func customCommandSubtitle(for effectiveCommand: EffectiveCustomCommand) -> String { + "\(effectiveCommand.source.displayTitle) custom command · " + + customCommandExecutionDescription(for: effectiveCommand.command) } private func customCommandExecutionDescription(for command: UserCustomCommand) -> String { diff --git a/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift index 5e7b502e..a38a6319 100644 --- a/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift +++ b/supacode/Features/CommandPalette/Reducer/CommandPaletteSupport.swift @@ -35,8 +35,8 @@ enum CommandPaletteItemID { "repo.\(repositoryID).open-settings" } - static func customCommand(_ commandID: String) -> CommandPaletteItem.ID { - "custom-command.\(commandID)" + static func customCommand(_ command: EffectiveCustomCommand) -> CommandPaletteItem.ID { + command.paletteID } static var globalIDs: [CommandPaletteItem.ID] { @@ -157,8 +157,8 @@ func delegateAction(for kind: CommandPaletteItem.Kind) -> CommandPaletteFeature. return .togglePinWorktree(worktreeID, isCurrentlyPinned: isCurrentlyPinned) case .openRepositorySettings(let repositoryID): return .openRepositorySettings(repositoryID) - case .runCustomCommand(let index, _, _): - return .runCustomCommand(index) + case .runCustomCommand(let id, _): + return .runCustomCommand(id) case .openPullRequest, .openRepositoryOnCodeHost, .markPullRequestReady, diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index acc83ef1..f2c4ad15 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -617,7 +617,7 @@ private struct CommandPaletteRowView: View { return "gearshape" case .deleteWorktree: return "trash" - case .runCustomCommand(_, _, let systemImage): + case .runCustomCommand(_, let systemImage): return systemImage #if DEBUG case .debugTestToast: diff --git a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift index 9c42b758..1d389df7 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift @@ -122,6 +122,7 @@ struct RunScriptToolbarButton: View { struct UserCustomCommandToolbarButton: View { let title: String let systemImage: String + let sourceSystemImage: String? let shortcut: String? let isEnabled: Bool let action: () -> Void @@ -134,6 +135,12 @@ struct UserCustomCommandToolbarButton: View { HStack(spacing: 6) { Image(systemName: systemImage) .accessibilityHidden(true) + if let sourceSystemImage { + Image(systemName: sourceSystemImage) + .imageScale(.small) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } Text(title) if commandKeyObserver.isPressed, let shortcut { Text(shortcut) @@ -159,9 +166,9 @@ struct UserCustomCommandToolbarButton: View { } struct CustomCommandOverflowButton: View { - let entries: [(index: Int, command: UserCustomCommand)] - let shortcutDisplay: (UserCustomCommand) -> String? - let onRunCustomCommand: (Int) -> Void + let entries: [EffectiveCustomCommand] + let shortcutDisplay: (EffectiveCustomCommand) -> String? + let onRunCustomCommand: (EffectiveCustomCommand.Identifier) -> Void @State private var isPresented = false private let maxVisibleRows = 10 @@ -178,10 +185,10 @@ struct CustomCommandOverflowButton: View { .popover(isPresented: $isPresented, arrowEdge: .bottom) { ScrollView { VStack(alignment: .leading, spacing: 2) { - ForEach(entries, id: \.command.id) { entry in + ForEach(entries) { entry in Button { isPresented = false - onRunCustomCommand(entry.index) + onRunCustomCommand(entry.id) } label: { HStack(spacing: 8) { Image(systemName: entry.command.resolvedSystemImage) @@ -191,7 +198,7 @@ struct CustomCommandOverflowButton: View { Text(entry.command.resolvedTitle) .lineLimit(1) Spacer(minLength: 0) - if let shortcut = shortcutDisplay(entry.command) { + if let shortcut = shortcutDisplay(entry) { Text(shortcut) .font(.caption.monospaced()) .foregroundStyle(.secondary) @@ -237,16 +244,18 @@ private struct WorktreeToolbarPreview: View { runScriptEnabled: true, runScriptIsRunning: false, customCommands: [ - UserCustomCommand( - title: "Test", - systemImage: "checkmark.circle.fill", - command: "swift test", - execution: .shellScript, - shortcut: UserCustomShortcut( - key: "u", - modifiers: UserCustomShortcutModifiers() - ) - ) + EffectiveCustomCommand( + source: .repository, + command: UserCustomCommand( + title: "Test", + systemImage: "checkmark.circle.fill", + command: "swift test", + execution: .shellScript, + shortcut: UserCustomShortcut( + key: "u", + modifiers: UserCustomShortcutModifiers() + ) + )) ], isUpdateAvailable: true, isUpdateReadyToInstall: false, diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index f5157b6f..61e3fa88 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -14,7 +14,7 @@ struct WorktreeDetailView: View { let showExtras: Bool let runScriptEnabled: Bool let runScriptIsRunning: Bool - let customCommands: [UserCustomCommand] + let customCommands: [EffectiveCustomCommand] let isUpdateAvailable: Bool let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? @@ -30,7 +30,7 @@ struct WorktreeDetailView: View { let unseenNotificationWorktreeCount: Int let runScriptEnabled: Bool let runScriptIsRunning: Bool - let customCommands: [UserCustomCommand] + let customCommands: [EffectiveCustomCommand] let isUpdateAvailable: Bool let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? @@ -44,7 +44,7 @@ struct WorktreeDetailView: View { let unseenNotificationWorktreeCount: Int let runScriptEnabled: Bool let runScriptIsRunning: Bool - let customCommands: [UserCustomCommand] + let customCommands: [EffectiveCustomCommand] } @Bindable var store: StoreOf @@ -314,31 +314,28 @@ struct WorktreeDetailView: View { stopAction: { store.send(.stopRunScript) } ) } - ForEach(inlineCommands, id: \.element.id) { index, command in + ForEach(inlineCommands, id: \.element.id) { _, command in UserCustomCommandToolbarButton( - title: command.resolvedTitle, - systemImage: command.resolvedSystemImage, + title: command.command.resolvedTitle, + systemImage: command.command.resolvedSystemImage, + sourceSystemImage: command.source.toolbarSystemImage, shortcut: store.resolvedKeybindings.display( - for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) + for: command.keybindingID ), - isEnabled: command.hasRunnableCommand, + isEnabled: command.command.hasRunnableCommand, action: { - store.send(.runCustomCommand(index)) + store.send(.runCustomCommand(command.id)) } ) } if !overflowCommands.isEmpty { CustomCommandOverflowButton( - entries: overflowCommands.map { - (index: $0.offset, command: $0.element) - }, + entries: overflowCommands.map { $0.element }, shortcutDisplay: { command in - store.resolvedKeybindings.display( - for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id) - ) + store.resolvedKeybindings.display(for: command.keybindingID) }, - onRunCustomCommand: { index in - store.send(.runCustomCommand(index)) + onRunCustomCommand: { id in + store.send(.runCustomCommand(id)) } ) } @@ -812,7 +809,7 @@ struct WorktreeDetailView: View { let showExtras: Bool let runScriptEnabled: Bool let runScriptIsRunning: Bool - let customCommands: [UserCustomCommand] + let customCommands: [EffectiveCustomCommand] let isUpdateAvailable: Bool let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? @@ -833,7 +830,7 @@ struct WorktreeDetailView: View { let onDismissAllNotifications: () -> Void let onRunScript: () -> Void let onStopRunScript: () -> Void - let onRunCustomCommand: (Int) -> Void + let onRunCustomCommand: (EffectiveCustomCommand.Identifier) -> Void let onActivateUpdateButton: () -> Void @Environment(\.resolvedKeybindings) private var resolvedKeybindings @@ -996,8 +993,8 @@ struct WorktreeDetailView: View { if !inlineEntries.isEmpty { ToolbarItemGroup { - ForEach(inlineEntries, id: \.command.id) { entry in - customCommandButton(entry.command, index: entry.index) + ForEach(inlineEntries) { entry in + customCommandButton(entry) } } } @@ -1013,24 +1010,25 @@ struct WorktreeDetailView: View { } } - private var customCommandEntries: [(index: Int, command: UserCustomCommand)] { - Array(toolbarState.customCommands.enumerated()).map { (index: $0.offset, command: $0.element) } + private var customCommandEntries: [EffectiveCustomCommand] { + toolbarState.customCommands } - private func customCommandButton(_ command: UserCustomCommand, index: Int) -> some View { + private func customCommandButton(_ command: EffectiveCustomCommand) -> some View { UserCustomCommandToolbarButton( - title: command.resolvedTitle, - systemImage: command.resolvedSystemImage, + title: command.command.resolvedTitle, + systemImage: command.command.resolvedSystemImage, + sourceSystemImage: command.source.toolbarSystemImage, shortcut: customCommandShortcutDisplay(for: command), - isEnabled: command.hasRunnableCommand, + isEnabled: command.command.hasRunnableCommand, action: { - onRunCustomCommand(index) + onRunCustomCommand(command.id) } ) } - private func customCommandShortcutDisplay(for command: UserCustomCommand) -> String? { - shortcutDisplay(for: LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id)) + private func customCommandShortcutDisplay(for command: EffectiveCustomCommand) -> String? { + shortcutDisplay(for: command.keybindingID) } private func shortcutDisplay(for commandID: String) -> String? { diff --git a/supacode/Features/Settings/BusinessLogic/UserGlobalSettingsKey.swift b/supacode/Features/Settings/BusinessLogic/UserGlobalSettingsKey.swift new file mode 100644 index 00000000..91c8ca5a --- /dev/null +++ b/supacode/Features/Settings/BusinessLogic/UserGlobalSettingsKey.swift @@ -0,0 +1,77 @@ +import Dependencies +import Foundation +import Sharing + +nonisolated struct UserGlobalSettingsKeyID: Hashable, Sendable { + let url: URL +} + +nonisolated enum UserGlobalSettingsURLKey: DependencyKey { + static var liveValue: URL { SupacodePaths.userGlobalSettingsURL } + static var previewValue: URL { SupacodePaths.userGlobalSettingsURL } + static var testValue: URL { SupacodePaths.userGlobalSettingsURL } +} + +extension DependencyValues { + nonisolated var userGlobalSettingsURL: URL { + get { self[UserGlobalSettingsURLKey.self] } + set { self[UserGlobalSettingsURLKey.self] = newValue } + } +} + +nonisolated struct UserGlobalSettingsKey: SharedKey { + let url: URL + + init(url: URL? = nil) { + if let url { + self.url = url + return + } + @Dependency(\.userGlobalSettingsURL) var userGlobalSettingsURL + self.url = userGlobalSettingsURL + } + + var id: UserGlobalSettingsKeyID { UserGlobalSettingsKeyID(url: url) } + + func load(context: LoadContext, continuation: LoadContinuation) { + @Dependency(\.settingsFileStorage) var storage + let decoder = JSONDecoder() + if let data = try? storage.load(url), let settings = try? decoder.decode(UserGlobalSettings.self, from: data) { + continuation.resume(returning: settings.normalized()) + return + } + let settings = (context.initialValue ?? .default).normalized() + do { + try storage.save(try Self.encoder.encode(settings), url) + } catch { + SupaLogger("Settings").warning("Unable to write user global settings: \(error.localizedDescription)") + } + continuation.resume(returning: settings) + } + + func subscribe( + context _: LoadContext, subscriber _: SharedSubscriber + ) -> SharedSubscription { + SharedSubscription {} + } + + func save(_ value: UserGlobalSettings, context _: SaveContext, continuation: SaveContinuation) { + @Dependency(\.settingsFileStorage) var storage + do { + try storage.save(try Self.encoder.encode(value.normalized()), url) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + + private static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } +} + +nonisolated extension SharedReaderKey where Self == UserGlobalSettingsKey.Default { + static var userGlobalSettings: Self { Self[UserGlobalSettingsKey(), default: .default] } +} diff --git a/supacode/Features/Settings/Models/UserGlobalSettings.swift b/supacode/Features/Settings/Models/UserGlobalSettings.swift new file mode 100644 index 00000000..f43e1191 --- /dev/null +++ b/supacode/Features/Settings/Models/UserGlobalSettings.swift @@ -0,0 +1,15 @@ +import Foundation + +nonisolated struct UserGlobalSettings: Codable, Equatable, Sendable { + var customCommands: [UserCustomCommand] + + static let `default` = UserGlobalSettings(customCommands: []) + + init(customCommands: [UserCustomCommand]) { + self.customCommands = UserCustomCommand.normalizedCommands(customCommands) + } + + func normalized() -> UserGlobalSettings { + UserGlobalSettings(customCommands: customCommands) + } +} diff --git a/supacode/Features/Settings/Models/UserRepositorySettings.swift b/supacode/Features/Settings/Models/UserRepositorySettings.swift index 77a3e955..90a678ef 100644 --- a/supacode/Features/Settings/Models/UserRepositorySettings.swift +++ b/supacode/Features/Settings/Models/UserRepositorySettings.swift @@ -24,7 +24,7 @@ nonisolated struct UserRepositorySettings: Codable, Equatable, Sendable { } static func normalizedCommands(_ commands: [UserCustomCommand]) -> [UserCustomCommand] { - commands.map { $0.normalized() } + UserCustomCommand.normalizedCommands(commands) } } @@ -99,6 +99,10 @@ nonisolated struct UserCustomCommand: Codable, Equatable, Sendable, Identifiable ) } + static func normalizedCommands(_ commands: [UserCustomCommand]) -> [UserCustomCommand] { + commands.map { $0.normalized() } + } + var resolvedTitle: String { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { @@ -120,6 +124,63 @@ nonisolated struct UserCustomCommand: Codable, Equatable, Sendable, Identifiable } } +nonisolated enum CustomCommandSource: String, Codable, CaseIterable, Equatable, Hashable, Sendable { + case repository + case global + + var displayTitle: String { + switch self { + case .repository: "Local" + case .global: "Global" + } + } + + var toolbarSystemImage: String? { + switch self { + case .repository: nil + case .global: "globe" + } + } +} + +nonisolated struct EffectiveCustomCommand: Equatable, Sendable, Identifiable { + nonisolated struct Identifier: Hashable, Sendable { + let source: CustomCommandSource + let commandID: UserCustomCommand.ID + } + + let source: CustomCommandSource + let command: UserCustomCommand + + var id: Identifier { Identifier(source: source, commandID: command.id) } + + var keybindingID: String { + LegacyCustomCommandShortcutMigration.customCommandBindingID(for: command.id, source: source) + } + + var paletteID: String { + "custom-command.\(source.rawValue).\(command.id)" + } + + static func resolve( + repositoryCommands: [UserCustomCommand], + globalCommands: [UserCustomCommand] + ) -> [EffectiveCustomCommand] { + let local = UserCustomCommand.normalizedCommands(repositoryCommands) + let localTitles = Set(local.map { $0.titleComparisonKey }) + return local.map { .init(source: .repository, command: $0) } + + UserCustomCommand.normalizedCommands(globalCommands) + .filter { !localTitles.contains($0.titleComparisonKey) } + .map { .init(source: .global, command: $0) } + } +} + +nonisolated extension UserCustomCommand { + fileprivate var titleComparisonKey: String { + title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + nonisolated enum UserCustomCommandExecution: String, Codable, CaseIterable, Identifiable, Sendable { case shellScript case terminalInput diff --git a/supacode/Features/Settings/Reducer/GlobalCustomCommandsFeature.swift b/supacode/Features/Settings/Reducer/GlobalCustomCommandsFeature.swift new file mode 100644 index 00000000..316e008f --- /dev/null +++ b/supacode/Features/Settings/Reducer/GlobalCustomCommandsFeature.swift @@ -0,0 +1,69 @@ +import ComposableArchitecture +import Foundation +import Sharing +import SwiftUI + +@Reducer +struct GlobalCustomCommandsFeature { + @ObservableState + struct State: Equatable { + var settings: UserGlobalSettings = .default + var keybindingUserOverrides: KeybindingUserOverrideStore = .empty + } + + enum Action: BindableAction { + case task + case settingsLoaded(UserGlobalSettings, KeybindingUserOverrideStore) + case addCommand + case removeCommands(IndexSet) + case binding(BindingAction) + case delegate(Delegate) + } + + @CasePathable + enum Delegate: Equatable { + case settingsChanged(UserGlobalSettings) + } + + var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .task: + return .run { send in + @Shared(.userGlobalSettings) var settings + @Shared(.settingsFile) var settingsFile + await send(.settingsLoaded(settings, settingsFile.global.keybindingUserOverrides)) + } + + case .settingsLoaded(let settings, let overrides): + state.settings = settings.normalized() + 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) + + case .delegate: + return .none + } + } + } + + private func persist(_ settings: UserGlobalSettings) -> Effect { + .run { send in + @Shared(.userGlobalSettings) var storedSettings + $storedSettings.withLock { $0 = settings } + await send(.delegate(.settingsChanged(settings))) + } + } +} diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 5e1be01b..f9436bd6 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -60,6 +60,7 @@ struct SettingsFeature { var dockBadgeAuthorization: SystemNotificationClient.DockBadgeAuthorization = .available var selection: SettingsSection? = .general var repositorySettings: RepositorySettingsFeature.State? + var globalCustomCommands: GlobalCustomCommandsFeature.State? @Presents var alert: AlertState? init(settings: GlobalSettings = .default) { @@ -178,6 +179,7 @@ struct SettingsFeature { case dockBadgeAuthorizationResponse(SystemNotificationClient.DockBadgeAuthorization) case showNotificationPermissionAlert(errorMessage: String?) case repositorySettings(RepositorySettingsFeature.Action) + case globalCustomCommands(GlobalCustomCommandsFeature.Action) case alert(PresentationAction) case delegate(Delegate) case binding(BindingAction) @@ -437,6 +439,9 @@ struct SettingsFeature { case .repositorySettings: return .none + case .globalCustomCommands: + return .none + case .delegate: return .none } @@ -444,6 +449,9 @@ struct SettingsFeature { .ifLet(\.repositorySettings, action: \.repositorySettings) { RepositorySettingsFeature() } + .ifLet(\.globalCustomCommands, action: \.globalCustomCommands) { + GlobalCustomCommandsFeature() + } } private func persist( diff --git a/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift b/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift new file mode 100644 index 00000000..e5483741 --- /dev/null +++ b/supacode/Features/Settings/Views/GlobalCustomCommandsView.swift @@ -0,0 +1,107 @@ +import ComposableArchitecture +import SwiftUI + +struct GlobalCustomCommandsView: View { + @Bindable var store: StoreOf + + var body: some View { + Form { + Section { + Text("Global commands are 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) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .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/SettingsSection.swift b/supacode/Features/Settings/Views/SettingsSection.swift index 6732c097..11344eb3 100644 --- a/supacode/Features/Settings/Views/SettingsSection.swift +++ b/supacode/Features/Settings/Views/SettingsSection.swift @@ -8,5 +8,6 @@ enum SettingsSection: Hashable { case updates case advanced case github + case customCommands case repository(Repository.ID) } diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index e09f93dc..2c7a69b7 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -44,6 +44,8 @@ struct SettingsView: View { .tag(SettingsSection.advanced) Label("GitHub", systemImage: "arrow.triangle.branch") .tag(SettingsSection.github) + Label("Custom Commands", systemImage: "globe") + .tag(SettingsSection.customCommands) Section("Repositories") { ForEach(repositories) { repository in @@ -104,6 +106,20 @@ struct SettingsView: View { .navigationTitle("GitHub") .navigationSubtitle("GitHub CLI integration") } + case .customCommands: + SettingsDetailView { + if let globalCustomCommandsStore = settingsStore.scope( + state: \.globalCustomCommands, + action: \.globalCustomCommands + ) { + GlobalCustomCommandsView(store: globalCustomCommandsStore) + .navigationTitle("Custom Commands") + .navigationSubtitle("Global terminal actions") + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } case .repository(let repositoryID): if let repository = repositories[id: repositoryID] { SettingsDetailView { diff --git a/supacode/Support/SupacodePaths.swift b/supacode/Support/SupacodePaths.swift index b590c295..d897d9e9 100644 --- a/supacode/Support/SupacodePaths.swift +++ b/supacode/Support/SupacodePaths.swift @@ -200,6 +200,10 @@ nonisolated enum SupacodePaths { baseDirectory.appending(path: "settings.json", directoryHint: .notDirectory) } + static var userGlobalSettingsURL: URL { + baseDirectory.appending(path: "global.onevcat.json", directoryHint: .notDirectory) + } + static var repositorySnapshotURL: URL { cacheDirectory.appending(path: "repository-snapshot.json", directoryHint: .notDirectory) } diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 53efde3d..f3e6beac 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -818,7 +818,8 @@ struct AppFeatureCommandPaletteTests { } store.exhaustivity = .off - await store.send(.commandPalette(.delegate(.runCustomCommand(3)))) + let commandID = EffectiveCustomCommand.Identifier(source: .global, commandID: "global-command") + await store.send(.commandPalette(.delegate(.runCustomCommand(commandID)))) await store.receive(\.runCustomCommand) } diff --git a/supacodeTests/AppFeatureCustomCommandTests.swift b/supacodeTests/AppFeatureCustomCommandTests.swift index 7944e04a..78025ae6 100644 --- a/supacodeTests/AppFeatureCustomCommandTests.swift +++ b/supacodeTests/AppFeatureCustomCommandTests.swift @@ -16,7 +16,7 @@ struct AppFeatureCustomCommandTests { repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State() ) - state.selectedCustomCommands = [ + state.selectedCustomCommands = effective( UserCustomCommand( title: "Test", systemImage: "checkmark.circle", @@ -24,7 +24,7 @@ struct AppFeatureCustomCommandTests { execution: .shellScript, shortcut: nil, ) - ] + ) let store = TestStore(initialState: state) { AppFeature() @@ -34,7 +34,7 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.runCustomCommand(0)) + await store.send(.runCustomCommand(state.selectedCustomCommands[0].id)) await store.finish() #expect( @@ -58,7 +58,7 @@ struct AppFeatureCustomCommandTests { repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State() ) - state.selectedCustomCommands = [ + state.selectedCustomCommands = effective( UserCustomCommand( title: "Watch", systemImage: "terminal", @@ -66,7 +66,7 @@ struct AppFeatureCustomCommandTests { execution: .terminalInput, shortcut: nil, ) - ] + ) let store = TestStore(initialState: state) { AppFeature() @@ -76,7 +76,7 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.runCustomCommand(0)) + await store.send(.runCustomCommand(state.selectedCustomCommands[0].id)) await store.finish() #expect( @@ -93,7 +93,7 @@ struct AppFeatureCustomCommandTests { repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State() ) - state.selectedCustomCommands = [ + state.selectedCustomCommands = effective( UserCustomCommand( title: "Tail", systemImage: "doc.text", @@ -102,7 +102,7 @@ struct AppFeatureCustomCommandTests { splitDirection: .down, shortcut: nil, ) - ] + ) let store = TestStore(initialState: state) { AppFeature() @@ -112,7 +112,7 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.runCustomCommand(0)) + await store.send(.runCustomCommand(state.selectedCustomCommands[0].id)) await store.finish() #expect( @@ -136,7 +136,7 @@ struct AppFeatureCustomCommandTests { repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State() ) - state.selectedCustomCommands = [ + state.selectedCustomCommands = effective( UserCustomCommand( title: "Build", systemImage: "hammer", @@ -154,7 +154,7 @@ struct AppFeatureCustomCommandTests { closeOnSuccess: true, shortcut: nil, ), - ] + ) let store = TestStore(initialState: state) { AppFeature() @@ -164,8 +164,8 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.runCustomCommand(0)) - await store.send(.runCustomCommand(1)) + await store.send(.runCustomCommand(state.selectedCustomCommands[0].id)) + await store.send(.runCustomCommand(state.selectedCustomCommands[1].id)) await store.finish() #expect( @@ -223,12 +223,57 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.runCustomCommand(0)) + await store.send(.runCustomCommand(.init(source: .repository, commandID: "missing"))) await store.finish() #expect(sent.value.isEmpty) } + @Test(.dependencies) func globalCustomCommandExecutesForSelectedWorktree() async { + let worktree = makeWorktree() + let sent = LockIsolated<[TerminalClient.Command]>([]) + var state = AppFeature.State( + repositories: makeRepositoriesState(worktree: worktree), + settings: SettingsFeature.State() + ) + let global = EffectiveCustomCommand( + source: .global, + command: UserCustomCommand( + id: "global-build", + title: "Build Everywhere", + systemImage: "globe", + command: "make build", + execution: .shellScript, + shortcut: nil + ) + ) + state.selectedCustomCommands = [global] + + let store = TestStore(initialState: state) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + } + + await store.send(.runCustomCommand(global.id)) + await store.finish() + + #expect( + sent.value == [ + .createTabWithInput( + worktree, + input: "make build", + runSetupScriptIfNew: false, + autoCloseOnSuccess: false, + customCommandName: "Build Everywhere", + customCommandIcon: "globe" + ) + ] + ) + } + @Test(.dependencies) func supportsCustomCommandBeyondLegacyThreeItemLimit() async { let worktree = makeWorktree() let sent = LockIsolated<[TerminalClient.Command]>([]) @@ -236,7 +281,7 @@ struct AppFeatureCustomCommandTests { repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State() ) - state.selectedCustomCommands = [ + state.selectedCustomCommands = effective( UserCustomCommand( title: "One", systemImage: "1.circle", @@ -272,7 +317,7 @@ struct AppFeatureCustomCommandTests { execution: .shellScript, shortcut: nil, ), - ] + ) let store = TestStore(initialState: state) { AppFeature() @@ -282,7 +327,7 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.runCustomCommand(4)) + await store.send(.runCustomCommand(state.selectedCustomCommands[4].id)) await store.finish() #expect( @@ -306,7 +351,7 @@ struct AppFeatureCustomCommandTests { repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State() ) - state.selectedCustomCommands = [ + state.selectedCustomCommands = effective( UserCustomCommand( title: "Default", systemImage: "terminal", @@ -314,7 +359,7 @@ struct AppFeatureCustomCommandTests { execution: .shellScript, shortcut: nil, ) - ] + ) let store = TestStore(initialState: state) { AppFeature() @@ -324,7 +369,7 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.runCustomCommand(0)) + await store.send(.runCustomCommand(state.selectedCustomCommands[0].id)) await store.finish() // The model's "terminal" placeholder should not be pinned as a @@ -351,7 +396,7 @@ struct AppFeatureCustomCommandTests { repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State() ) - state.selectedCustomCommands = [ + state.selectedCustomCommands = effective( UserCustomCommand( title: "Blank", systemImage: " ", @@ -359,7 +404,7 @@ struct AppFeatureCustomCommandTests { execution: .shellScript, shortcut: nil, ) - ] + ) let store = TestStore(initialState: state) { AppFeature() @@ -369,7 +414,7 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.runCustomCommand(0)) + await store.send(.runCustomCommand(state.selectedCustomCommands[0].id)) await store.finish() #expect( @@ -417,12 +462,12 @@ struct AppFeatureCustomCommandTests { } await store.send(.worktreeUserSettingsLoaded(settings, worktreeID: worktree.id)) { - $0.selectedCustomCommands = settings.customCommands + $0.selectedCustomCommands = effective(settings.customCommands) $0.resolvedKeybindings = KeybindingResolver.resolve( - schema: .appResolverSchema(customCommands: settings.customCommands), + schema: .appResolverSchema(effectiveCustomCommands: effective(settings.customCommands)), migratedOverrides: LegacyCustomCommandShortcutMigration - .migrate(commands: settings.customCommands) + .migrate(commands: effective(settings.customCommands)) .overrides ) } @@ -437,7 +482,7 @@ struct AppFeatureCustomCommandTests { repositories: repositories, settings: SettingsFeature.State() ) - state.selectedCustomCommands = [ + state.selectedCustomCommands = effective( UserCustomCommand( title: "Canvas Build", systemImage: "hammer", @@ -445,7 +490,7 @@ struct AppFeatureCustomCommandTests { execution: .shellScript, shortcut: nil ) - ] + ) let store = TestStore(initialState: state) { AppFeature() @@ -456,7 +501,7 @@ struct AppFeatureCustomCommandTests { } } - await store.send(.runCustomCommand(0)) + await store.send(.runCustomCommand(state.selectedCustomCommands[0].id)) await store.finish() #expect( @@ -509,12 +554,12 @@ struct AppFeatureCustomCommandTests { // pass so the toolbar updates atomically with the focus change. await store.send(.canvasFocusedWorktreeChanged(worktree.id)) { $0.openActionSelection = expectedDefaultOpenAction() - $0.selectedCustomCommands = settings.customCommands + $0.selectedCustomCommands = effective(settings.customCommands) $0.resolvedKeybindings = KeybindingResolver.resolve( - schema: .appResolverSchema(customCommands: settings.customCommands), + schema: .appResolverSchema(effectiveCustomCommands: effective(settings.customCommands)), migratedOverrides: LegacyCustomCommandShortcutMigration - .migrate(commands: settings.customCommands) + .migrate(commands: effective(settings.customCommands)) .overrides ) } @@ -558,7 +603,7 @@ struct AppFeatureCustomCommandTests { settings: SettingsFeature.State() ) state.selectedRunScript = "npm run repo-a" - state.selectedCustomCommands = [commandA] + state.selectedCustomCommands = effective(commandA) return TestStore(initialState: state) { AppFeature() @@ -573,18 +618,18 @@ struct AppFeatureCustomCommandTests { await store.send(.canvasFocusedWorktreeChanged(worktreeB.id)) { $0.openActionSelection = expectedDefaultOpenAction() $0.selectedRunScript = "npm run repo-b" - $0.selectedCustomCommands = [commandB] + $0.selectedCustomCommands = effective(commandB) $0.resolvedKeybindings = KeybindingResolver.resolve( - schema: .appResolverSchema(customCommands: [commandB]), + schema: .appResolverSchema(effectiveCustomCommands: effective(commandB)), migratedOverrides: LegacyCustomCommandShortcutMigration - .migrate(commands: [commandB]) + .migrate(commands: effective(commandB)) .overrides ) } await store.send(.runScript) - await store.send(.runCustomCommand(0)) + await store.send(.runCustomCommand(store.state.selectedCustomCommands[0].id)) await store.finish() #expect( @@ -606,6 +651,14 @@ struct AppFeatureCustomCommandTests { makeWorktree(id: "/tmp/repo/wt-1", name: "wt-1", repoRoot: "/tmp/repo") } + private func effective(_ commands: UserCustomCommand...) -> [EffectiveCustomCommand] { + effective(commands) + } + + private func effective(_ commands: [UserCustomCommand]) -> [EffectiveCustomCommand] { + commands.map { EffectiveCustomCommand(source: .repository, command: $0) } + } + private func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { Worktree( id: id, diff --git a/supacodeTests/AppFeaturePlainFolderTerminalTests.swift b/supacodeTests/AppFeaturePlainFolderTerminalTests.swift index 42a48dbd..a5932169 100644 --- a/supacodeTests/AppFeaturePlainFolderTerminalTests.swift +++ b/supacodeTests/AppFeaturePlainFolderTerminalTests.swift @@ -74,7 +74,9 @@ struct AppFeaturePlainFolderTerminalTests { $0.selectedRunScript = "pnpm dev" } await store.receive(\.worktreeUserSettingsLoaded) { - $0.selectedCustomCommands = userSettings.customCommands + $0.selectedCustomCommands = userSettings.customCommands.map { + EffectiveCustomCommand(source: .repository, command: $0) + } $0.resolvedKeybindings = KeybindingResolver.resolve( schema: .appResolverSchema(customCommands: userSettings.customCommands) ) @@ -162,7 +164,7 @@ struct AppFeaturePlainFolderTerminalTests { await store.send(.worktreeUserSettingsLoaded(conflicted, worktreeID: repository.id)) let expectedShortcut = conflicted.customCommands[0].shortcut?.normalized() - #expect(store.state.selectedCustomCommands == conflicted.customCommands) + #expect(store.state.selectedCustomCommands.map(\.command) == conflicted.customCommands) #expect(registeredShortcuts.value == [expectedShortcut].compactMap { $0 }) let customCommandID = LegacyCustomCommandShortcutMigration.customCommandBindingID( for: conflicted.customCommands[0].id @@ -188,13 +190,15 @@ struct AppFeaturePlainFolderTerminalTests { settings: SettingsFeature.State() ) state.selectedCustomCommands = [ - UserCustomCommand( - title: "Watch", - systemImage: "terminal", - command: "pnpm test --watch", - execution: .terminalInput, - shortcut: nil - ) + EffectiveCustomCommand( + source: .repository, + command: UserCustomCommand( + title: "Watch", + systemImage: "terminal", + command: "pnpm test --watch", + execution: .terminalInput, + shortcut: nil + )) ] let store = TestStore(initialState: state) { AppFeature() @@ -204,7 +208,7 @@ struct AppFeaturePlainFolderTerminalTests { } } - await store.send(.runCustomCommand(0)) + await store.send(.runCustomCommand(state.selectedCustomCommands[0].id)) await store.finish() #expect( diff --git a/supacodeTests/CommandPaletteFeatureTests.swift b/supacodeTests/CommandPaletteFeatureTests.swift index c553c960..0c0acaed 100644 --- a/supacodeTests/CommandPaletteFeatureTests.swift +++ b/supacodeTests/CommandPaletteFeatureTests.swift @@ -235,20 +235,26 @@ struct CommandPaletteFeatureTests { let items = CommandPaletteFeature.commandPaletteItems( from: RepositoriesFeature.State(), - customCommands: [buildCmd, emptyCmd] + customCommands: [ + EffectiveCustomCommand(source: .repository, command: buildCmd), + EffectiveCustomCommand(source: .repository, command: emptyCmd), + ] ) let ids = Set(items.map(\.id)) - #expect(ids.contains("custom-command.cmd-build")) + #expect(ids.contains("custom-command.repository.cmd-build")) // Commands with empty body are filtered out. - #expect(!ids.contains("custom-command.cmd-empty")) + #expect(!ids.contains("custom-command.repository.cmd-empty")) - let buildItem = items.first { $0.id == "custom-command.cmd-build" } + let buildItem = items.first { $0.id == "custom-command.repository.cmd-build" } #expect(buildItem?.title == "Build") - #expect(buildItem?.subtitle == "Custom command in this repo · Opens in a new tab") + #expect(buildItem?.subtitle == "Local custom command · Opens in a new tab") #expect(buildItem?.defaultSuggestion == false) #expect( buildItem?.kind - == .runCustomCommand(index: 0, commandID: "cmd-build", systemImage: "hammer") + == .runCustomCommand( + .init(source: .repository, commandID: "cmd-build"), + systemImage: "hammer" + ) ) } @@ -313,20 +319,22 @@ struct CommandPaletteFeatureTests { let items = CommandPaletteFeature.commandPaletteItems( from: RepositoriesFeature.State(), - customCommands: [shellCmd, inlineCmd, splitCmd] + customCommands: [shellCmd, inlineCmd, splitCmd].map { + EffectiveCustomCommand(source: .repository, command: $0) + } ) #expect( - items.first { $0.id == "custom-command.cmd-shell" }?.subtitle - == "Custom command in this repo · Opens in a new tab" + items.first { $0.id == "custom-command.repository.cmd-shell" }?.subtitle + == "Local custom command · Opens in a new tab" ) #expect( - items.first { $0.id == "custom-command.cmd-inline" }?.subtitle - == "Custom command in this repo · Runs in the focused terminal" + items.first { $0.id == "custom-command.repository.cmd-inline" }?.subtitle + == "Local custom command · Runs in the focused terminal" ) #expect( - items.first { $0.id == "custom-command.cmd-split" }?.subtitle - == "Custom command in this repo · Opens in a new split (down)" + items.first { $0.id == "custom-command.repository.cmd-split" }?.subtitle + == "Local custom command · Opens in a new split (down)" ) } diff --git a/supacodeTests/EffectiveCustomCommandTests.swift b/supacodeTests/EffectiveCustomCommandTests.swift new file mode 100644 index 00000000..9e2e4f32 --- /dev/null +++ b/supacodeTests/EffectiveCustomCommandTests.swift @@ -0,0 +1,46 @@ +import Testing + +@testable import supacode + +struct EffectiveCustomCommandTests { + @Test func localCommandsKeepOrderAndHideMatchingGlobalTitles() { + let localBuild = command(id: "local-build", title: " Build ") + let localTest = command(id: "local-test", title: "Test") + let hiddenGlobalBuild = command(id: "global-build", title: "build") + let globalLint = command(id: "global-lint", title: "Lint") + + let resolved = EffectiveCustomCommand.resolve( + repositoryCommands: [localBuild, localTest], + globalCommands: [hiddenGlobalBuild, globalLint] + ) + + #expect( + resolved.map(\.id) == [ + .init(source: .repository, commandID: "local-build"), + .init(source: .repository, commandID: "local-test"), + .init(source: .global, commandID: "global-lint"), + ]) + } + + @Test func sourceQualifiedIdentityPreventsLocalGlobalUUIDCollisions() { + let local = EffectiveCustomCommand(source: .repository, command: command(id: "same", title: "Local")) + let global = EffectiveCustomCommand(source: .global, command: command(id: "same", title: "Global")) + + #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(global.paletteID == "custom-command.global.same") + } + + private func command(id: String, title: String) -> UserCustomCommand { + UserCustomCommand( + id: id, + title: title, + systemImage: "terminal", + command: "echo \(title)", + execution: .shellScript, + shortcut: nil + ) + } +} diff --git a/supacodeTests/ExternalDiffToolTests.swift b/supacodeTests/ExternalDiffToolTests.swift index 97b57b02..3eea0d6a 100644 --- a/supacodeTests/ExternalDiffToolTests.swift +++ b/supacodeTests/ExternalDiffToolTests.swift @@ -140,7 +140,7 @@ struct ExternalDiffToolTests { try runGit(["config", "user.name", "Test User"], in: repoURL) try "one\n".write(to: repoURL.appending(path: "tracked.txt"), atomically: true, encoding: .utf8) try runGit(["add", "tracked.txt"], in: repoURL) - try runGit(["commit", "-m", "Initial"], in: repoURL) + try runGit(["commit", "--no-verify", "-m", "Initial"], in: repoURL) try "two\n".write(to: repoURL.appending(path: "tracked.txt"), atomically: true, encoding: .utf8) try "new\n".write(to: repoURL.appending(path: "untracked.txt"), atomically: true, encoding: .utf8) diff --git a/supacodeTests/KeybindingSchemaTests.swift b/supacodeTests/KeybindingSchemaTests.swift index 452d5959..380f3291 100644 --- a/supacodeTests/KeybindingSchemaTests.swift +++ b/supacodeTests/KeybindingSchemaTests.swift @@ -6,6 +6,36 @@ import Testing @MainActor struct KeybindingSchemaTests { + @Test func globalCustomCommandsUseSeparateBindingNamespace() { + let local = EffectiveCustomCommand( + source: .repository, + command: UserCustomCommand( + id: "build", + title: "Build", + systemImage: "hammer", + command: "make build", + execution: .shellScript, + shortcut: nil + ) + ) + let global = EffectiveCustomCommand( + source: .global, + command: UserCustomCommand( + id: "build", + title: "Build Everywhere", + systemImage: "globe", + command: "make build", + execution: .shellScript, + shortcut: nil + ) + ) + + let schema = KeybindingSchemaDocument.appResolverSchema(effectiveCustomCommands: [local, global]) + + #expect(schema.commands.map(\.id).contains("custom_command.build")) + #expect(schema.commands.map(\.id).contains("custom_command.global.build")) + } + @Test func schemaEncodeDecodeRoundTripsWithVersion() throws { let schema = KeybindingSchemaDocument( version: 1, diff --git a/supacodeTests/UserGlobalSettingsKeyTests.swift b/supacodeTests/UserGlobalSettingsKeyTests.swift new file mode 100644 index 00000000..766ff101 --- /dev/null +++ b/supacodeTests/UserGlobalSettingsKeyTests.swift @@ -0,0 +1,84 @@ +import Dependencies +import DependenciesTestSupport +import Foundation +import Sharing +import Testing + +@testable import supacode + +struct UserGlobalSettingsKeyTests { + @Test(.dependencies) func missingFileCreatesDefaultGlobalSettings() throws { + let storage = UserGlobalSettingsTestStorage() + let url = URL(fileURLWithPath: "/tmp/prowl-global-settings-\(UUID().uuidString).json") + + let loaded = withDependencies { + $0.settingsFileStorage = storage.storage + $0.userGlobalSettingsURL = url + } operation: { + @Shared(.userGlobalSettings) var settings: UserGlobalSettings + return settings + } + + #expect(loaded == .default) + #expect(try JSONDecoder().decode(UserGlobalSettings.self, from: #require(storage.data(at: url))) == .default) + } + + @Test(.dependencies) func savingWritesOnlyTheGlobalSettingsFile() throws { + let storage = UserGlobalSettingsTestStorage() + let url = URL(fileURLWithPath: "/tmp/prowl-global-settings-\(UUID().uuidString).json") + let settings = UserGlobalSettings(customCommands: [ + UserCustomCommand( + id: "global-build", + title: "Build", + systemImage: "hammer", + command: "make build", + execution: .split, + splitDirection: .down, + closeOnSuccess: true, + shortcut: UserCustomShortcut(key: "b", modifiers: .init(command: true, shift: true)) + ) + ]) + + withDependencies { + $0.settingsFileStorage = storage.storage + $0.userGlobalSettingsURL = url + } operation: { + @Shared(.userGlobalSettings) var storedSettings: UserGlobalSettings + $storedSettings.withLock { $0 = settings } + } + + #expect(try JSONDecoder().decode(UserGlobalSettings.self, from: #require(storage.data(at: url))) == settings) + } +} + +nonisolated final class UserGlobalSettingsTestStorage: @unchecked Sendable { + private let lock = NSLock() + private var dataByURL: [URL: Data] = [:] + + var storage: SettingsFileStorage { + SettingsFileStorage(load: { try self.load($0) }, save: { try self.save($0, at: $1) }) + } + + func data(at url: URL) -> Data? { + lock.lock() + defer { lock.unlock() } + return dataByURL[url] + } + + private func load(_ url: URL) throws -> Data { + lock.lock() + defer { lock.unlock() } + guard let data = dataByURL[url] else { throw UserGlobalSettingsTestStorageError.missing } + return data + } + + private func save(_ data: Data, at url: URL) throws { + lock.lock() + defer { lock.unlock() } + dataByURL[url] = data + } +} + +private enum UserGlobalSettingsTestStorageError: Error { + case missing +} -- 2.51.2 From 161cbd26f28cbc3cbd4e4c695dfda18087d8bda8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 13 Jul 2026 10:15:03 +0900 Subject: [PATCH 2/6] docs: sync global command documentation --- .../005-global-custom-commands-action.md | 30 +++++++++++++++++++ docs/.sync-meta.json | 6 ++-- 2 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 docs-ai/002-custom-commands/005-global-custom-commands-action.md 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 new file mode 100644 index 00000000..8bc9fd6b --- /dev/null +++ b/docs-ai/002-custom-commands/005-global-custom-commands-action.md @@ -0,0 +1,30 @@ +# 002.005 — Global Custom Commands: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-07-13 | Added global command storage, resolution, settings UI, source-qualified routing, tests, and manual documentation. | `f481db26` | + +## Outcome & current state (as of 2026-07-13) + +- `UserGlobalSettings` persists `customCommands` in `~/.prowl/global.onevcat.json` through + `UserGlobalSettingsKey`. +- `EffectiveCustomCommand` resolves local commands before global commands and suppresses a + global command when a trimmed, case-insensitive local title matches. +- `AppFeature`, `WorktreeCommands`, `WorktreeDetailView`, and `CommandPaletteFeature` use + source-qualified command identity. Local bindings retain `custom_command.` and + global bindings use `custom_command.global.`; a local shortcut collision disables + the global binding. +- Settings → Custom Commands edits the global list. The toolbar marks global entries and + the overflow menu receives the source-qualified commands. + +## 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. + +## Open questions + +None. diff --git a/docs/.sync-meta.json b/docs/.sync-meta.json index d8d7e5cd..e007528c 100644 --- a/docs/.sync-meta.json +++ b/docs/.sync-meta.json @@ -1,6 +1,6 @@ { "_comment": "Metadata for the docs/ manual. The last_synced_commit is the commit at which docs/ was last verified against the implementation; the sync-docs skill (.claude/skills/sync-docs/SKILL.md) diffs HEAD against it and updates this file. Committed to git on purpose so the baseline persists across sessions and machines. A dotfile + .json so a future docs website does not render it as a page.", - "last_synced_commit": "168d8e9c8e61655f57dcb8411092b0af9263e7e0", - "last_synced_date": "2026-07-10", - "note": "Release prep. All user-facing changes in range were doc-synced in their own PRs (#545 sound picker, #546 mute viewed surface, #542 editor additions, #543 TERM_PROGRAM, #541 gh hardening); #544 symlink-preserving writes and PR tri-state fixes need no doc changes." + "last_synced_commit": "f481db268318eb52963b567b03e7c612f7a94ada", + "last_synced_date": "2026-07-13", + "note": "Verified global Custom Commands, including Settings, toolbar/menu, palette, local precedence, storage, and shortcut behavior." } -- 2.51.2 From 82c40f3bcb115c2a831a073057807915ee8d700f Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 13 Jul 2026 10:15:40 +0900 Subject: [PATCH 3/6] docs: correct global command toolbar details --- docs/components/custom-actions.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/components/custom-actions.md b/docs/components/custom-actions.md index 2954cae7..306eb936 100644 --- a/docs/components/custom-actions.md +++ b/docs/components/custom-actions.md @@ -69,9 +69,8 @@ shortcuts**; conflicts (with reserved app actions or other custom commands) are detected when you record the key, and you choose Replace / Cancel. **Where they appear:** as buttons in the UI, in the Worktrees menu, and in the -[Command Palette](command-palette.md). The overflow menu groups Local and Global -entries, while global toolbar entries carry a globe marker. Global commands are stored -in `~/.prowl/global.onevcat.json`; local commands remain in +[Command Palette](command-palette.md). Global toolbar entries carry a globe marker. +Global commands are stored in `~/.prowl/global.onevcat.json`; local commands remain in `~/.prowl/repo//prowl.onevcat.json`. ## Example uses -- 2.51.2 From 4a101dee41816d02e80e442235021637322365db Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 19 Jul 2026 00:37:09 +0900 Subject: [PATCH 4/6] refactor: share the custom command editor with global settings Extract the repository custom command table into CustomCommandsEditor and reuse it for Settings -> Custom Commands, replacing the compact form that had no reachable delete affordance on macOS. Global commands now get the same icon picker, inline name/script editing, shortcut recording with Replace/Cancel conflict handling, and explicit +/- row management. - Parameterize the editor by CustomCommandSource so shortcut resolution targets the matching keybinding namespace; the loaded keybinding overrides now drive shortcut display in the global editor - Drop the now-unused addCommand/removeCommands actions; edits persist through the existing binding path - Keep repository palette IDs in the legacy custom-command. form so palette recency survives; globals use custom-command.global. - Rename the runCustomCommand binding from index to commandID Claude-Session: https://claude.ai/code/session_01CLXvQhjmJ7avnPfx6FFG2E --- .../005-global-custom-commands-action.md | 16 +- .../Features/App/Reducer/AppFeature.swift | 4 +- .../Models/UserRepositorySettings.swift | 8 +- .../Reducer/GlobalCustomCommandsFeature.swift | 11 - ...sView.swift => CustomCommandsEditor.swift} | 280 ++++++++++++------ .../Views/GlobalCustomCommandsView.swift | 101 +------ .../Views/RepositorySettingsView.swift | 104 +------ .../CommandPaletteFeatureTests.swift | 12 +- .../EffectiveCustomCommandTests.swift | 2 +- .../GlobalCustomCommandsFeatureTests.swift | 68 +++++ 10 files changed, 308 insertions(+), 298 deletions(-) rename supacode/Features/Settings/Views/{RepositorySettingsCustomCommandsView.swift => CustomCommandsEditor.swift} (72%) create mode 100644 supacodeTests/GlobalCustomCommandsFeatureTests.swift 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 + ) + } +} -- 2.51.2 From 1eb5ccac91af80b21c602c994679ccc1d18d674a Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 19 Jul 2026 01:17:28 +0900 Subject: [PATCH 5/6] fix: drop the globe marker from global command toolbar buttons Global commands now render identically to local ones everywhere; the source is only surfaced in hover tooltips ("Defined as a global command") on toolbar buttons, overflow rows, and Worktrees menu items. Disabled global buttons point at Settings -> Custom Commands instead of Repository Settings. Claude-Session: https://claude.ai/code/session_01CLXvQhjmJ7avnPfx6FFG2E --- .../005-global-custom-commands-action.md | 5 +-- docs/components/custom-actions.md | 3 +- supacode/Commands/WorktreeCommands.swift | 19 ++++++---- .../Views/WorktreeDetailToolbarViews.swift | 35 +++++++++++++------ .../Views/WorktreeDetailView.swift | 4 +-- .../Models/UserRepositorySettings.swift | 6 ++-- 6 files changed, 49 insertions(+), 23 deletions(-) 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 3b1e1f2f..983c3c79 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 @@ -17,8 +17,9 @@ source-qualified command identity. Local bindings retain `custom_command.` and global bindings use `custom_command.global.`; a local shortcut collision disables the global binding. -- Settings → Custom Commands edits the global list. The toolbar marks global entries and - the overflow menu receives the source-qualified commands. +- Settings → Custom Commands edits the global list. Toolbar, overflow, and menu entries + render identically for both sources; the global origin is only surfaced in hover + tooltips ("Defined as a global command") after onevcat rejected a visible globe marker. - Both settings surfaces embed `CustomCommandsEditor` (`supacode/Features/Settings/Views/CustomCommandsEditor.swift`), parameterized by `CustomCommandSource` so shortcut resolution targets the right binding namespace. The diff --git a/docs/components/custom-actions.md b/docs/components/custom-actions.md index 306eb936..d4a2a269 100644 --- a/docs/components/custom-actions.md +++ b/docs/components/custom-actions.md @@ -69,7 +69,8 @@ shortcuts**; conflicts (with reserved app actions or other custom commands) are detected when you record the key, and you choose Replace / Cancel. **Where they appear:** as buttons in the UI, in the Worktrees menu, and in the -[Command Palette](command-palette.md). Global toolbar entries carry a globe marker. +[Command Palette](command-palette.md). Global entries look identical to local ones; +hovering shows a "Defined as a global command" tooltip note. Global commands are stored in `~/.prowl/global.onevcat.json`; local commands remain in `~/.prowl/repo//prowl.onevcat.json`. diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 477dbfae..1584b471 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -261,19 +261,26 @@ struct WorktreeCommands: Commands { command: EffectiveCustomCommand, hasActiveWorktree: Bool ) -> some View { + Button(command.command.resolvedTitle, systemImage: command.command.resolvedSystemImage) { + store.send(.runCustomCommand(command.id)) + } + .modifier(KeyboardShortcutModifier(shortcut: customCommandShortcut(for: command))) + .help(customCommandHelpText(for: command)) + .disabled(!hasActiveWorktree) + } + + private func customCommandHelpText(for command: EffectiveCustomCommand) -> String { let title = command.command.resolvedTitle - let helpText: String = + var helpText: String = if let shortcut = customCommandShortcutDisplay(for: command) { "\(title) (\(shortcut))" } else { title } - Button(title, systemImage: command.command.resolvedSystemImage) { - store.send(.runCustomCommand(command.id)) + if let note = command.source.tooltipNote { + helpText += " — \(note)" } - .modifier(KeyboardShortcutModifier(shortcut: customCommandShortcut(for: command))) - .help(helpText) - .disabled(!hasActiveWorktree) + return helpText } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift index 1d389df7..16de221e 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift @@ -122,7 +122,7 @@ struct RunScriptToolbarButton: View { struct UserCustomCommandToolbarButton: View { let title: String let systemImage: String - let sourceSystemImage: String? + let source: CustomCommandSource let shortcut: String? let isEnabled: Bool let action: () -> Void @@ -135,12 +135,6 @@ struct UserCustomCommandToolbarButton: View { HStack(spacing: 6) { Image(systemName: systemImage) .accessibilityHidden(true) - if let sourceSystemImage { - Image(systemName: sourceSystemImage) - .imageScale(.small) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } Text(title) if commandKeyObserver.isPressed, let shortcut { Text(shortcut) @@ -156,12 +150,21 @@ struct UserCustomCommandToolbarButton: View { private var helpText: String { guard isEnabled else { - return "\(title) (Set command script in Repository Settings)" + switch source { + case .repository: + return "\(title) (Set command script in Repository Settings)" + case .global: + return "\(title) (Set command script in Settings → Custom Commands)" + } } + var text = title if let shortcut { - return "\(title) (\(shortcut))" + text = "\(title) (\(shortcut))" + } + if let note = source.tooltipNote { + text += " — \(note)" } - return title + return text } } @@ -211,6 +214,7 @@ struct CustomCommandOverflowButton: View { } .buttonStyle(.plain) .disabled(!entry.command.hasRunnableCommand) + .help(helpText(for: entry)) } } .padding(8) @@ -223,6 +227,17 @@ struct CustomCommandOverflowButton: View { let visibleRows = min(maxVisibleRows, max(entries.count, 1)) return CGFloat(visibleRows) * 32 + 16 } + + private func helpText(for entry: EffectiveCustomCommand) -> String { + var text = entry.command.resolvedTitle + if let shortcut = shortcutDisplay(entry) { + text = "\(text) (\(shortcut))" + } + if let note = entry.source.tooltipNote { + text += " — \(note)" + } + return text + } } @MainActor diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 61e3fa88..c38c455f 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -318,7 +318,7 @@ struct WorktreeDetailView: View { UserCustomCommandToolbarButton( title: command.command.resolvedTitle, systemImage: command.command.resolvedSystemImage, - sourceSystemImage: command.source.toolbarSystemImage, + source: command.source, shortcut: store.resolvedKeybindings.display( for: command.keybindingID ), @@ -1018,7 +1018,7 @@ struct WorktreeDetailView: View { UserCustomCommandToolbarButton( title: command.command.resolvedTitle, systemImage: command.command.resolvedSystemImage, - sourceSystemImage: command.source.toolbarSystemImage, + source: command.source, shortcut: customCommandShortcutDisplay(for: command), isEnabled: command.command.hasRunnableCommand, action: { diff --git a/supacode/Features/Settings/Models/UserRepositorySettings.swift b/supacode/Features/Settings/Models/UserRepositorySettings.swift index 077782c3..fefab488 100644 --- a/supacode/Features/Settings/Models/UserRepositorySettings.swift +++ b/supacode/Features/Settings/Models/UserRepositorySettings.swift @@ -135,10 +135,12 @@ nonisolated enum CustomCommandSource: String, Codable, CaseIterable, Equatable, } } - var toolbarSystemImage: String? { + /// Extra sentence appended to hover tooltips; global commands are only + /// distinguished there so toolbar buttons stay visually uniform. + var tooltipNote: String? { switch self { case .repository: nil - case .global: "globe" + case .global: "Defined as a global command" } } } -- 2.51.2 From cf3a0981cec35da4dae885bbb6e9bcec98cc35ca Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 19 Jul 2026 14:39:19 +0900 Subject: [PATCH 6/6] fix: shorten the global commands settings tab to Commands Every other sidebar tab is a single word; the detail pane title and doc references to the tab follow suit. The in-form section header keeps the full "Custom Commands" feature name. Claude-Session: https://claude.ai/code/session_01CLXvQhjmJ7avnPfx6FFG2E --- .../002-custom-commands/005-global-custom-commands-action.md | 2 +- docs/components/custom-actions.md | 4 ++-- docs/components/settings.md | 2 +- .../Repositories/Views/WorktreeDetailToolbarViews.swift | 2 +- supacode/Features/Settings/Views/SettingsView.swift | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) 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 983c3c79..033f56d4 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 @@ -17,7 +17,7 @@ source-qualified command identity. Local bindings retain `custom_command.` and global bindings use `custom_command.global.`; a local shortcut collision disables the global binding. -- Settings → Custom Commands edits the global list. Toolbar, overflow, and menu entries +- Settings → Commands edits the global list. Toolbar, overflow, and menu entries render identically for both sources; the global origin is only surfaced in hover tooltips ("Defined as a global command") after onevcat rejected a visible globe marker. - Both settings surfaces embed `CustomCommandsEditor` diff --git a/docs/components/custom-actions.md b/docs/components/custom-actions.md index d4a2a269..6f5a5823 100644 --- a/docs/components/custom-actions.md +++ b/docs/components/custom-actions.md @@ -16,7 +16,7 @@ Prowl has four distinct mechanisms for "run my command": | **Run Script** | one per repo | on demand (`⌘R`) | Repo Settings → Run Script | | **Setup Script** | one per repo | automatically after a worktree is created | Repo Settings → Setup Script | | **Archive Script** | one per repo | automatically before a worktree is archived | Repo Settings → Archive Script | -| **Custom Commands** | many, global or per repo | on demand (button / hotkey / palette) | Settings → Custom Commands or Repo Settings → Custom Commands | +| **Custom Commands** | many, global or per repo | on demand (button / hotkey / palette) | Settings → Commands or Repo Settings → Custom Commands | All scripts run with these **environment variables** injected: - `PROWL_WORKTREE_PATH` — the active worktree's directory. @@ -86,7 +86,7 @@ Global commands are stored in `~/.prowl/global.onevcat.json`; local commands rem - Per repo (Repo Settings): `runScript`, `setupScript`, `archiveScript`, and local Custom Commands. -- Global (Settings → Custom Commands): global Custom Commands shared by every repo. +- Global (Settings → Commands): global Custom Commands shared by every repo. - Global: `showRunButtonInToolbar`, `showDefaultEditorInToolbar`. ## Gotchas for agents diff --git a/docs/components/settings.md b/docs/components/settings.md index 53f35e17..0217d653 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -23,7 +23,7 @@ window is a sidebar of tabs plus a detail pane. | **Updates** | Auto-check toggle, "Check for Updates Now". → [updates](updates.md) | | **Advanced** | Analytics, crash reports, restore terminal layout on launch (experimental) + clear saved layout, and the **Install Command Line Tool** (`prowl` CLI) action. | | **GitHub** | Enable GitHub integration (uses the `gh` CLI). → [github-pull-requests](github-pull-requests.md) | -| **Custom Commands** | Global Custom Commands available in every repository. Local commands with the same title take precedence. → [custom-actions](custom-actions.md) | +| **Commands** | Global Custom Commands available in every repository. Local commands with the same title take precedence. → [custom-actions](custom-actions.md) | | **Repositories / Repo Settings** | Per-repository: setup/archive/run scripts, **Custom Commands**, default base ref & directory, copy-files overrides, open-with app, custom title, icon & color, PR merge strategy, line-diff & PR-state fetching. Reached from the sidebar context menu → "Repo Settings". → [custom-actions](custom-actions.md), [repositories-and-worktrees](repositories-and-worktrees.md) | ## Where settings live on disk diff --git a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift index 16de221e..814bb8c8 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift @@ -154,7 +154,7 @@ struct UserCustomCommandToolbarButton: View { case .repository: return "\(title) (Set command script in Repository Settings)" case .global: - return "\(title) (Set command script in Settings → Custom Commands)" + return "\(title) (Set command script in Settings → Commands)" } } var text = title diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 2c7a69b7..868b6828 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -44,7 +44,7 @@ struct SettingsView: View { .tag(SettingsSection.advanced) Label("GitHub", systemImage: "arrow.triangle.branch") .tag(SettingsSection.github) - Label("Custom Commands", systemImage: "globe") + Label("Commands", systemImage: "globe") .tag(SettingsSection.customCommands) Section("Repositories") { @@ -113,7 +113,7 @@ struct SettingsView: View { action: \.globalCustomCommands ) { GlobalCustomCommandsView(store: globalCustomCommandsStore) - .navigationTitle("Custom Commands") + .navigationTitle("Commands") .navigationSubtitle("Global terminal actions") } else { ProgressView()