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 +}